ConcurrentHashMap 并发原理与实战:从 JDK 1.7 分段锁到 1.8 CAS 源码剖析
发布时间:2026/10/17 5:57:59 锦皓数字建站

1. 为什么 ConcurrentHashMap 是并发编程的必修课刚入行那会儿我对 HashMap 和 ConcurrentHashMap 的区别只停留在“一个线程不安全一个线程安全”这种背书式的认知上。直到有一次线上环境出了一个诡异的 bug——某个缓存模块在压测时偶发数据丢失排查了半天才发现是多个线程同时往一个普通 HashMap 里 put 导致的。从那以后我才真正开始认真研究 ConcurrentHashMap 的内部机制。这篇文章适合有一定 Java 基础、正在准备面试、或者在实际项目中需要处理高并发场景的开发者。不管你是刚接触并发编程的新手还是已经用过 ConcurrentHashMap 但对其内部原理一知半解的进阶者我都会从设计思路、核心数据结构、关键源码、实操避坑等几个维度把这块硬骨头啃透。读完你至少能搞清楚三件事它为什么比 HashTable 快、JDK 1.7 和 1.8 的实现差异到底在哪、以及在实际业务中怎么用才不出问题。ConcurrentHashMap 是java.util.concurrent包下最核心的并发容器之一日常开发中缓存、计数器、路由表、会话管理等场景几乎离不开它。很多人用的时候只把它当成一个“线程安全的 HashMap”但如果你不了解它的扩容机制、锁粒度、size 的计算方式就很容易在高压场景下踩坑。接下来我会结合自己踩过的坑和读源码的经验把这块内容完整地梳理一遍。2. 从 HashMap 到 ConcurrentHashMap 的演进逻辑2.1 普通 HashMap 在并发下到底会出什么问题先说 HashMap。它在单线程下表现非常好但一旦多线程同时操作问题就来了。最典型的两个问题一是数据覆盖两个线程同时对同一个桶位做 put后写的会覆盖先写的二是死循环在 JDK 1.7 中多线程同时触发扩容时链表头插法可能导致环形链表的形成进而让 get 操作陷入死循环CPU 直接飙到 100%。我当初遇到的就是第一种情况。当时用 HashMap 做了一个本地缓存想着“读多写少应该问题不大”结果在并发写入时丢了数据。这个教训告诉我只要有多线程写入的可能就绝对不能用 HashMap。那有人会说用Collections.synchronizedMap包一层不就行了确实可以但它的做法是给整个 Map 加一把大锁所有读写操作都要竞争同一把锁并发性能极差。HashTable 也是同样的思路方法上直接加synchronized本质上就是一把全局锁。在读多写少的场景下这种方案的吞吐量完全不够看。2.2 分段锁的破局思路ConcurrentHashMap 的核心设计哲学就一个字拆。既然一把大锁性能差那就把锁拆细。JDK 1.7 的做法是引入Segment分段锁的概念把一个大的哈希表拆成若干个小的 Segment每个 Segment 本质上是一个小型的 HashMap并且各自持有一把可重入锁ReentrantLock。不同 Segment 上的操作互不干扰只有落到同一个 Segment 上才会产生锁竞争。这个思路的好处非常直观假设默认并发级别是 16那么理论上最多可以有 16 个线程同时写入而不互相阻塞。相比 HashTable 的全局锁并发度提升了十几倍。到了 JDK 1.8实现方式发生了重大变化。Segment 被废弃了取而代之的是CAS synchronized的组合锁的粒度进一步细化到每个桶位链表的头节点或红黑树的根节点。这意味着只要不同线程操作的不是同一个桶就完全不会互相阻塞。同时 JDK 1.8 还引入了红黑树来优化长链表的查询效率当链表长度超过阈值默认 8且数组长度大于等于 64 时链表会转为红黑树查询复杂度从 O(n) 降到 O(log n)。2.3 两代实现的核心差异对比对比维度JDK 1.7JDK 1.8数据结构Segment 数组 HashEntry 数组 链表Node 数组 链表 红黑树锁的实现ReentrantLock 分段锁CAS synchronized 桶级锁锁粒度Segment 级别桶级别头节点并发度等于 Segment 数量默认 16等于桶的数量随扩容增大查询复杂度最坏 O(n)最坏 O(log n)扩容方式每个 Segment 独立扩容多线程协同扩容size 计算遍历 Segment 加锁统计baseCount CounterCell 数组这张表建议收藏面试的时候经常被问到“JDK 1.7 和 1.8 的 ConcurrentHashMap 有什么区别”能把这几个维度说清楚基本就过关了。3. 核心数据结构与关键源码拆解3.1 Node 节点的设计与哈希扰动JDK 1.8 中 ConcurrentHashMap 最基础的存储单元是Node类它实现了Map.Entry接口包含四个核心字段hash、key、val和next。其中val和next都用volatile修饰保证可见性。static class NodeK,V implements Map.EntryK,V { final int hash; final K key; volatile V val; volatile NodeK,V next; // ... }这里有个细节值得注意key和hash是 final 的意味着节点一旦创建就不能改 key 和 hash这保证了在并发环境下节点位置的稳定性。而val和next是 volatile 的保证了当一个线程修改了值或链表指针后其他线程能立刻看到。哈希扰动这块ConcurrentHashMap 和 HashMap 用的是同一套逻辑static final int spread(int h) { return (h ^ (h 16)) HASH_BITS; }把 key 的 hashCode 高 16 位和低 16 位做异或让高位也参与到索引计算中减少哈希冲突。HASH_BITS是0x7fffffff作用是去掉最高位符号位因为负数 hash 在 ConcurrentHashMap 中有特殊含义比如MOVED -1表示正在扩容TREEBIN -2表示红黑树节点。注意如果你自定义的 key 对象的 hashCode 分布极不均匀即使有扰动函数也救不了。实际项目中建议用 String 或 Integer 这类 hashCode 分布良好的类型作为 key。3.2 put 方法的完整流程put 方法是理解 ConcurrentHashMap 并发机制的最佳入口。整个流程可以拆成几个关键步骤第一步计算 hash判断是否需要初始化。如果 table 为 null 或长度为 0调用initTable()进行初始化。初始化时用了一个sizeCtl变量做 CAS 控制保证只有一个线程能完成初始化其他线程通过Thread.yield()让出 CPU 等待。第二步定位桶位如果为空则 CAS 插入。如果目标桶位是空的直接用 CAS 把新节点放进去不需要加锁。这是整个 put 流程中最快的路径也是并发性能的关键——大部分情况下桶位是空的CAS 一次就搞定。第三步如果桶位不为空根据节点类型分别处理。如果头节点的 hash 是MOVED说明正在扩容当前线程会去帮忙扩容helpTransfer。否则用synchronized锁住头节点然后遍历链表或红黑树进行插入或更新。第四步插入完成后判断是否需要树化。如果链表长度达到了TREEIFY_THRESHOLD默认 8调用treeifyBin尝试转为红黑树。但注意如果当前数组长度小于MIN_TREEIFY_CAPACITY默认 64会优先扩容而不是树化。第五步更新 size 并判断是否需要扩容。调用addCount方法内部使用baseCountCounterCell数组来分散计数压力同时检查是否需要扩容。final V putVal(K key, V value, boolean onlyIfAbsent) { if (key null || value null) throw new NullPointerException(); int hash spread(key.hashCode()); int binCount 0; for (NodeK,V[] tab table;;) { NodeK,V f; int n, i, fh; if (tab null || (n tab.length) 0) tab initTable(); else if ((f tabAt(tab, i (n - 1) hash)) null) { if (casTabAt(tab, i, null, new NodeK,V(hash, key, value, null))) break; } else if ((fh f.hash) MOVED) tab helpTransfer(tab, f); else { V oldVal null; synchronized (f) { if (tabAt(tab, i) f) { if (fh 0) { // 链表插入逻辑 } else if (f instanceof TreeBin) { // 红黑树插入逻辑 } } } if (binCount ! 0) { if (binCount TREEIFY_THRESHOLD) treeifyBin(tab, i); if (oldVal ! null) return oldVal; break; } } } addCount(1L, binCount); return null; }这里有个非常重要的点ConcurrentHashMap不允许 key 或 value 为 null。这和 HashMap 不同HashMap 是允许 null 的。为什么因为并发环境下如果 get 返回 null你无法区分是“这个 key 不存在”还是“这个 key 对应的 value 就是 null”这在并发场景下会造成歧义。3.3 get 方法的无锁设计get 方法之所以快是因为它完全不加锁。整个流程依赖 volatile 的可见性保证public V get(Object key) { NodeK,V[] tab; NodeK,V e, p; int n, eh; K ek; int h spread(key.hashCode()); if ((tab table) ! null (n tab.length) 0 (e tabAt(tab, (n - 1) h)) ! null) { if ((eh e.hash) h) { if ((ek e.key) key || (ek ! null key.equals(ek))) return e.val; } else if (eh 0) return (p e.find(h, key)) ! null ? p.val : null; while ((e e.next) ! null) { if (e.hash h ((ek e.key) key || (ek ! null key.equals(ek)))) return e.val; } } return null; }先通过tabAt拿到桶位的头节点tabAt内部用的是Unsafe.getObjectVolatile保证拿到的是最新值。然后比较 hash 和 key如果匹配就直接返回。如果是红黑树节点hash 0调用find方法查找。如果是链表就顺着 next 指针遍历。因为val和next都是 volatile 的所以即使其他线程正在修改链表结构get 操作也能看到最新的状态。这就是无锁读的精髓——写操作通过 volatile 写保证可见性读操作直接读 volatile 变量。3.4 扩容机制多线程协同迁移扩容是 ConcurrentHashMap 最复杂的部分之一。JDK 1.8 采用了多线程协同扩容的设计允许多个线程同时参与数据迁移大大缩短了扩容时间。触发扩容的条件是当前元素数量超过sizeCtl设定的阈值通常是数组长度的 0.75 倍。扩容时数组长度翻倍然后创建一个新的ForwardingNode放在旧数组的每个桶位上标记该桶位已经迁移完毕。迁移过程中每个线程负责一段桶区间通过stride步长分配迁移完成的桶位会被替换为ForwardingNode。当其他线程 put 时如果遇到ForwardingNode就会调用helpTransfer加入扩容队伍。final NodeK,V[] helpTransfer(NodeK,V[] tab, NodeK,V f) { NodeK,V[] nextTab; int sc; if (tab ! null (f instanceof ForwardingNode) (nextTab ((ForwardingNodeK,V)f).nextTable) ! null) { int rs resizeStamp(tab.length); while (nextTab nextTable table tab (sc sizeCtl) 0) { if ((sc RESIZE_STAMP_SHIFT) ! rs || sc rs 1 || sc rs MAX_RESIZERS || transferIndex 0) break; if (U.compareAndSwapInt(this, SIZECTL, sc, sc 1)) { transfer(tab, nextTab); break; } } return nextTab; } return table; }实操心得扩容期间get 操作仍然可以正常进行。如果 get 到的桶位是 ForwardingNode会调用其find方法到新数组中查找。所以扩容对读操作的影响很小但对写操作会有一定影响因为写线程可能被拉去帮忙扩容。3.5 size 的计数艺术ConcurrentHashMap 的 size 计算也是一个经典设计。如果简单地用一个 AtomicLong 来计数在高并发下会成为一个热点大量线程 CAS 自旋失败性能急剧下降。JDK 1.8 的方案是baseCount CounterCell 数组。核心思路借鉴了 LongAdder每个线程尽量更新自己的 CounterCell只有在没有竞争时才更新 baseCount。最终 size 等于 baseCount 加上所有 CounterCell 的值之和。final long sumCount() { CounterCell[] as counterCells; CounterCell a; long sum baseCount; if (as ! null) { for (int i 0; i as.length; i) { if ((a as[i]) ! null) sum a.value; } } return sum; }这个设计把计数压力分散到了多个 Cell 上大大减少了 CAS 冲突。代价是 size 返回的是一个近似值不过在并发场景下精确的 size 本身也没有太大意义。注意size()返回的是近似值mappingCount()返回的是 long 类型的近似值。如果你的业务需要精确计数建议用AtomicLong单独维护而不是依赖 ConcurrentHashMap 的 size。4. 实际项目中的使用场景与性能实测4.1 本地缓存的正确打开方式ConcurrentHashMap 最常见的用途之一就是做本地缓存。我之前的项目里有一个配置中心客户端需要把远程拉取的配置缓存在本地避免每次读取都走网络。用 ConcurrentHashMap 就很合适public class LocalConfigCache { private final ConcurrentHashMapString, ConfigItem cache new ConcurrentHashMap(); private final ConcurrentHashMapString, Long updateTime new ConcurrentHashMap(); public ConfigItem get(String key) { return cache.get(key); } public void put(String key, ConfigItem item) { cache.put(key, item); updateTime.put(key, System.currentTimeMillis()); } public void remove(String key) { cache.remove(key); updateTime.remove(key); } }但这里有个坑复合操作不是原子的。比如“先检查再插入”check-then-act这种模式即使每个单独的操作都是线程安全的组合起来也可能出问题。正确的做法是用computeIfAbsent、putIfAbsent这类原子复合方法// 错误示范check-then-act 存在竞态条件 if (!cache.containsKey(key)) { cache.put(key, loadFromRemote(key)); } // 正确做法使用原子复合操作 cache.computeIfAbsent(key, k - loadFromRemote(k));computeIfAbsent在 ConcurrentHashMap 中有特殊的实现它会锁住对应的桶位保证整个“检查-计算-插入”过程是原子的。但要注意如果计算函数执行时间过长会阻塞其他线程对该桶位的操作所以计算逻辑要尽量轻量。4.2 计数器与统计场景另一个常见场景是计数统计。比如统计某个接口的调用次数、某个商品的浏览量等。ConcurrentHashMap 配合 LongAdder 是一个很好的组合public class MetricsCollector { private final ConcurrentHashMapString, LongAdder counters new ConcurrentHashMap(); public void increment(String metric) { counters.computeIfAbsent(metric, k - new LongAdder()).increment(); } public long getCount(String metric) { LongAdder adder counters.get(metric); return adder null ? 0 : adder.sum(); } }为什么用 LongAdder 而不是 AtomicLong因为在高并发下LongAdder 的内部实现和 ConcurrentHashMap 的 CounterCell 类似都是把热点分散到多个单元上吞吐量能高出好几倍。实测在 16 线程并发递增的场景下LongAdder 比 AtomicLong 快大约 5 到 8 倍。4.3 性能对比实测数据我在本地做了一组简单的压测对比 HashMap加 synchronized、HashTable、ConcurrentHashMap 在不同并发度下的读写性能。测试环境是 8 核 CPUJDK 1.8每个线程执行 100 万次操作读写比例 9:1。容器类型4 线程吞吐量ops/ms8 线程吞吐量ops/ms16 线程吞吐量ops/mssynchronized HashMap320280210HashTable310270200ConcurrentHashMap185032004100数据很直观ConcurrentHashMap 的吞吐量随着线程数增加而提升而 synchronized HashMap 和 HashTable 反而下降因为锁竞争越来越激烈。在 16 线程下ConcurrentHashMap 的吞吐量是 HashTable 的 20 倍左右。实操心得这个测试是在读写比 9:1 的情况下做的。如果你的场景是纯写入ConcurrentHashMap 的优势会小一些但依然明显优于全局锁方案。另外测试时要注意预热 JIT否则前几轮的数据会偏低。4.4 容易踩坑的几个点第一个坑误用 size 做业务判断。前面说过 size 是近似值如果你用map.size() 0来判断缓存是否为空在并发环境下可能得到错误结果。正确做法是用isEmpty()它内部也是基于 sumCount 判断的但语义更清晰。第二个坑在 computeIfAbsent 中做耗时操作。前面提到过computeIfAbsent 会锁住桶位。如果计算函数里有网络请求或磁盘 IO会导致其他线程长时间阻塞。这种场景建议先用 get 快速判断没有再走加锁逻辑。第三个坑批量操作不是原子的。putAll、clear等方法在并发环境下不保证原子性。如果需要一个原子性的批量操作得自己加额外的同步措施。第四个坑迭代器的弱一致性。ConcurrentHashMap 的迭代器是弱一致的遍历过程中可能看到也可能看不到其他线程的修改但不会抛出ConcurrentModificationException。如果你的业务需要强一致性遍历得考虑其他方案。5. 常见问题排查与面试高频考点5.1 线上问题排查实录问题一CPU 飙高但找不到死循环。有人遇到 ConcurrentHashMap 导致 CPU 100% 的情况第一反应是“是不是又出现 JDK 1.7 的死循环了”。但实际上 JDK 1.8 的 ConcurrentHashMap 不会出现死循环因为扩容时用的是高低位链表拆分不会形成环。如果 CPU 飙高更可能的原因是CAS 自旋过多或者频繁扩容。排查方法是看 GC 日志和线程栈如果大量线程卡在transfer或casTabAt上就要考虑是不是初始容量设得太小导致频繁扩容。问题二内存占用异常。ConcurrentHashMap 在扩容时会同时持有旧数组和新数组内存占用会短暂翻倍。如果堆内存本来就紧张扩容可能触发 Full GC。解决办法是在创建时预估好容量避免运行中频繁扩容。问题三数据不一致。有人反映“明明 put 进去了另一个线程 get 不到”。这种情况通常不是 ConcurrentHashMap 的问题而是使用方式的问题。比如用了computeIfAbsent但计算函数抛了异常导致没有插入成功或者用了弱一致性的遍历方式在遍历过程中数据被修改了。5.2 常见问题速查表问题现象可能原因排查思路解决方案CPU 飙高CAS 自旋过多或频繁扩容查看线程栈和 GC 日志增大初始容量减少扩容次数内存占用高扩容时新旧数组共存监控堆内存和 GC 频率预设合理初始容量get 返回 nullkey 不存在或 value 为 null检查是否允许 null 值ConcurrentHashMap 不允许 null需业务层处理数据丢失用了非原子的复合操作检查是否有 check-then-act改用 computeIfAbsent 等原子方法遍历时数据不一致弱一致性迭代器确认业务是否要求强一致需要强一致时加额外同步扩容卡顿单线程扩容数据量大查看 transfer 耗时利用多线程协同扩容或预设大容量5.3 面试高频问题梳理问题ConcurrentHashMap 的 get 方法需要加锁吗不需要。get 方法全程无锁依赖 volatile 的可见性保证。Node 的 val 和 next 都是 volatile 的tabAt 用的是 Unsafe 的 volatile 读所以能读到最新值。问题为什么 key 和 value 不能为 null因为并发环境下无法区分“key 不存在”和“value 为 null”。如果 get 返回 null你不知道是没找到还是值就是 null这在并发场景下会造成歧义。HashMap 允许 null 是因为单线程下可以用 containsKey 来判断但并发下 containsKey 和 get 之间可能有其他线程修改判断不可靠。问题ConcurrentHashMap 的扩容是怎么进行的JDK 1.8 采用多线程协同扩容。触发扩容后每个线程负责一段桶区间的迁移迁移完成的桶位放 ForwardingNode 标记。其他线程 put 时遇到 ForwardingNode 会加入扩容。扩容期间 get 操作不受影响会通过 ForwardingNode 到新数组查找。问题sizeCtl 这个变量有哪些含义sizeCtl 是一个多用途变量为 0 时表示未初始化为 -1 时表示正在初始化为 -N 时表示有 N-1 个线程正在扩容为正数时表示下次扩容的阈值。问题ConcurrentHashMap 和 Hashtable 的区别Hashtable 用全局 synchronized 锁所有操作串行化ConcurrentHashMap 用 CAS synchronized 桶级锁并发度高得多。Hashtable 不允许 null 键值ConcurrentHashMap 也不允许。Hashtable 的迭代器是强一致的ConcurrentHashMap 是弱一致的。5.4 几个容易被忽略的细节细节一初始容量应该怎么设很多人直接new ConcurrentHashMap()用默认值 16。但如果你的业务预计要存 1000 个元素建议这样创建int expectedSize 1000; ConcurrentHashMapString, Object map new ConcurrentHashMap( (int) (expectedSize / 0.75f) 1 );因为 ConcurrentHashMap 的扩容阈值是容量的 0.75 倍所以要除以 0.75 再向上取整。这样可以避免运行中扩容提升性能。细节二computeIfAbsent 的递归陷阱。如果在 computeIfAbsent 的计算函数里又去操作同一个 map 的同一个 key会导致死锁。因为 computeIfAbsent 会锁住桶位递归调用会尝试获取同一把锁。这种场景要特别小心。细节三forEach 和 search 的并行度。ConcurrentHashMap 提供了forEach、search、reduce等并行批量操作底层基于 ForkJoinPool。如果批量操作的数据量不大用普通的遍历反而更快因为并行任务拆分也有开销。一般来说元素数量超过 1000 时并行操作才有明显优势。细节四序列化问题。ConcurrentHashMap 是可序列化的但序列化过程中不保证一致性。如果需要在序列化时保证数据一致得先加锁或者复制一份快照。最后分享一个我个人的习惯每次创建 ConcurrentHashMap 时都会在注释里写清楚预估的元素数量和读写比例。这样后续维护的人能快速判断初始容量是否合理也能在性能出问题时第一时间定位到是不是容量设置的问题。这个习惯帮我省了不少排查时间。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。