ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

在 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 HashSet<E> extends AbstractSet<E> { private HashMap<E, 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,而返回的旧值不等于 null,add 就返回 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.0?0.5 意味着空间浪费严重,1.0 则意味着冲突概率大幅上升,读写性能会明显退化,0.75 是时间与空间的折中,这是 JDK 作者基于大量测试给出的经验值。

再说树化阈值。链表长度为 8 且数组长度至少是 64 的时候,链表会转成红黑树。为什么要两条条件同时满足?如果数组只有 16 或 32,那说明元素都挤在了少数几个桶里,根本原因是容量太小,这时候正确做法是扩容而不是转红黑树。树节点差不多是普通节点的两倍大小,很占内存,只有桶里真的有 8 个以上元素并且整体哈希分布还算合理时,转树才有性价比。面试常问的"为什么是 8",JDK 源码注释里有一段泊松分布的推导,大意是负载因子 0.75 的前提下,正常数据分布下桶内节点数量到达 8 的概率已经非常小,真出现了,那多半是 hashCode 写得有问题,用树可以兜底防极端攻击。

1.3 LinkedHashMap 和 TreeMap 各自管哪种顺序

好多人在学习时只盯着 HashMap,真到用的时候才发现还有两个兄弟要认识一下。

LinkedHashMap 是 HashMap 加了一条双向链表,这条链表维护了节点的顺序。它有两种模式:插入顺序和访问顺序。默认是插入顺序,也就是你 put 进去的顺序,遍历时会按这个顺序输出,这在很多场景下非常实用,比如你要一个缓存,希望淘汰最久没被访问的那个元素,就把 accessOrder 设为 true,每次 get 或 put 都会把节点移到链表尾部,头部自然就是最久没用的那个,这不就是手写了一个 LRU 缓存的核心骨架吗?

LinkedHashMap<String, 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 好一点,但本质还是整表锁,适合简单场景。真正扛并发的是 ConcurrentHashMap,JDK 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 比较 id,hashCode 却把整个对象所有字段都算进去,就会出现在同一个 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 最常见的三个用途是统计、去重和缓存。统计词频,一行循环就能做:

Map<String, 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 才是标准做法:

LinkedHashMap<String, Object> cache = new LinkedHashMap<>(16, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry<String, 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 就办不到了,你必须 Map<String, Order>,value 放最早那条记录,发现重复时再比较时间。这个选择看起来微小,写代码时的灵活性差异却很大。另外,如果你用 TreeSet 去重,注意它去重的依据是 compareTo 而不是 equals,两者不一致时行为会很迷惑:两个 equals 的对象如果 compareTo 返回 0,TreeSet 会认为它们重复;反过来 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 的更多细节剥开来看。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进