ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

HashMap 深度解析:从数组链表到红黑树,彻底讲透 put、get、扩容与线程安全

HashMap 深度解析:从数组链表到红黑树,彻底讲透 put、get、扩容与线程安全 HashMap 是 Java 集合框架里出镜率最高的类也是面试八股里的常青树。但说句实话能把它讲到“面试官点头”的人真不多。我面过不少候选人十个里八个能背出“数组加链表JDK 1.8 引入红黑树”可再追问一句“get 的时候什么时候会调用 equals”立刻就卡壳了。今天我不打算再复述一遍源码注释而是把 HashMap 的 get、put、扩容、线程安全、遍历这些点按实际运行的链路一层层剥开把那些最容易混淆、最容易在面试和实战里踩坑的地方讲透。无论你是刚入门想搞懂原理还是准备面试想补短板这篇应该都能给你一点不一样的东西。1. 底层结构数组、链表、红黑树是怎么拼出一个 HashMap 的1.1 为什么主结构是一张数组先从一个最核心的事实说起HashMap 底层不管怎么演变最根本的存储主体始终是一张NodeK,V[] table数组。数组的特点是内存连续、按下标访问的时间复杂度是 O(1)这正是 HashMap 想要的高效查找基础。你可以把这张数组想象成酒店的一排房间每个房间门牌号就是数组下标。放数据的时候我们不能直接决定某个 key 住哪间房而是通过 key 的hashCode()算出一个哈希值再把这个哈希值映射到具体的下标上。这个“映射”动作官方术语叫寻址。所以数组的第一个作用就是让 HashMap 能做到“平均 O(1)”的插入和查询。但问题也跟着来了哈希函数再均匀也无法保证不同的 key 算出的下标完全不冲突。两个不同的 key 映射到同一个房间这就是哈希冲突。处理哈希冲突的经典办法有开放寻址法和链地址法Java 的 HashMap 选的是链地址法——也就是在数组的每个坑位后面再挂一条链表或者一棵红黑树。1.2 链表和红黑树分别解决什么问题链表的插入非常快直接往后挂节点就行但查找时要从头遍历时间复杂度是 O(n)。当冲突不严重时链表长度很短遍历成本几乎可以忽略。可一旦有人恶意构造大量哈希值相同的 key比如故意把字符串设计成同 hash链表就会越挂越长查询性能会从 O(1) 直接退化到 O(n)这是任何业务系统都接受不了的。所以 JDK 1.8 在链表后面又引入了红黑树。当某个桶位bucket的链表长度达到 8并且整个数组长度达到 64 时链表会转成红黑树。红黑树是一种自平衡二叉查找树查找、插入、删除的时间复杂度都是 O(log n)。为什么是 8 这个数字文档源码里有解释用泊松分布计算在负载因子 0.75 的情况下链表长度到 8 的概率已经低到千万分之六基本可以认为正常业务里不可能出现。也就是说树化主要是为了对抗极端哈希冲突的兜底方案而不是日常会走的路径。这里有一个经常被忽略的细节树化并不是只看链表长度还要求数组长度不小于 64。如果链表长度到了 8 但数组长度还不到 64HashMap 会优先选择扩容而不是树化。原因也很直接数组太短说明容量太小此时扩容既能重新分散元素又比维护红黑树成本更低。1.3 Node 节点到底存了什么无论是链表还是红黑树节点都实现了同一个 Map.Entry 接口。我们看链表节点的属性就够了static class NodeK,V implements Map.EntryK,V { final int hash; final K key; V value; NodeK,V next; }注意这个hash字段它存的是经过扰动计算后的哈希值而不是原始hashCode()。为什么要把这个值存下来因为后续 get、扩容时会反复用到它。如果不存每次比较都要重新算一遍 hash性能上划不来。而next指针则是链表的核心它把冲突到同一个桶位的节点串成一条链。红黑树节点TreeNode则是在 Node 基础上增加了 parent、left、right、prev 这些指针和 red 颜色标记。这里有个值得留意的点TreeNode 继承了 LinkedHashMap.Entry而 LinkedHashMap.Entry 又继承了 HashMap.Node所以红黑树节点在结构上依然兼容链表节点。这就是为什么 HashMap 能在一个桶位里同时存在树节点和链表节点也解释了为什么反树化红黑树退回链表时可以直接复用原来的 next 指针。2. put 流程hash 扰动、寻址与冲突处理的完整链路2.1 一个 put 调用经历了什么很多人以为map.put(key, value)就是简单地把 key 丢进数组其实这一行代码背后跑了整整一个链路。我把整个过程按执行顺序拆出来对 key 的hashCode()做扰动计算得到最终的 hash 值如果 table 数组还没初始化或长度为 0先触发 resize 初始化数组用hash (n - 1)计算出数组下标如果这个下标位上没有节点直接 new 一个 Node 放进去如果这个下标位上有节点说明发生了哈希冲突需要走链表或红黑树的插入/替换逻辑插入成功后size加 1如果超过阈值 threshold则触发扩容。每一步都有设计上的讲究下面挑最关键的几个节点展开。2.2 扰动函数为什么要把 hashCode 高低位异或JDK 1.8 里的 hash 函数长这样static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }这段代码只有三行却藏着一个很重要的思考。hashCode()返回的是一个 32 位的 int如果不做任何处理直接用这个值去对数组长度取模高位的信息就浪费了。因为 HashMap 的数组长度在扩容前往往只有 16、32、64 这种小数字取模时真正参与运算的只有低位几个 bit。把高 16 位和低 16 位做异或本质上是把高位信息“混入”低位让最终参与寻址的 hash 值尽可能分散。这样即使多个 key 的 hashCode 在高位差异很大、低位完全一样经过扰动之后也能分散到不同的桶位。顺带说明一下key null时 hash 直接返回 0这也是 HashMap 允许 null 作为 key 的原因。null 的哈希值固定为 0因此它永远落在数组下标为 0 的桶位。2.3 寻址算法为什么用 不用 %算完 hash 之后下一步是定位数组下标。源码里用的是i (n - 1) hash这里的 n 是数组长度。很多人第一次看到这行代码都会懵为什么不用hash % n原因是位运算比取模运算快得多但前提是 n 必须是 2 的幂次方。当 n 2^k 时(n - 1) hash和hash % n的结果完全等价但 运算直接操作二进制位省去了除法器的开销。这也是 HashMap 强制要求容量必须是 2 的幂次的根本原因。你初始化时传入一个不是 2 的幂的数比如 new HashMap(13)它也会通过tableSizeFor方法把容量调整到最近的 2 的幂——也就是 16。这个细节在面试里几乎是必问的在实战中的意义在于它让 hash 值能够均匀分布同时让后续扩容时的重哈希变得极其高效这个我在后面扩容章节细说。2.4 哈希冲突时链表遍历、equals 比较与树化下标定位完成后分两种情况。如果这个桶位是空的直接插入新节点什么比较都不需要。但如果桶位不空就要进入冲突处理逻辑if (p.hash hash ((k p.key) key || (key ! null key.equals(k)))) e p;这里的判断顺序很有意思。先比较 hash再比较最后才调用equals。为什么要先比 hash因为 hash 是一个 int比较一次就能完成开销极小而 equals 是方法调用可能涉及复杂的对象字段比较成本高得多。用 hash 做一层粗筛能过滤掉绝大多数不相关的 key只有 hash 相同的情况下才值得用 equals 做精确判断。这种“先粗筛再精判”的思路在整个 HashMap 的查找逻辑里贯穿始终。你可以把它类比成医院分诊先挂号分科室hash 定位再让医生做详细诊断equals 确认而不是每个病人都直接推给专家。如果是链表节点就沿 next 指针遍历整条链逐个用同样的方式比较如果遍历完了都没找到相同的 key就在链表尾部插入新节点JDK 1.8 改成尾插法。如果是树节点则走红黑树的插入逻辑。每插入一个节点都会检查当前链表长度是否达到树化阈值 8达到则调用treeifyBin但这个方法内部还会再检查一次数组长度是否达到 64不满足就扩容。2.5 覆盖旧值的场景什么时候算“同一个 key”put 的时候如果找到相同 key不会新增节点而是用新 value 覆盖旧 value并把旧 value 返回给你。那么什么情况算“同一个 key”标准有两条两个 key 的哈希值相同两个 key 通过比较为 true或者通过equals比较为 true。注意这里用的是“或”。如果两个引用指向同一个对象为 trueequals 根本不会被调用。只有引用不同时才会通过 equals 判断内容是否相同。这一点极其重要可惜很多人在面试时说不清楚。3. get 流程精读equals 方法到底在哪个环节被调用3.1 getNode 的查询链路get 方法和 put 是对称的核心逻辑在getNode里计算 key 的 hash 值同样的扰动函数如果 table 不为空且长度大于 0用(n - 1) hash定位槽位取出该槽位的第一个节点先用hash和key做快速判断命中就直接返回如果第一个节点没命中看它是链表还是树节点分别用不同方式遍历整个遍历过程中每个节点都要先比较 hash再用或equals判定 key 是否相同如果所有节点都不匹配返回 null。所以回到开头那个问题get 的时候什么情况下会调用 equals答案很明确——只有当 hash 计算出来的槽位和某个节点持有的 hash 值相等时才会继续用 equals 比较。hash 不相等的话equals 一次都不会被调用。很多人画 get 流程图时会把 equals 画成每次遍历都必然执行的一步这是不对的。3.2 为什么说 hashCode 是查找的第一层索引理解了查询链路就会明白一个反直觉的事实HashMap 在查找时真正最依赖的不是 equals而是 hashCode。hashCode 决定了 key 会被分配到哪个桶而 equals 只是在同一个桶内做二次确认。假如两个对象内容相同equals 返回 true但 hashCode 不同那么它们会被分配到不同的桶get 时就根本不会相遇。举个实际的例子。定义一个 Person 类public class Person { private String name; public Person(String name) { this.name name; } Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; Person person (Person) o; return Objects.equals(name, person.name); } // 注意没有重写 hashCode }然后执行这段代码MapPerson, String map new HashMap(); map.put(new Person(张三), 北京); String city map.get(new Person(张三)); System.out.println(city); // 输出 null两个 Person 的 name 都是 “张三”equals 返回 true但因为你没有重写 hashCode两个对象的 hashCode 是默认实现的几乎不可能相等。它们被分配到不同的桶位get 自然找不到。这个例子能帮你从骨子里理解重写 equals 而不重写 hashCode等价于把“同样的东西”放进了两个不同的房间还指望其中一个去另一个房间找。对照之下String 和 Integer 之所以是 HashMap 最完美的 key就是因为它俩都是不可变类hashCode 和 equals 都正确重写了而且 hashCode 的计算结果不会因为对象状态变化而改变。3.3 查询性能的边界条件get 的时间复杂度理论上平均是 O(1)但这个结论依赖两个前提哈希函数足够均匀、桶位上的冲突链表足够短。一旦某个桶位被暴力填充成一条 8 个节点的链表get 在该桶位上的查询就退化成了链表遍历综合复杂度会逼近 O(n)。红黑树就是为了兜住这种边界情况。树化之后get 在该桶位上的查询从 O(n) 变成 O(log n)这个提升在恶意攻击场景下是能保命的。早年 Java 版本就因为哈希碰撞攻击导致过服务不可用后来引入树化也有这方面的考虑。4. 扩容与负载因子表面是参数背后是性能权衡4.1 扩容的触发时机阈值是怎么算出来的HashMap 不会等数组塞满了才扩容。它维护了一个扩容阈值threshold计算公式是threshold capacity * loadFactor。默认初始容量是 16默认负载因子是 0.75所以默认阈值是 12。也就是说元素个数达到 12 个时即使数组里还有 4 个空位也会触发一次扩容。为什么留这么多空位因为哈希冲突和数组的“填充率”是强相关的。数组用得越满新的 key 落到已有桶位的概率就越高链表就会变长性能就会下降。负载因子是时间和空间的权衡参数调低负载因子比如 0.6空间浪费更大但冲突更少调高负载因子比如 1.0空间利用率高但冲突明显增加。源码注释里给出了统计层面的解释在负载因子 0.75 的情况下单个桶位链表长度的分布符合泊松分布长度达到 8 的概率已经极低树化阈值取 8 是经过概率计算的结果。这也是为什么很多大厂面试会问“为什么负载因子默认是 0.75”本质上考的是概率统计和性能取舍的思维而不只是背参数。4.2 扩容过程为什么是无损的高效重哈希扩容时数组长度会翻倍新容量成为原来的 2 倍。所有已有节点都得重新放置但 JDK 1.8 做了一个非常漂亮的优化不需要重新计算每个节点的 hash也不需要重新取模。得益于容量是 2 的幂次方元素在新数组中的位置只有两种可能留在原来的下标或者移动到“原下标 原数组长度”。判断依据是(e.hash oldCap) 0。oldCap 是 2 的幂次方二进制里只有一位是 1。hash 与该位相与结果为 0 说明 hash 在这一位上为 0新位置不变结果为 1 说明 hash 在这一位上为 1新位置就是原下标加 oldCap。用代码表示就是NodeK,V loHead null, loTail null; // 留在原位的链表 NodeK,V hiHead null, hiTail null; // 移动到高位段的链表 if ((e.hash oldCap) 0) { // 放入 lo 链表 } else { // 放入 hi 链表 }扩容结束后低位链表直接挂到newTab[j]高位链表挂到newTab[j oldCap]。整个过程没有一次除法和取模全是位运算这就是 2 的幂次方容量带来的最大红利。同时JDK 1.8 的扩容在并发环境下虽然仍有数据覆盖问题但不再像 JDK 1.7 那样容易形成环形链表导致死循环这一点在讲线程安全时会再提到。4.3 扩容的代价与初始容量设定的实战建议扩容很昂贵。它要新建数组、遍历旧数组里每个节点、重新计算节点归属、把节点迁移到新位置。频繁扩容对性能的影响非常直接。举个实际场景如果你知道 Map 里会存 1000 条数据直接new HashMap(1000)其实并不好。因为 1000 传入后会被调整为 1024阈值就是1024 * 0.75 768而你要放 1000 条中间必然要触发一次扩容白白多一次全量迁移。正确的做法是估算预期容量然后除以 0.75 再初始化。也就是int expectedSize 1000; MapString, String map new HashMap((int) (expectedSize / 0.75f) 1);计算结果约为 1334HashMap 内部会向上调整为 2048阈值变为 1536放 1000 条数据完全不需要扩容。这个“加 1”是为了处理浮点数除法的精度误差避免算出来的容量恰好卡在临界值上。这个写法很多老开发都不知道但字节码层面就是少一次 resize 的差距数据量大时能明显拉开性能。这里有个特别容易踩的坑初始化容量写太大也不行。比如你直接new HashMap(1024 * 1024)但只放几条数据数组一次就占用了 16MB 左右的内存而真正用到的只有几个桶。HashMap 的数组是“一次到位”的不会因为你放得少就自动缩小缩容只能通过重新 new 一个 Map 实现。5. 线程安全非线程安全的根源与三种补救方案5.1 HashMap 在并发环境下会出哪些问题HashMap 的所有方法都没有同步控制它默认的适用场景是单线程。多线程同时 put 时最典型的问题有三个。第一是数据覆盖。两个线程同时发现某个桶为空都准备插入新节点在“读取桶为空”和“写入节点”这个时间窗口内后写入的线程会直接覆盖先写入的节点。这种情况下数据不是丢了而是根本没进去。代码上完全不会报错但在业务上就是某种隐性的脏读。第二是扩容时的并发问题。JDK 1.7 用头插法迁移节点在多线程同时扩容时可能形成环形链表之后任何一次 get 都可能陷入死循环CPU 飙到 100%。JDK 1.8 改用了尾插法环链问题基本得到解决但并发写入时的数据丢失和覆盖问题依旧存在。第三是 modCount 快速失败机制。modCount 是 HashMap 记录“结构修改次数”的计数器每次增加节点、删除节点都会加 1。迭代器在创建时会保存一个期望值 expectedModCount每次迭代都会检查两者是否一致不一致就抛ConcurrentModificationException。快速失败的设计初衷是让错误尽早暴露但它不能当作并发控制的工具它只是在并发出错时能快速报错而已。5.2 三种线程安全的替代方案怎么选如果需要线程安全通常有三个选择性能差异非常明显。方案锁粒度特点适用场景Hashtable整个表所有方法用 synchronized 修饰读写全部串行遗留代码新项目不建议用Collections.synchronizedMap整个 map包装所有方法同样串行简单场景并发量极低ConcurrentHashMap单个桶JDK 1.8读无锁写锁桶内节点高并发场景首选Hashtable 的问题在于锁粒度太粗连 get 这样的只读操作都得拿全表锁。ConcurrentHashMap 在 JDK 1.8 里改用 CAS synchronized 锁住单个桶节点读写并发度大幅提升是生产环境的正确选择。如果你在面试里被问到“HashMap 是线程安全的吗”正确的回答流程是先说不是再解释为什么最后补一句该用什么替代。5.3 单线程里也值得注意的快速失败陷阱快速失败不是只在多线程下才会触发。即使在单线程里你遍历 HashMap 的过程中如果调用了map.put()、map.remove()这类结构性修改方法下一次迭代时也会抛 ConcurrentModificationException。这是个特别容易在写业务时踩到的坑MapString, String map new HashMap(); // 初始化省略 for (Map.EntryString, String entry : map.entrySet()) { if (entry.getKey().equals(xxx)) { map.remove(entry.getKey()); // 会抛异常 } }正确的删除方式是使用迭代器自带的 remove 方法IteratorMap.EntryString, String iterator map.entrySet().iterator(); while (iterator.hasNext()) { Map.EntryString, String entry iterator.next(); if (entry.getKey().equals(xxx)) { iterator.remove(); } }因为iterator.remove()会同步更新 modCount让 expectedModCount 跟着变从而避开快速失败检查。这个细节在很多线上 bug 里都是元凶改起来一行代码的事但排查起来可能要半天。6. 遍历方式汇总与实战选择6.1 四种基础遍历方式对比HashMap 的遍历方式远比新手以为的多我把常用的做了一个对比。// 方式一keySet get for (String key : map.keySet()) { String value map.get(key); } // 方式二entrySet for (Map.EntryString, String entry : map.entrySet()) { String key entry.getKey(); String value entry.getValue(); } // 方式三只遍历 value for (String value : map.values()) { // ... } // 方式四迭代器 IteratorMap.EntryString, String iterator map.entrySet().iterator(); while (iterator.hasNext()) { Map.EntryString, String entry iterator.next(); }第一种keySet get是最直观的写法但它每取一次 value都要在内部重新执行一遍 hash 定位和链表/树的查找。也就是说遍历 n 个 key 时额外承担了 n 次 get 的查询成本。数据量小的时候无所谓数据量大时这就是明显可以省掉的浪费。第二种 entrySet 就聪明得多它直接把 key 和 value 封装在 Entry 对象里遍历一次就能同时拿两个多数场景下的最优选择。第三种 values 适合只要 value 的情况但它拿不到 key。第四种迭代器和第二种本质上是同一套东西迭代器只是把遍历过程暴露出来方便在其中做删除操作。6.2 Java 8 的 forEach 和 streamJava 8 之后Map 接口新增了forEach(BiConsumer)方法map.forEach((key, value) - System.out.println(key value));这行代码看起来很简洁但它和 entrySet 遍历内部的实现几乎一样性能上没有魔法。需要注意的一点是forEach 里同样受快速失败机制约束你在 lambda 里对 map 做结构性修改依然会抛异常。stream 方式则是更现代化的选择map.entrySet().stream() .filter(entry - entry.getValue() 10) .forEach(System.out::println);stream 的吞吐在单线程下不如普通 for 循环但它能配合 filter 做条件过滤、配合 collect 做结果收集代码的可读性有明显优势。并行流的坑在于它不保证遍历顺序如果你的业务依赖 HashMap 的遍历顺序那本身就已经错了——HashMap 本来就不保证顺序。6.3 遍历顺序相关的三个类怎么选这里顺便把顺序问题说清楚。HashMap 的遍历顺序取决于每个 key 的 hash 值任意时刻都可能是“乱的”插入顺序在大多数情况下和遍历顺序毫无关系。需要有序遍历时有三个选择LinkedHashMap维护了双向链表遍历顺序默认等于插入顺序适合需要保持插入顺序的缓存、会话管理TreeMap按键的自然顺序或自定义 Comparator 排序适合需要有序输出的场景ConcurrentSkipListMap并发环境下的有序 Map基于跳表实现。选择哪个取决于你的核心需求是顺序、排序还是并发。没有一种 Map 能同时把这三件事做得最好这是在设计阶段就要想清楚的。7. 面试追问冷门点与实战避坑清单7.1 那些容易翻车的高频追问面试里光是“讲讲 HashMap 的原理”这一问就够挖半个小时。我整理了几个最容易把人问倒的追问连同标准思路一起列出来。为什么容量必须是 2 的幂因为要通过(n - 1) hash替代取模运算也因为扩容时能通过hash oldCap判断元素迁移位置不需要重新计算全部 hash。为什么树化阈值是 8反树化阈值是 68 是泊松分布下极低的概率阈值代表异常冲突6 留出了缓冲空间避免某个元素反复插入删除导致链表和树频繁互转白白消耗性能。为什么不能只重写 equals 不重写 hashCode因为 HashMap 把 hashCode 当作第一层索引equals 只做桶内二次确认两者缺一不可。HashMap 允许 null 吗key 和 value 都允许但 key 为 null 时 hash 固定为 0因此 key 只能有一个是 null。HashMap 和 HashTable 的区别线程安全、允许 null、初始容量、扩容方式、遍历的快速失败机制这几个维度都能展开。为什么初始容量是 16它和负载因子 0.75 配合后阈值是 12能在空间利用率和冲突率之间取得一个合理的平衡点。太小频繁扩容太大浪费空间。你能手写一个简单的 HashMap 吗这个题考查的是链表数组结构和 put/get 逻辑能把 hash、寻址、冲突串起来说明白比背源码更有说服力。还有一类追问专门喜欢挑 Java 版本差异JDK 1.7 和 1.8 的 HashMap 有什么区别核心差异是三个——头插法改尾插法、引入红黑树、扩容时不用重新计算 hash。能把这三点讲清楚再配合对前因后果的解释基本就能证明你是理解而不是背诵。7.2 实战避坑可变 Key 和序列化问题先说可变 Key 这个坑它非常隐蔽。假设你用了一个对象做 key对象放进 Map 之后你修改了它的某个字段导致 hashCode 发生变化。这个 key 还留在 Map 里但你已经无法通过它 get 到原来的 value 了因为它新的 hashCode 会定位到另一个桶位旧的桶位里那个节点还孤零零地挂在那儿直到整个 Map 被重建。举一个真实场景用订单对象做 key订单状态字段发生了变化瞬间这个 key 就“失效”了。所以在设计 key 的时候一定要用不可变类比如 String、Integer、Long或者自己写一个字段全部为 final、hashCode 依赖的字段不可修改的类。这不仅是规范问题也是 HashMap 使用中最容易造成线上数据“凭空消失”的坑。另一个容易被忽略的是 HashMap 的序列化机制。HashMap 实现了 Serializable 接口但table字段被声明为 transient。这意味着默认序列化不会直接保存数组里的节点而是通过自定义的writeObject方法遍历整个 Map把每个键值对逐个写出去反序列化时再重新 put 一遍。为什么要这么做因为不同版本的 JDK 对 String 的 hashCode 算法可能不同直接序列化保存 hash 值换一个 JDK 版本后hash 值对不上数据就全乱了。既然 hash 不能信任就干脆不保存数组结构只保存键值对反序列化时重新计算。理解了这一点你就不难解释为什么 HashMap 序列化的文档注释里明确写着“不建议依赖默认序列化”。7.3 性能对比什么时候 HashMap 反而慢HashMap 并非万能。当 key 的 hashCode 分布极差或者 key 的 equals 方法写得极慢时HashMap 的性能会急剧下降。比如你的 equals 方法内部做了数据库查询、远程调用那每一次冲突比较都等于一次灾难。更常见的情况是数据量特别小比如只有 3 到 5 条时HashMap 的 hash 计算、节点创建、数组分配的开销可能比直接 ArrayList 遍历还大。这种场景就别为了用 Map 而用 Map简单数组线性扫描反而更高效。我在实际项目里见过不少因为“用 HashMap 显得专业”而额外引入的 bug特别是受可变 key 影响导致的取不到值的问题。选数据结构时按数据规模和访问模式来而不是按流行度来。容量预估也是一门学问。除了前面提到的除法初始化技巧还要注意扩容是成倍发生的所以容量最好一次性给足避免数据增长过程中多次触发 resize。如果数据是分批到达的可以考虑预留足够的成长空间或者接受一到两次可控的扩容而不是反复初始化新 Map 来替换旧 Map。最后说一个很多人问过的点为什么 ConcurrentHashMap 在 JDK 1.8 里依然不允许 null key 和 null value因为它无法判断并发环境下get(key)返回 null 到底表示“key 不存在”还是“value 就是 null”这种二义性在并发场景下会造成误判。而 HashMap 是单线程的出现 null 时可以从上下文推断所以敢放行。这个差别很细微但对理解两个类的并发设计哲学很有帮助。我个人在实际开发里体会最深的一点是HashMap 最难的从来不是把原理背下来而是每次用它之前先想清楚三个问题——key 可变吗需要顺序吗有并发吗这三个问题的答案基本决定了你会不会写出线上 bug。把这三个问题内化成习惯比记住所有源码细节都更有价值。
RELATED READING

延伸阅读

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