资讯动态

Java集合框架与String底层原理:从HashMap到字符串不可变与常量池

发布时间:2026/10/15 22:55:52 来源:尧图企业网站定制
在 Java 集合框架里折腾到 Map 和 Set这就算把线性表和单值集合这条线走完了。如果你还在纠结 HashMap 和 HashSet 到底是什么关系、LinkedHashMap 到底有什么用或者被面试时那道HashMap 的容量为什么是 2 的幂次问住了那这篇文章就是为你收尾的。后面我们还要正式开启字符串 String 系列我记得自己从处理业务日志到写算法题再到后来调线上接口性能一大半时间都花在了字符串拼接、分割、格式化和比较的取舍上。String 看起来人人会用但真正把它讲明白一篇是不够的这一篇我们先解决最核心的两件事它凭什么不可变以及常量池到底是怎么运作的。先说一次真实经历。某次线上排查一个内存增长的问题吃掉了不少堆内存。同事查了一圈发现罪魁祸首是日志框架里不断拼接大段字符串排查过程中我们就聊到了 String 的不可变性、substring 的老问题和常量池的滥用。这些知识点平时不显眼但真正出事的时候你才知道自己是不是只是会用而不是理解。所以这系列文章想达到的目标很简单让你既能写对能跑的代码也能在別人问起底层机制的时候有理有据地讲出个所以然来。适合正在学集合框架的初学者也适合准备面试、想系统梳理一遍的开发者。1. Map 和 Set 的收尾底层原来是同一回事1.1 HashSet 就是穿了马甲的 HashMap第一次知道 HashSet 底层居然直接 new 了一个 HashMap说实话我是有点意外的。你看名字上 Hash 开头的两个类一个管键值对一个只管单值好像八竿子打不着。但打开 JDK 源码注释和构造方法写得明明白白HashSet 内部维护了一个 HashMap你往 HashSet 里 add 的元素其实是被当成 HashMap 的 key 存进去的value 则统一用一个静态的 dummy 对象占位。// 这是 JDK 中的真实设计简化示意如下 public class HashSetE extends AbstractSetE { private HashMapE, Object map; private static final Object PRESENT new Object(); public boolean add(E e) { return map.put(e, PRESENT) null; } }这一下就解释了好多事情。为什么 HashSet 的元素不能重复因为 HashMap 的 key 不能重复你往里面放相同的东西第二次 put 时只会覆盖 value而返回的旧值不等于 nulladd 就返回 false。为什么 HashSet 的遍历顺序看起来毫无规律因为那是 HashMap 的哈希桶顺序不是插入顺序也不是自然排序。为什么 HashSet 允许一个 null 元素因为 HashMap 的 key 本身允许一个 null。理解了这层关系你就不会被面试题绕晕也更容易从整体上记忆 API。HashMap 允许 null 的 key 和 value那 HashSet 自然允许 null 元素TreeMap 不允许 null 的 key那 TreeSet 同理也不允许。你不需要单独背每个类允许什么、不允许什么只需要记住它们底层是谁规矩就是谁的。1.2 哈希冲突、负载因子和扩容是怎么配合的HashMap 底层是一个数组数组每个位置是一个桶。put 一个键值对时先算 key 的 hashCode再做一次扰动然后定位到数组下标。如果这个位置上已经有元素了就叫哈希冲突后续的元素会被挂成链表链表太长时再转成红黑树。这里面的关键参数有三个初始容量、负载因子、树化阈值。负载因子默认是 0.75这一句话引出了很多问题。负载因子 0.75 指的不是数组用了 75% 就扩容而是当前元素数量大于等于容量乘以负载因子的时候才扩容。举个例子默认容量是 16那么当元素数量达到 12 时触发扩容而不是每个桶都占了才算满。为什么是 0.75 而不是 0.5 或者 1.00.5 意味着空间浪费严重1.0 则意味着冲突概率大幅上升读写性能会明显退化0.75 是时间与空间的折中这是 JDK 作者基于大量测试给出的经验值。再说树化阈值。链表长度为 8 且数组长度至少是 64 的时候链表会转成红黑树。为什么要两条条件同时满足如果数组只有 16 或 32那说明元素都挤在了少数几个桶里根本原因是容量太小这时候正确做法是扩容而不是转红黑树。树节点差不多是普通节点的两倍大小很占内存只有桶里真的有 8 个以上元素并且整体哈希分布还算合理时转树才有性价比。面试常问的为什么是 8JDK 源码注释里有一段泊松分布的推导大意是负载因子 0.75 的前提下正常数据分布下桶内节点数量到达 8 的概率已经非常小真出现了那多半是 hashCode 写得有问题用树可以兜底防极端攻击。1.3 LinkedHashMap 和 TreeMap 各自管哪种顺序好多人在学习时只盯着 HashMap真到用的时候才发现还有两个兄弟要认识一下。LinkedHashMap 是 HashMap 加了一条双向链表这条链表维护了节点的顺序。它有两种模式插入顺序和访问顺序。默认是插入顺序也就是你 put 进去的顺序遍历时会按这个顺序输出这在很多场景下非常实用比如你要一个缓存希望淘汰最久没被访问的那个元素就把 accessOrder 设为 true每次 get 或 put 都会把节点移到链表尾部头部自然就是最久没用的那个这不就是手写了一个 LRU 缓存的核心骨架吗LinkedHashMapString, Integer accessOrderMap new LinkedHashMap(16, 0.75f, true);TreeMap 的路子完全不一样它基于红黑树所有的 key 都会按自然顺序或者你传入的 Comparator 排序。它没有插入顺序这个概念遍历出来永远是有序的 key。最适合做什么区间查询、范围统计、按大小顺序遍历。比如你有大量分数记录想取出 60 到 80 分之间的所有记录TreeMap 的 subMap 方法一下就能搞定HashMap 做不到这件事。所以选型很容易判断需要快速存取、不在乎顺序选 HashMap需要维护插入顺序或用访问顺序做缓存选 LinkedHashMap需要 key 有序排列和范围操作选 TreeMap。这三者没有谁更好只有谁更合适。1.4 并发环境不要再用 Hashtable 了Hashtable 是最老的线程安全 Map它的做法简单粗暴所有方法都加 synchronized读写全上锁面对现在的多线程场景基本没有并发度可言。日常开发里除非维护祖传老代码否则真不建议再用它。替代方案有两个层面。Collections.synchronizedMap 包装一下锁粒度虽然比 Hashtable 好一点但本质还是整表锁适合简单场景。真正扛并发的是 ConcurrentHashMapJDK 8 之后它把锁粒度细化到单个桶put 时只锁住对应的数组下标读操作完全无锁利用了 volatile 和 CAS。性能上远超 Hashtable语义上也更灵活。如果你要用 String 作为 key这一点尤其要注意并发环境里 ConcurrentHashMap 一般是首选。2. 实战选型Map 和 Set 怎么用才不踩坑2.1 那些年我们犯过的 equals 和 hashCode 错误用 HashMap 或 HashSet 存自定义对象有一道跨不过去的坎必须重写 equals 和 hashCode。很多人偷懒只重写 equals或者两个都不重写结果就是两个字段完全一样的对象看起来相同却能在 HashSet 里存两份在 HashMap 里当成两个不同的 key。根因很简单HashMap 判断 key 是否重复先用 hashCode 定位桶再用 equals 比较桶内元素两个方法缺一个都会出问题。规范说来重写 equals 的字段必须参与 hashCode 的计算。你 equals 比较了 id 和 name那 hashCode 也要用 id 和 name 来算。如果只让 equals 比较 idhashCode 却把整个对象所有字段都算进去就会出现在同一个 HashMap 里equals 相等而 hashCode 不等的情况HashMap 会把它俩分发到不同桶里永远比较不到一起你还是会存出两份重复 key。更隐蔽的问题是如果你用了可变对象当 key放进 Map 之后又修改了它的某个 hash 相关字段它的哈希值变了但它在桶里的位置还是按旧哈希放的get 的时候用新哈希去检索根本找不到内存泄漏还是小事业务数据错乱才是大事。所以实践中有一条硬规矩放进集合作为 key 的对象尽量设计成不可变的字段能 final 就 final。2.2 容量预估和负载因子的填法很多人的习惯是 new HashMap() 空构造一把梭等到数据量大起来了扩容就频繁发生。扩容这个过程很贵要重新计算每个元素的桶位置还要复制过去。你明明知道大概要放 100 万个元素却让 HashMap 从 16 开始一路扩到 1048576这个过程中间要触发多少次扩容简单算一下就知道16 - 32 - 64 - ...大概 17 次每次都有一大段内存的搬移。处理办法是构造时直接给容量。但如果你只是粗略地把元素数量传进去比如要放 7 个元素就 new HashMap(7)这里有个坑HashMap 会把传入的数字向上取整到最近的 2 的幂7 会变成 16并不是你以为的 7 或 8。如果你传的是 12它向上取到 16传 100取到 128。容量取整之后还要结合负载因子算真正的扩容阈值。想存 7 个元素、又不想中途扩容直接 new HashMap(16) 是安全的因为 16 * 0.75 12 7。如果是大量数据业界常用的估算公式是初始容量 数据量 / 负载因子 1比如你要存 100 万个计算得到约 1333334取整后是 2097152这样基本能避开扩容。2.3 HashMap 的遍历方式怎么选才顺手遍历 HashMap 有好几种姿势最常用的就是用 entrySet 拿键值对。Java 8 之后还可以用 forEach 方法直接处理每个 entry代码更紧凑。之前见过一个同事要遍历 Map 的时候先 keySet() 拿所有 key再在循环里 map.get(key) 取值。这个方法不是不行但每次 get 都要重新算一次哈希再查一次桶数据量大了之后double 的时间就出来了。正确的做法要么直接用 entrySet一次拿到 key 和 value要么用 forEach 或迭代器里的 Entry 对象。大数据量下这个差距可以到一倍以上。如果你只是想把 key 当成集合用那 keySet 没问题如果想同时对 key 和 value 做点事别绕路直接 entrySet。要是写 Java 8 风格代码map.forEach((k, v) - ...) 最省事。可有一点要记住纯遍历过程中不要做结构性修改否则会抛 ConcurrentModificationException除非你用的是迭代器的 remove 方法。2.4 用 Map 做统计、去重和缓存的几个片段在实际业务代码里Map 和 Set 最常见的三个用途是统计、去重和缓存。统计词频一行循环就能做MapString, Integer counter new HashMap(); for (String word : wordList) { counter.put(word, counter.getOrDefault(word, 0) 1); }用 Set 去重就更简单了add 几个进去重复的自动被挡在外面。这里有个小技巧如果你在维护一堆临时数据后又想统一处理Set 的 add 方法返回 boolean你可以根据它判断这个元素是不是第一次出现用来做首次出现时要做的事这种逻辑。说到缓存简单场景直接用一个 HashMap 加锁就能应付但线上真的不建议这么干因为还要考虑容量上限、过期清理、并发淘汰。这时候要么用成熟缓存框架要么用上面说的 LinkedHashMap 的 accessOrder 模式重写 removeEldestEntry 才是标准做法LinkedHashMapString, Object cache new LinkedHashMap(16, 0.75f, true) { Override protected boolean removeEldestEntry(Map.EntryString, Object eldest) { return size() 100; // 最多保存 100 个条目 } };这段代码看起来简单但真的能解决不少中小规模缓存需求比引入重量级框架要轻得多。3. String 开篇为什么它值得一个专属系列3.1 字符串为什么是类而不是基本类型Java 里有八种基本类型但没有字符串基本类型。String 是一个最终类不能被继承。之所以不把它做成基本类型原因有很多但最主要的是字符串在业务中太常用了它需要一整套方法比较、截取、查找、替换、格式化、正则匹配。这些能力只能靠类来承载。与此同时Java 又把它从普通类里摘出来给予特殊待遇字面量直接声明就能用比如 String s hello而不是到处 new String(hello)。语言层面的特殊待遇加上常量池的配合让它用起来有基本类型的感觉底层又是引用类型。String 被声明为 final 也有讲究。如果允许继承子类可以覆写方法那就可能出现一个表面上像 String、行为却完全不同的对象放在集合当 key 或做并发控制时后果不可控。final 保证了所有 String 实例的行为是一致的这也是字符串能够被安全地作为 HashMap 的 key、作为锁对象的一个重要前提。3.2 不可变性到底是福还是祸String 是不可变的这句结论大家都会背。它意味着什么任何对字符串的修改操作都不是在那个字符串上改而是新建一个字符串返回。拼接、替换、截取本质上都一样。不可变带来的好处很明显。第一它是线程安全的多个线程同时读同一个 String 没有任何问题。第二它可以被安全地缓存 hashCode所以 String 的 hashCode 方法里能缓存第一次计算的值后续调用直接返回作为 HashMap 的 key 时性能非常好。第三它适合放在常量池里共享反正不可变大家引用同一个对象地址都不会被篡改。第四作为中间结果在网络上传输时它不会因为某个线程的误操作而改变内容代码的错误被局限在局部。坏处也很现实性能开销大。一个简单字符串拼接比如 str x底层都会创建新的字符串对象循环里拼几百次就会产生大量中间对象对 GC 压力不小。所以不可变性是一把双刃剑我们后面专门讲拼接优化时会反复提到 StringBuilder 和字符串池的利弊。3.3 常量池和 intern 的关系别搞混了字符串常量池是 JVM 里一块专门存放字符串字面量的区域。你用双引号写的字符串在类加载和编译阶段就会被放入常量池。编译期能够确定的字符串拼接比如 String a ha ha编译器会直接优化成 haha 这个字面量不会在运行期做拼接。但如果是变量相加比如 String b base ha编译期算不出来就必须运行期执行 StringBuilder.append结果会在堆里新建一个对象常量池里可能根本没有这个内容。这就引出了 intern 方法。intern 的作用是如果常量池里有这个字符串就返回常量池里的引用没有就把当前字符串内容加入常量池再返回引用。JDK 7 之后常量池被移到了堆里intern 不仅可以引用常量池中已有的字符串必要时还会在堆里创建这个字符串并把它登记进常量池。很多面试题爱考new String(xyz) 创建了几个对象答案通常是两个一个在常量池一个在堆里但如果你前面已经出现过字面量 xyz那只有堆里那个是新对象。这个细节值得自己写段代码跑一遍验证光靠背结论容易翻车。3.4 为什么 equals 而不是 比较字符串凡是写过 Java 的人都踩过这个坑两个字符串内容一样用 比较却返回 false。原因是 比较的是引用地址equals 才是比较内容。字面量赋值时由于常量池的存在相同值的字面量可能指向同一个对象这时候 碰巧返回 true一旦某个字符串是运行期拼接或 new 出来的它的引用地址就和常量池里的不同 就失灵了。String a abc; String b abc; System.out.println(a b); // true都是常量池中的同一个对象 String c new String(abc); System.out.println(a c); // false堆里新建的对象实际开发中判断字符串内容是否相同永远要用 equals。用 比字符串像是拿门牌号比是不是同一套房而不是比较两套房长得像不像。如果大量比较的字符串都是常量池中的对象用 能减少 equals 的开销但这属于极端优化场景需要在代码里非常明确地保证引用来源平时建议老老实实用 equals如果为了保证内容比较的同时避免空指针Java 7 之后可以用 Objects.equals。4. String 的核心现象实测拼接、截断、池与性能4.1 循环拼接字符串的灾难现场一个很常见的代码写法String result ; for (int i 0; i 10000; i) { result i; }这段代码的字节码层面每一次 都会 new 一个 StringBuilder然后 append最后 toString。如此反复一万次就创建了一万个 StringBuilder 对象和一万个中间字符串。其结果就是代码简单性能灾难。正确的做法是在循环外面创建一个 StringBuilder循环里只 append最后 toString。如果你拼的是单线程环境StringBuilder 就够用了它是非线程安全的多线程共享拼接场景才考虑 StringBuffer它的方法加了 synchronized但一般业务代码几乎用不到这个场景。有个容易忽视的点是编译器的优化只针对一个字面量拼接表达式比如 String s a 1 2编译期直接算出 a12但一旦循环涉及变量编译器无法在编译期确定结果就只能老老实实走 StringBuilder 流程。所以编译器会优化字符串拼接这句话是要分情况的不能以偏概全。4.2 substring 的内存坑长字符串截出个短字符串关于 substring 有个很古老的问题。在 JDK 6 时代substring 生成的字符串会共享底层 char 数组只记录了一个偏移量。这个设计节省了复制成本但代价是如果你有一个几百 MB 的大字符串截出其中 100 个字符的短字符串只要这个短字符串还被引用着那整块几百 MB 的 char 数组就无法被回收。服务器内存就是这样一点点被吃掉的。JDK 7 起substring 不再共享原数组而是会把截取的内容复制到新数组里小字符串不再牵制大数组的生命周期那个内存坑被填上了。但从性能角度看每次 substring 都要复制大字符串高频截取会有一定代价。工作中处理大文本时如果只需要其中一小段可以考虑用自定义的偏移量引用方式或者确保截取的大字符串本身可回收。4.3 什么时候 intern 值得用intern 是一个非常好用但非常容易被过度使用的工具。它的优点是字符串内容相同时可以复用同一个对象大量重复的字符串可以大幅减少内存。缺点是常量池本质上是 JVM 的全局共享区域如果内容极多intern 本身会消耗时间和内存而且池里的字符串几乎没有强回收机制取决于实现滥用会弄巧成拙。判断要不要用 intern你可以问自己这段字符串是不是生命周期长且重复率高比如长期驻留的 userType、status 这类枚举值这些可以 intern如果是随机生成的长文本、日志内容、用户输入这些就别碰 intern。有人说 intern 能提升比较性能如果两个引用都来自常量池再用 比较确实快。但这种代码可读性差而且依赖前提条件除非是热点读路径上的极小访存量比较否则不值得为了省一点 equals 开销把代码搞复杂。业务代码里更稳的做法仍然是 equals内存优化交给专门的设计方案。4.4 关于 StringBuilder 初始容量的小提醒StringBuilder 默认容量是 16如果往里面 append 的内容超过了当前容量它会自动扩容策略和 ArrayList 类似通常是旧容量乘 2 再加 2。扩容会有数组复制的开销高频拼接时需要留意。如果你大概知道总长度例如要拼 10 万条日志每条平均 100 字符那总长度约 1000 万构造 StringBuilder 时直接指定一个接近的初始容量就能减少扩容次数。代码就是 new StringBuilder(10_000_000)一次到位后面 append 之后再来 toString。也不要太追求精确容量给个合理的估算值就够了。容量给大了只是数组预留多一点空间总比反复扩容复制数组要划算得多。5. 生产环境中的记忆点与复习清单5.1 一套容器用到底出问题先从这三处找线上如果出现跟集合相关的性能问题我自己的排查顺序基本是固定的。第一看容量是不是无参构造一路扩容假如能预估数据量却没设置初始容量立刻就能看到大量扩容日志或 GC 压力。第二看 key 的 hashCode项目里某个自定义对象作为 key 出现就要怀疑 hashCode 是不是写得太差比如所有对象都返回同一个整数、或者用了随机变量HashMap 直接退化成链表读一次 O(n) 都打不住。第三看元素的不可变性放进 Set 或作为 Map key 之后对象字段是否被修改过。前面说过了改了 hashCode 字段等于自断后路get 都找不到数据。这三条检查一遍很多看起来莫名其妙的线上问题都会水落石出。我见过一次HashMap 存进去后 get 不到的问题最后定位就是实体类作为 key 放进集合后又有个后续流程把它的 name 字段改了。这个 bug 代码层面隐藏得很深因为单独看每一步都合理只有通盘理解 hash 定位机制才能想到是hash 变了导致桶位置变了。5.2 Set 去重和 Map 去重的适用边界很多场景用 Set 和 Map 都能去重但两者有区别。如果你只是想知道某个值有没有出现过Set 足够。如果你需要统计每个值出现了多少次或者需要把第一次出现的元素和后续重复的元素做不同处理Map 更合适value 可以放计数、放第一次出现的索引、放对象自身。Set 的实际限制在于它只保留唯一的值不保留任何附加信息。假设你要按订单号去重但去重之后还需要保留订单对应的最早时间Set 就办不到了你必须 MapString, Ordervalue 放最早那条记录发现重复时再比较时间。这个选择看起来微小写代码时的灵活性差异却很大。另外如果你用 TreeSet 去重注意它去重的依据是 compareTo 而不是 equals两者不一致时行为会很迷惑两个 equals 的对象如果 compareTo 返回 0TreeSet 会认为它们重复反过来 compareTo 不返回 0即使 equals 相等TreeSet 也放得下两份。用排序集合时一定先把这层语义理顺。5.3 String 相关的三个高频面试追问面试里关于 String 的问题问到最后几乎都会落在这三个点上。第一个是String 为什么不可变能带来什么好处这是基础题但很多人只能答出线程安全其实还应该答出 hashCode 缓存、常量池共享、作为 key 的安全性。第二个是String、StringBuilder、StringBuffer 的区别和适用场景String 侧重不可变和共享StringBuilder 单线程拼接StringBuffer 线程安全拼接依次展开就好。第三个是new String(abc) 创建了几个对象这里容易遗漏堆对象和常量池对象的区别必须结合 JVM 内存区域回答。另外现在不少面试官会追着问字符串常量池在 JDK 7 前后的变化。JDK 6 里常量池在永久代JDK 7 挪到了堆里。这个挪动影响不小永久代空间有限大量 intern 容易造成内存溢出挪到堆之后常量池里的字符串可以被 GC 管理。这个变化也解释了为什么 intern 在 JDK 6 和 JDK 7 之后的行为不同。能把这几个版本演进的原因讲清楚面试官基本就会认为你是有源码功底和版本演进意识的人而不只是背了几道题。5.4 从集合到字符串学习路线的自然衔接有人会问Map 和 Set 还没完全搞透怎么就跳到 String 了其实这两个主题环环相扣。String 是 Map 最常见的 key 类型HashSet 内部用 HashMap而 HashMap 的很多问题都源于 String key 的哈希与不可变性。所以先把 Map 和 Set 搞明白再回头理解 String是一条非常自然的路线。String 的知识点比如常量池、不可变性、equals 的语义又会加深你对集合框架的理解。虽然这一篇主要讲 String 的入门内容后面的进阶我会重点覆盖 String 在 JVM 层面的各种细节、格式化与正则表达式、以及字符串在编码转换中的坑。现在先把手头的清单消化掉Map 的容量与负载因子、LinkedHashMap 的访问顺序、HashSet 和 HashMap 的对应关系、字符串不可变与常量池、拼接性能、substring 内存风险。这些点吃透了你再看集合框架就不再是背了一堆类名和方法的 API 手册而是一张能互相推导的知识网。最后给一个我自己的习惯学习和实战之后我都会把代码亲自跑一遍尤其是常量池、intern、substring 这些行为一定要用打印输出和对象地址来验证。Java 集合与字符串设计得都很精妙但精妙之处不亲眼验证永远是课本上的一句话。希望这篇收尾加开篇能帮你把零散的知识点串成线后面我们再一点一点把 String 的更多细节剥开来看。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑