
先问大家一个问题如果你正在写一个高频服务需要往一个 Map 里读写几十万次数据你会选 HashMap、HashTable 还是 ConcurrentHashMap我相信大多数人都能脱口而出“高并发用 ConcurrentHashMap单线程用 HashMapHashTable 别用”但真到了线上出了问题比如 CPU 飙高、死循环、数据丢了能一针见血定位到问题根源的人就不多了。这篇文章我不打算抄书而是以一个实际踩过坑、也看过源码的过来人视角把这三个容器的底层原理、演进逻辑和选型思路一次性讲透。我会重点拆解 HashMap 的底层数据结构、哈希扰动、扩容与红黑树转换再对比 HashTable 为何被淘汰最后剖析 ConcurrentHashMap 从 JDK 7 到 JDK 8 的实现变迁。全程尽量少讲废话多给结论、多给公式、多给排查思路保证你读完能从“会背面试题”进步到“真能解决线上问题”。1. 三个容器的关系与定位先搞清楚它们到底解决什么问题1.1 从最朴素的键值存储说起HashMap 本质上就是一个用来存“键值对”的散列表结构。它的核心追求是在理想情况下插入、查找、删除的时间复杂度都是 O(1)。这个“理想情况”依赖于哈希函数足够均匀让每个 key 都能分散到不同的桶位避免链表过长。但 HashMap 有一个非常致命的天然缺陷非线程安全。这不是说它“在多线程下偶尔出错”而是说它在多线程并发写入时可能会出现非常严重的问题——CPU 100% 死循环、数据覆盖丢失、甚至直接导致 JVM 进程崩溃。JDK 7 及之前HashMap 在并发扩容时头插法会造成链表成环一旦有线程在这个成环节点上做查询就会陷入无限循环直接把一个核跑满。我曾经在线上遇到过一次故障老年代内存一直不释放CPU 多核飙高排查了半天才发现是一个全局的 HashMap 在高并发下被多个线程同时触发扩容最终形成了环形链表。那个印象太深了所以后来我对并发容器选型格外敏感。HashTable 则是 JDK 1.0 就存在的“元老级”线程安全 Map。它的线程安全实现方式很粗暴给几乎所有的 public 方法都加上 synchronized 锁。对你没看错是锁整个对象。这意味着任何时刻只有一个线程能执行读或写操作并发吞吐量极低在高并发场景下就是典型的性能瓶颈。更尴尬的是连读操作也要争一把全局锁这在实际业务里非常浪费。ConcurrentHashMap 就是为了解决以上两个痛点而生的既要线程安全又要高性能。它采用“锁粒度细化”的思路不去锁整个表而是锁住一部分数据区域从而大幅提升并发度。从 JDK 7 的 Segment 分段锁到 JDK 8 抛弃 Segment、改用 CAS synchronized 锁桶位ConcurrentHashMap 的并发能力发生了质的变化现在它已经是 Java 并发编程里最值得反复研究的容器之一。1.2 三者的核心差异速览很多初学者容易把这三者当成“同一个东西的三种版本”但它们的定位完全不同。我习惯用一句话概括HashMap 是单线程工具箱里的标配HashTable 是教科书里用来比较的“反面教材”ConcurrentHashMap 才是生产环境高并发场景下的正解。为了让你看得更清楚我列一个对比表这三个容器在底层数据结构、线程安全策略、锁粒度、迭代器、性能表现上的区别都一目了然对比维度HashMapHashTableConcurrentHashMap首次版本JDK 1.2JDK 1.0JDK 1.5线程安全否是全方法 synchronized是分段锁 / CAS synchronized锁粒度无锁整个对象JDK7SegmentJDK8桶位 无锁读允许 key/value 为 nullkey 和 value 都允许 nullkey 和 value 都不允许 nullkey 和 value 都不允许 null底层结构数组 链表 红黑树JDK8数组 链表数组 链表 红黑树JDK8扩容机制当元素数超过阈值触发rehash 全部节点容量翻倍rehash 全部节点JDK7 按 Segment 扩容JDK8 支持多线程协助扩容迭代器fail-fast并发修改抛 ConcurrentModificationExceptionfail-fast枚举迭代时不抛弱一致性迭代时不会抛异常适用场景单线程、无并发写几乎不推荐使用高并发读写的生产环境这里有一个非常容易踩坑的点为什么 ConcurrentHashMap 不允许 key 为 null而 HashMap 允许这个问题很多人答不上来。一个关键原因是ConcurrentHashMap 在并发环境下无法区分“key 对应的 value 是 null”和“key 不存在”。如果允许 null那么当你 get 一个不存在的 key 时返回 null你无法判断是这个 key 真的不存在还是这个 key 存在但 value 为 null。在 HashMap 单线程场景下你可以再用 containsKey 二次判断但在高并发下你查了一次之后状态可能已经变了语义上会非常混乱。还有一个更技术性的原因在 ConcurrentHashMap 的 CAS 操作里如果 value 为 null那么casTabAt判断节点是否为空时会无法区分“这个位置是空的”和“这个位置有值但是 null”从而破坏并发控制逻辑。所以源码里直接写死了如果 key 或 value 为 null抛出 NullPointerException。2. HashMap 的底层实现深度拆解数组、链表、红黑树是如何协作的2.1 初始容量、负载因子与扩容阈值的数学逻辑先看一个最简单的初始化代码new HashMap()。你以为它创建了一个长度为 16 的数组但实际 JDK 8 里它只是设置了一个负载因子DEFAULT_LOAD_FACTOR 0.75f并没有立即创建表而是等到第一次 put 时才触发resize()去初始化容量为 16 的 Node 数组。这里有两个关键参数容量capacity和负载因子load factor。容量是桶数组的长度负载因子是“扩容的触发比例”。扩容阈值 threshold capacity × loadFactor默认就是 16 × 0.75 12。也就是说当 HashMap 中的元素个数超过 12 个时就会触发扩容将容量翻倍到 32并重新计算所有元素的桶位rehash。为什么负载因子要选 0.75而不是 0.5 或者 1.0这是时间与空间的经典权衡。负载因子越小比如 0.5那么扩容阈值就越低数组会更稀疏链表长度也更短查询效率高但浪费内存。负载因子越大比如 1.0那么桶位填充更满内存利用率高但链表变长的概率显著增加查询、插入性能会退化。0.75 是 JDK 源码作者经过大量实验得出的一个折中值在多数场景下既保证了较低碰撞概率又避免了过多内存浪费。如果你的业务符合“内存充足、对查询性能极其敏感”的特征可以手动把负载因子调小到 0.5 或 0.6反之如果内存非常紧张且数据量巨大、写多读少可以考虑调大到 0.8 左右。但这里我要明确提醒你负载因子是全局参数一旦设置后期不能动态修改一定要结合业务峰值慎重评估。再看“为什么默认容量是 16”。这个 16 并不是拍脑袋定的。它是 2 的整数次幂。HashMap 在计算 key 落在哪个桶时用的不是取模运算hash % length而是位运算(length - 1) hash。这个位运算能成立的前提就是 length 必须是 2 的幂。为什么不用取模因为位运算直接操作内存中的二进制位比取模快一个数量级。而且当 length 为 2 的幂时(length - 1) hash等价于hash % length但位运算避免了除法指令的开销。这也是为什么 HashMap 的扩容总是翻倍而不是按某个比例随机增长——只有保持容量是 2 的幂这个优化才能一直生效。2.2 hash 扰动函数为什么 hash 方法要异或右移 16 位JDK 8 的 HashMap 里有一个你可能没在意但非常精妙的函数——hash(Object key)static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }这里把 key 的 hashCode 值 h 与 h 右移 16 位后的值做异或运算。目的是把高位的特征也掺入到低位中降低哈希碰撞的概率。为什么需要这样因为 HashMap 计算桶位时使用的是(length - 1) hash而 length 通常不会特别大比如默认 16此时length - 1的二进制是1111只有低 4 位参与运算。也就是说如果 hashCode 的低 4 位都是 0那么无论高位怎么变化这些 key 都会落到同一个桶。这在某些极端情况下会引发严重的哈希碰撞。而通过 h 16将高位信息“异或”到低位相当于把高 16 位和低 16 位混合起来让低位的随机性增强。这就是所谓的“扰动函数”能有效减少碰撞。你可能会问为什么不直接用一个特别复杂的哈希函数把每个 key 的 hash 都计算得足够分散因为hashCode()本身可能已经分散了再复杂的函数会影响 put 和 get 的性能。扰动函数只做了两次位运算成本极低收益却很高。我在实际项目中特别喜欢这个设计它用微小的开销换取整体冲突率的明显下降是非常典型的“时间换空间再换时间”的经典范例。2.3 put 流程全解析从哈希到挂链再到树化的完整路径下面把 HashMap 的 put 过程完整走一遍。当你调用map.put(key, value)时JDK 8 内部会经历这些步骤第一步计算 key 的 hash 值调用上面的扰动函数。第二步检查 table 是否为空如果为空调用 resize() 初始化容量为 16。第三步用(length - 1) hash计算出桶位下标 i。第四步判断 table[i] 是否为 null如果为 null直接 new 一个 Node 放进这个位置如果不为 null说明发生了哈希碰撞。第五步碰撞后先判断链表头节点是否与当前 key 相同hash 相等且 equals 相等是则替换 value 并返回旧值。第六步如果头节点是 TreeNode 类型说明这个桶已经是红黑树结构走 putTreeVal 逻辑。第七步否则遍历链表如果找到相同 key 则替换并返回旧值如果遍历到尾部还没找到则尾插法新增节点同时判断链表长度是否达到树化阈值 8如果达到并且数组长度达到最小树化容量 64就调用 treeifyBin 把链表转化为红黑树。最后put 完成后判断size threshold是则触发扩容。这里有几个关键细节值得展开。第一个是树化阈值 8。为什么链表长度达到 8 才转红黑树而不是 6 或 10这背后有概率统计的逻辑。在随机哈希码下节点分配到桶中的个数服从泊松分布。当负载因子为 0.75 时链表长度达到 8 的概率约为千万分之六大约 0.0000006这是一个极低概率事件。也就是说正常情况下根本不会触发树化一旦触发说明哈希函数出了问题或者 key 的 hashCode 分布极差。所以设置 8 可以理解为“用红黑树来兜底极端哈希碰撞场景”。第二个是红黑树退化为链表。当红黑树中的节点数量减少到 6 以下时会通过 untreeify 转回链表。为什么是 6 而不是 8这是为了避免“反复横跳”如果删除元素后树化阈值和退化阈值相同比如都是 8那么当元素在 7、8、9 之间震荡时就会频繁地在链表和树之间切换性能开销很大。中间留出 2 个数字的缓冲区间可以有效减少结构转换的频率。第三个是为什么树化还有一个前置条件数组长度必须大于等于 64。如果链表长度达到 8但是数组长度还小于 64这时候优先进行扩容而不是树化。为什么因为当数组很小的时候链表变长很可能只是“桶位太少导致的拥挤”通过扩容翻倍使得 key 重新分布到更多桶中链表长度自然就降下来了。只有在数组容量已经足够大但某些桶的链表依然过长时才说明是真正的哈希碰撞问题此时树化才有意义。2.4 扩容机制详解rehash 时的高低位迁移技巧HashMap 的扩容是一个比较重操作。当 size 超过 threshold 时会创建一个容量为原来 2 倍的新数组然后将旧数组中的所有节点重新分配到新数组中。JDK 8 对扩容做了一个非常重要的优化不再逐节点重新计算 hash。由于容量是 2 的整数次幂扩容时每个节点的新位置要么还是在原来的下标 i要么变成了i oldCap。这个判断依据是(e.hash oldCap) 0。我来解释下这个公式怎么来的。假设旧容量 oldCap 16二进制是10000。扩容后新容量是 32二进制是100000。下标计算是(length - 1) hash。旧下标取的是 hash 的低 4 位新下标取的是 hash 的低 5 位。区别就在于 hash 的第 5 位也就是二进制中对应 oldCap 的那一位是 0 还是 1。如果e.hash oldCap 0说明这个 key 在第 5 位上是 0新下标与旧下标相同如果等于 oldCap说明第 5 位是 1新下标就是旧下标加上 oldCap。这个技巧非常巧妙它省去了重新计算每个 key 的 hash 和取模的操作只需要一次位与运算就能把原来的一条链表或树拆分成高位和低位两条链表然后分别放到新数组的 i 和 i oldCap 两个位置。这个“高低位拆分”的思路后来也被 ConcurrentHashMap 借鉴在并发扩容中起到了重要作用。扩容时还有一个值得注意的点原来的红黑树在扩容时会被拆分成 high 和 low 两条链表如果拆分后的链表长度小于等于 6会被退化成链表否则继续保持树化结构。所以扩容不只是“把元素挪个位置”还要维护树和链表的动态平衡。3. 哈希碰撞与容量选择的实战经验这几招能帮你避开 90% 的性能坑3.1 为什么预设置初始容量这么重要很多人用 HashMap 都是直接new HashMap()然后往里面循环 put 数据。如果数据量预估是 10000 条这样会有什么问题默认容量 16负载因子 0.75阈值是 12。插入第 13 个元素时触发第一次扩容到 32插入第 25 个元素时触发第二次扩容到 64插入第 49 个元素时触发第三次扩容到 128……你会发现当数据量达到 10000 时已经扩容了 9 到 10 次。每次扩容都要重新分配数组、迁移所有节点代价非常高。正确的做法是在创建 HashMap 时预估好数据量并设置合理的初始容量。有一条公式经常被提到initialCapacity (预估元素个数 / 负载因子) 1。比如预估 10000 条数据那么 initialCapacity 10000 / 0.75 1 13334向上取整到 2 的幂就是 16384。这样整个生命周期内不会触发扩容或者极少触发性能最优。如果你用 Guava 的Maps.newHashMapWithExpectedSize(10000)它执行的就是这个公式。如果不依赖 Guava也可以自己算。我从实际经验出发的体会是凡是能提前预估数量的场景一定不要偷懒默认初始化这个习惯在数据量大时差距是质变的。实测下来一个 10 万量级的 put 操作预设置容量比默认容量能快 20% 左右内存分配也更稳定。3.2 重写 hashCode 的常见误区与正确姿势HashMap 的 key 通常用 String 或 Integer这些类的 hashCode 已经足够分散不用太担心。但如果你用自定义对象作为 key那么 hashCode 的写法直接决定了 HashMap 的性能和正确性。最常见的错误是只重写 equals不重写 hashCode。这样会造成两个逻辑上相同的对象hashCode 不同被放在不同的桶里HashMap 的 get 永远找不到值。反之只重写 hashCode 不重写 equals则可能导致“哈希冲突明明找到了桶但 equals 判断不相等”无法正确取值。所以重写规则必须是equals 相等的两个对象hashCode 必须相等hashCode 相等时equals 可以不等。另一个错误是把 hashCode 实现为固定常数比如所有对象都返回 1。这会让所有 key 都落在同一个桶里HashMap 直接退化成链表查询复杂度从 O(1) 退化为 O(n)。我曾在一个项目中看到有人图省事用Objects.hash(id, name)生成了 hashCode后来又因为性能问题来排查发现这个对象作为 key 时大量 key 的 hash 值低位完全相同导致某几个桶的数据量异常大。排查过程用到了红黑树的树化逻辑才意识到 hash 分布不好的严重性。在重写 hashCode 时我推荐遵循Objects.hash()或31 * 字段1 字段2这种经典写法。为什么是 31因为它是奇素数乘法运算在虚拟机里可以优化为移位和减法操作而且它的分布效果在实践中表现很好。不要用偶数比如 2、4、8因为它们会导致 hash 值在二进制低位上出现大量 0加大碰撞概率。3.3 别再用 HashMap 做并发缓存一个线上故障案例我在第 1 节提过 JDK 7 的并发死循环问题这里展开说一个我经历过的真实案例。某次线上服务发布后CPU 突然持续飙到 90% 以上当时查线程栈发现大量线程阻塞在 HashMap.getNode 方法里循环永远出不来。深入排查后发现有一个业务线程在往一个静态的 HashMap 里写入数据另一个线程同时对同一个 Map 做读操作。写入触发了扩容扩容时多个线程同时执行 transfer 逻辑链表被反转后形成了环。之后任何线程在这个环上查找 key都会陷入无限循环。这个案例告诉我们一个道理不要相信“偶尔并发写入没问题”的侥幸心理。HashMap 的非线程安全不是“概率小就不会发生”而是“一旦发生就是毁灭性的”。在线业务里哪怕只是想做一个简单的本地缓存并发写入场景也一定要避开 HashMap。Java 里提供了Collections.synchronizedMap可以做简单包装但它的内部实现也是全局锁性能上跟 HashTable 没什么本质区别。我后续在这个场景换成了 ConcurrentHashMap 之后问题立刻消失CPU 恢复平稳。这个案例的教训后来被我写进了团队代码规范作为典型反面教材。4. HashTable 为什么成了被时代淘汰的容器4.1 全局锁的实现方式与性能瓶颈HashTable 的线程安全实现非常“简单粗暴”它把所有可能引发线程安全问题的 public 方法都用 synchronized 关键字修饰。比如 put、get、remove、containsKey 等全部锁在 this 对象上。这种实现方式在低并发、小数据量时代还能用但一旦并发量上来问题就非常突出。因为所有线程都在抢同一把锁即使它们操作的是完全不同的桶位也无法同时执行。读操作也要等待锁释放这在读多写少的高并发场景下简直是灾难。你想象一个场景100 个线程同时读取一个 HashTable理想情况下应该瞬间完成但实际却是串行执行每个线程都要等待前一个线程释放锁。吞吐量直接降到原来的百分之一。更麻烦的是由于锁的粒度是整个对象HashTable 的扩容也会阻塞所有读写操作。一旦触发扩容所有线程都会阻塞在扩容方法上等待一个线程完成所有节点的 rehash。数据量越大扩容时间越长系统停顿越明显。4.2 HashTable 和 ConcurrentHashMap 的迭代器差异HashTable 的迭代器是 fail-fast 的。如果在迭代过程中有其他线程修改了 HashTable增删改迭代器会抛出 ConcurrentModificationException。这种设计本意是快速暴露并发修改问题但代价是“迭代过程的可用性极差”高并发场景下这个异常几乎无法避免。ConcurrentHashMap 的迭代器则是弱一致性的。它不保证迭代过程中一定能看到其他线程的最新修改但也绝不会抛出 ConcurrentModificationException。它更关注“迭代过程中的可用性”。这个区别在分布式系统的本地缓存场景中非常重要你可以在一个线程不断 put 的同时另一个线程安全地遍历整个 Map。虽然遍历结果可能不是严格实时一致的但对于大多数缓存类应用来说这种弱一致性完全够用。4.3 什么时候 HashTable 仍有存在价值虽然 HashTable 在绝大多数场景下都不推荐但它并非一无是处。在非常小的并发压力下比如程序启动时加载配置、单线程全局变量需要线程安全兜底时HashTable 的代码简单、行为可预测反而比 ConcurrentHashMap 更容易理解。另外HashTable 不允许 null 的特点可以强制某些业务场景中的 key 和 value 不为空减少 NPE 隐患的排查成本。不过我的建议是新项目一律不要用 HashTable。它在 JDK 11 中虽然未被标记为废弃但所有官方文档和框架实现都已经明确转向了 ConcurrentHashMap。既然有更好的选择就没必要抱着一把全局锁不放了。5. ConcurrentHashMap 的并发演进从分段锁到 CAS synchronized5.1 JDK 7 的 Segment 分段锁设计JDK 7 的 ConcurrentHashMap 采用了一个非常重要的设计——Segment 分段锁。它内部维护了一个 Segment 数组每个 Segment 本身就是一个小的 HashTableSegment继承了ReentrantLock所以每个 Segment 都有自己的锁。put 操作时通过 key 的 hash 定位到某个 Segment然后只锁住这个 Segment其他 Segment 不受影响。这意味着默认情况下如果有 16 个 Segment理论上最多可以有 16 个线程同时写入不同的 Segment并发度是 HashTable 的 16 倍。读操作呢由于 Segment 内部的 Node 是用 volatile 修饰的读操作无需加锁直接读取所以读性能非常高。但这种设计也有弊端Segment 的个数一旦初始化就不能改变默认 16扩容时只会对某个 Segment 内部的数组扩容不会影响其他 Segment。也就是说Segment 级别的并发度上限是固定的无法随数据量增长而提升。另外Segment 继承了 ReentrantLock意味着每次 put 都要走一次锁的获取和释放在竞争激烈时锁的开销会变得很明显。5.2 JDK 8 的 CAS synchronized 锁桶位设计JDK 8 对 ConcurrentHashMap 做了一次彻底的重写核心变化是抛弃了 Segment改用 Node 数组 CAS synchronized 的实现方式。这几乎就是照着 HashMap 的数据结构升级出来的并发版本。put 流程大致是这样的先计算 key 的 hash定位桶位。如果桶位为空则用 CAS 操作尝试直接放入新节点这是无锁的快速路径。如果 CAS 失败说明有其他线程抢先占据了此时对桶位的头节点加 synchronized 锁然后进入链表或红黑树的插入流程。如果数组正在扩容当前线程会先协助完成扩容再继续自己的 put 操作。这个设计的精妙之处在于无锁处理最乐观的情况锁只处理发生竞争的桶位。绝大多数 put 操作如果落在空桶上就完全不用加锁只有哈希冲突时才加锁而且锁的粒度从 Segment 级别的“一段区域”细化到“单个桶位”级别。换句话说只要不同线程的 key 落在不同桶位它们就能完全并发执行互不干扰。理论并发度从 Segment 数默认 16提升到了数组长度默认 16扩容后可达数百甚至数千。5.3 sizeCtl 与扩容协助机制一个值得反复品读的细节ConcurrentHashMap 里有一个非常核心的 volatile 变量sizeCtl它扮演了多重角色当数组为 null 时sizeCtl 表示初始容量当数组正在初始化时sizeCtl 为 -1表示有线程正在进行初始化当数组正常使用时sizeCtl 等于扩容阈值容量 × 负载因子当扩容时sizeCtl 为一个负值高 16 位表示扩容戳低 16 位表示参与扩容的线程数 1。这个变量是保证多线程扩容安全的关键。当某个线程触发扩容后会通过 CAS 操作把 sizeCtl 更新为扩容状态其他线程在 put 时发现sizeCtl 0就知道当前正在进行扩容于是会加入到扩容工作中这就是协助扩容机制。扩容时节点迁移采用“任务分块”的方式整个数组会被切分成多个 stride 任务段每个参与扩容的线程领取一个任务段迁移完后再领取下一个。迁移完一段后通过 CAS 更新transferIndex保证不同线程不会重复处理同一个区域。这样扩容这个原本在 HashMap 中只能单线程做的事在 ConcurrentHashMap 中变成了多线程并行扩容效率大幅提升。5.4 为什么 ConcurrentHashMap 读操作可以做到几乎无锁ConcurrentHashMap 的读操作 get 是不加锁的。它的安全性依赖两个核心机制一是 Node 数组本身是 volatile 修饰的所以数组引用变更扩容后数组替换对读线程是可见的二是 Node 类中的 value 字段和 next 字段都是 volatile 修饰的所以在并发写入时读线程要么读到旧值要么读到新值但绝不会读到中间状态。这里需要区分volatile 只能保证可见性不能保证原子性。但在 ConcurrentHashMap 的设计中桶位上的新值是通过“先构建一个完整的 Node 对象再通过 CAS 把 Node 引用发布到数组槽位”这个方式实现的。一旦 Node 发布成功这个 Node 的所有字段都已经构造完成并且可见。这就是所谓的“发布安全”safe publication。所以读线程虽然不加锁但它能安全地读到完整的节点内容。考虑到这些细节你可能会明白为什么 ConcurrentHashMap 的源码难度比 HashMap 高一个量级它需要你在脑海里同时把握 CAS、volatile、synchronized、红黑树、扩容协作等多线程知识点任何一个环节理解不到位看代码都是云里雾里。5.5 JDK 8 中 size 统计的妙招LongAdder 思路的借鉴ConcurrentHashMap 的size()方法并不准确它返回的是一个估计值。为什么因为在并发写入下精确计数成本极高。为了性能它借鉴了LongAdder的思想内部维护一个 baseCount 变量和一组 CounterCell 数组。当并发更新时线程会通过 CAS 尝试更新 baseCount如果 CAS 失败说明竞争激烈就分散到某个 CounterCell 上累加最后求和时把 baseCount 和所有 CounterCell 的值加在一起。这本质上是一个“分段计数”的思路把单一计数变量的竞争压力分散到多个槽位上。实际使用中如果你需要非常精确的元素个数不建议依赖 size()而是自己维护一个 AtomicLong或者在不写入的时候调用 size() 获取快照。6. 生产环境选型建议一张决策指南与三个避坑要点6.1 什么时候用哪个容器说了这么多原理最后落到实际选择上。我的建议可以浓缩成一条决策路径如果应用场景是单线程访问或者能保证不会出现并发写例如只在初始化阶段一次性 put之后只读直接使用 HashMap它可以给你最好的读写性能。如果需要对线程不安全进行简单兜底并且并发量很低Collections.synchronizedMap或 HashTable 也可以接受但我不推荐新代码使用。如果涉及多线程并发读写无脑选择 ConcurrentHashMap。如果只是并发读、单线程写HashMap 也能顶住但为了长期安全不建议赌这个。ConcurrentHashMap 并不是在任何场景都比 HashMap 快。在小数据量、单线程场景下ConcurrentHashMap 由于存在额外的 CAS、volatile 读等开销性能反而可能低于 HashMap。所以选型时不要盲目追求“线程安全”只有在确实存在并发问题时才需要上锁。6.2 三个必须避开的坑第一个坑是误用containsKey后再put的非原子组合。在并发场景下先判断再写入会存在竞态条件两个线程可能同时判断 key 不存在然后同时写入导致覆盖。ConcurrentHashMap 提供了putIfAbsent和compute等方法能保证原子性。高并发场景下推荐使用这些方法而不是自己组合两步操作。第二个坑是没有留意 key 的可变性。如果使用可变对象作为 HashMap 的 key且在放入 Map 后修改了该对象中参与 hashCode 计算的字段会导致这个 key 的 hash 值变化但它在 Map 中的存储位置不会跟着变。后续 get 时用同一个对象去查hash 已经变了必然找不到。这个 bug 特别隐蔽因为它不会报错只是返回 null。唯一的解决方案是尽量使用不可变对象如 String、Integer作为 key或者在 key 放入 Map 后绝不修改其参与 hashCode 的字段。第三个坑是无脑使用new HashMap(10000)以为容量就是 10000。我见过不少人误把参数当成“容量”实际上它只是初始容量HashMap 会根据负载因子计算出阈值。如果阈值小于实际存放的数据量一样会触发扩容。正确做法是使用10000 / 0.75 1来反推初始容量或者直接使用 Guava 的工具方法。6.3 一个实用的性能对比实验思路如果你想亲自验证三者的性能差异可以写一个简单的 JMH 基准测试。比如分别用 HashMap、HashTable、Collections.synchronizedMap、ConcurrentHashMap 执行 100 万次 put 操作线程数分别设为 1、8、16记录吞吐量。你会发现单线程下 HashMap 绝对领先ConcurrentHashMap 略慢8 线程下 ConcurrentHashMap 的吞吐量远超 HashTable 和 synchronizedMap16 线程下差别更加明显。这个实验做一遍你对并发容器选型的记忆会比看十篇博客都牢。7. 常见问题与排查技巧实录7.1 HashMap 死循环问题JDK 7问题表现CPU 100%线程栈停留在 HashMap.getNode 或 transfer 方法。排查思路先用top -Hp找到 CPU 最高的线程再用 jstack 抓线程栈看到大量线程卡在 HashMap 相关方法上并且方法调用栈上出现循环引用基本可以断定是并发扩容导致的链表成环。解决方式将 HashMap 替换为 ConcurrentHashMap。如果非要用 HashMap必须从业务上保证只有单线程写入。7.2 ConcurrentHashMap 的 size 不准确问题问题表现size() 返回的值与预期不完全一致。排查思路这不是 bug而是弱一致性的设计。高并发下 size() 本来就是一个估计值。如果需要精确值在业务低峰期获取或自行维护计数器。解决方式使用mappingCount()方法它返回 long比 size() 更精确但仍不是绝对精确。7.3 红黑树化后性能反而不升反降问题表现在某些极端场景下HashMap 的 put 和 get 反而变慢了。排查思路查看该 Map 中是否存在大量 key 分布极度不均匀的情况。比如某个自定义类的 hashCode 返回了固定值导致所有 key 进入同一个桶链表退化后触发了树化。树化后红黑树的查找是 O(log n)虽然比链表 O(n) 好但比正常分布的 O(1) 差得多。根因还是 hashCode 质量太差。解决方式优化自定义 key 的 hashCode让二进制低位充分分散这才是治本之策。7.4 ConcurrentHashMap 的 key 为 null 导致 NPE问题表现调用 put 方法时抛出 NullPointerException但业务代码里看不出为什么。排查思路检查 key 或 value 是否为 null。ConcurrentHashMap 明确不允许 null key 和 null value。解决方式在 put 前做防御性判断或使用 Optional 包装。不要在业务里依赖“value 为 null 表示不存在”这种隐式逻辑。7.5 如何快速定位 HashMap 中链表过长或树化严重的问题排查思路可以通过打印 HashMap 内部 table 数组中每个桶的节点数来观察分布状况。虽然这需要反射或调试工具但这类定位手段在分析哈希质量问题时很实用。如果你发现某个桶的节点数超过 20甚至触发了红黑树化而你的 key 是 String 类型那大概率是哈希函数或者数据结构选型出了问题。解决方式换用 TreeMap 或调整 key 的生成逻辑。8. 关于源码阅读与面试深度的个人经验分享最后聊聊我自己的一个习惯每次读多线程源码我不会只盯着某个方法看而是先画一张“时间线图”模拟两个线程同时 put、一个线程扩容时的状态流转。尤其是 ConcurrentHashMap 的协助扩容机制如果不模拟多线程场景只看代码是看不出它有多精巧的。我建议你也试试这个方法。在实际项目中我不太建议从零开始重写一个 Map 来“练手”因为要想达到 ConcurrentHashMap 的正确性和并发性能难度极高。更好的方式是先通过上面提到的 JMH 基准测试感受性能差异再对照源码把 put、扩容、size 这几个关键流程的口述逻辑写下来对着源码一步步验证。当你有一天能在不看源码的情况下把 ConcurrentHashMap 的整个 put 流程画成一张准确的流程图你对 Java 并发的理解就算真正过关了。这篇文章从底层原理写到线上排查核心是把三个容器彻底讲透。我相信按照这些思路去实践你不仅在面试中能对答如流更重要的是在真实业务里你再也不会因为“随手 new 一个 HashMap”而栽跟头了。