☰
ConcurrentHashMap深度解析:从线程安全到高并发设计
2026/10/13 5:51:02 网站建设 项目流程

1. 为什么ConcurrentHashMap是J.U.C里最值得啃的容器

聊到Java多线程,绕不开J.U.C这个包,而J.U.C里面真正在生产环境里天天被用的并发容器,ConcurrentHashMap说第二,没人敢说第一。很多同学背八股文的时候能把“分段锁”“CAS+synchronized”“红黑树”这些词说得滚瓜烂熟,但真到了项目里,让他解释为什么HashMap在多线程下会死循环、为什么ConcurrentHashMap的size()方法返回的只是一个估计值、为什么JDK8之后放弃了Segment分段锁,往往就卡壳了。

这篇内容就是冲着这些卡壳的地方来的。我会把ConcurrentHashMap从设计动机、底层结构、核心方法实现、扩容机制到实际使用中的坑,一层一层拆开讲。适合已经会写Java、用过HashMap、但对并发场景下容器行为心里没底的同学;也适合正在准备面试、想把J.U.C这块知识从“背答案”升级成“真理解”的开发者。读完你至少能搞清楚三件事:ConcurrentHashMap到底怎么保证线程安全、它的性能优势从哪来、以及在实际项目里怎么用才不出问题。

需要先说明一点,这篇内容以JDK8及之后的实现为主线,因为现在主流生产环境基本都是JDK8起步,JDK7那套Segment分段锁的设计我会在对比时提到,但不会花大篇幅去讲已经很少用的老实现。另外文中涉及的一些参数和阈值,我会给出计算过程,方便你自己推导,而不是死记。

2. 从HashMap的线程安全问题说起

2.1 HashMap在多线程下到底会出什么事

很多人知道“HashMap线程不安全”,但具体怎么个不安全法,说不清楚。我把它归纳成三类典型问题,每一类都对应着真实的故障场景。

第一类是数据覆盖。HashMap的put操作在JDK8里是这么个逻辑:算出桶下标,如果桶是空的,直接把新节点放进去。问题就出在“如果桶是空的”这个判断上。两个线程同时判断某个桶为空,然后同时往里放节点,后放的那个就会把先放的覆盖掉,导致一个线程的数据凭空消失。这种问题在并发量不高的时候很难复现,一旦复现就是偶发的数据丢失,排查起来极其痛苦。

第二类是扩容时的死循环。这个问题在JDK7里特别出名,JDK8虽然做了优化,但并发扩容仍然可能导致数据错乱。JDK7的HashMap在扩容时用的是头插法,多线程同时触发扩容,链表指针可能被反转成环,之后任何一次get操作只要落到这个环上,就会无限循环,CPU直接飙到100%。我早年排查过一个线上问题,一个服务跑着跑着CPU打满,jstack一看全卡在HashMap.get上,最后定位就是多线程共享了一个HashMap。

第三类是size计数不准。HashMap的size字段就是个普通int,多线程并发修改时,自增自减不是原子操作,最终结果肯定对不上。

注意:不要觉得“我只是读多写少”就可以用HashMap。只要有一个写操作和其他读写操作并发,就可能出问题。读操作本身在HashMap里也不是完全安全的,因为扩容过程中读到的可能是旧表也可能是新表。

2.2 为什么不用Collections.synchronizedMap

既然HashMap不安全,那用Collections.synchronizedMap包一层行不行?行,但性能很难看。它的做法是给整个Map对象加一把大锁,所有读写操作都要先抢这把锁。这意味着同一时刻只能有一个线程操作Map,并发能力直接退化成单线程。你开100个线程,实际干活的是一个一个排队来,吞吐量上不去。

Hashtable也是同样的毛病,方法上直接加synchronized,锁粒度太粗。在并发场景下,这种“一把大锁保平安”的方案,安全是安全了,但性能代价太大。ConcurrentHashMap要解决的核心矛盾就是:既要线程安全,又要把锁的粒度降到足够细,让多个线程能真正并行地操作。

2.3 ConcurrentHashMap的设计目标

ConcurrentHashMap的设计目标可以概括成三条:高并发读、适度并发写、弱一致性。

高并发读指的是读操作基本不加锁,靠volatile保证可见性,这样读的性能接近HashMap。适度并发写指的是写操作只锁住当前要操作的那个桶,不同桶的写可以并行。弱一致性指的是像size()、isEmpty()、containsValue()这类方法返回的不是精确值,而是某一时刻的估计值,因为要拿到精确值就得锁住整个表,那就违背了设计初衷。

理解这三点很关键,因为很多使用上的困惑都源于对“弱一致性”的不理解。比如你往ConcurrentHashMap里放了100个元素,另一个线程调size()可能返回98,这不是bug,是设计如此。

3. JDK8里ConcurrentHashMap的底层结构拆解

3.1 数组加链表加红黑树的组合结构

JDK8的ConcurrentHashMap底层结构和HashMap基本一致,都是数组+链表+红黑树。数组叫table,每个元素是一个桶,桶里挂链表,链表长度超过阈值就转红黑树。

这里有个关键参数需要搞清楚:树化阈值是8,退化阈值是6。为什么是8?官方注释里给了解释,链表节点按泊松分布,在负载因子0.75的情况下,链表长度达到8的概率大约是千万分之六,几乎不可能。选8是为了在极端情况下才转树,避免频繁的树化和退化操作。退化阈值选6而不是7,是为了留一个缓冲,防止在7附近反复横跳。

数组的默认容量是16,负载因子0.75,这些和HashMap一样。但ConcurrentHashMap多了一个sizeCtl字段,这个字段身兼数职:负数时表示正在初始化或扩容,-1表示正在初始化,-(1+n)表示有n个线程正在协助扩容;正数时表示下一次扩容的阈值。这个设计很巧妙,用一个字段表达了多种状态。

3.2 为什么放弃Segment分段锁

JDK7的ConcurrentHashMap用的是Segment分段锁,把整个表分成若干段,每段独立加锁。这个设计在当时是很先进的,但有几个问题。

一是内存开销大。每个Segment本身就是一个ReentrantLock,还要维护自己的HashEntry数组,段数一多,内存占用就上去了。二是锁粒度还是偏粗。Segment的数量在初始化时就固定了,并发度上限就是段数,没法动态调整。三是实现复杂。定位一个元素要先定位Segment再定位桶,两层hash,代码可读性差。

JDK8改成CAS+synchronized锁单个桶,锁粒度直接降到桶级别。桶是动态的,数组扩容后桶的数量增加,并发度自然提升。而且synchronized在JDK8里做了大量优化,偏向锁、轻量级锁、锁消除这些机制让它的性能不比ReentrantLock差,代码还更简洁。

3.3 Node节点的设计细节

ConcurrentHashMap的节点类叫Node,和HashMap的Node比,关键区别在于val和next都用volatile修饰。volatile保证了可见性,一个线程修改了节点的值或next指针,其他线程能立刻看到。这是读操作能不加锁的基础。

Node的hash字段是final的,一旦计算好就不变。这里有个细节:ConcurrentHashMap的hash计算比HashMap多了一步,会把hash值的高位和低位做异或,目的是让hash分布更均匀,减少碰撞。具体做法是(h ^ (h >>> 16)) & 0x7fffffff,最后和0x7fffffff做与运算是为了把符号位抹掉,保证结果非负。

除了普通Node,还有几个特殊节点:ForwardingNode表示这个桶已经迁移到新表了,TreeBin是红黑树的包装节点,ReservationNode用于computeIfAbsent等方法的占位。这些特殊节点在扩容和并发控制里扮演重要角色,后面会细说。

4. 核心方法的实现原理与实操分析

4.1 put方法的完整流程

put方法是理解ConcurrentHashMap的钥匙,我把它拆成几个阶段来讲。

第一个阶段是计算hash并定位桶。如果table还没初始化,先调用initTable()初始化。initTable里用CAS抢一个sizeCtl为-1的状态,抢到的线程负责初始化,其他线程自旋等待。这个设计保证了初始化只做一次。

第二个阶段是桶为空的情况。如果目标桶是null,直接用CAS把新节点放进去。CAS失败就进入下一轮循环重试。这是最理想的情况,全程无锁。

第三个阶段是桶不为空的情况。这时候用synchronized锁住桶的头节点,然后在链表或红黑树里找位置插入。如果是链表,遍历到尾部插入;如果是红黑树,调用红黑树的插入方法。这里锁的是头节点对象,不同桶的头节点不同,所以不同桶的写操作可以并行。

第四个阶段是判断是否需要树化。链表长度达到8且数组容量达到64时才树化,如果容量不够64,优先扩容而不是树化。这个细节很多人不知道,以为链表到8就一定转树。

第五个阶段是更新size并判断是否需要扩容。size的更新用的是类似LongAdder的分散计数机制,后面单独讲。扩容判断用的是sizeCtl阈值,超过就触发扩容。

// put方法的核心逻辑简化示意 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 (Node<K,V>[] tab = table;;) { Node<K,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 Node<K,V>(hash, key, value, null))) break; } else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); else { V oldVal = null; synchronized (f) { // 链表或红黑树插入逻辑 } 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 key和多个null value。为什么不允许?因为并发场景下,如果get返回null,你没法区分是“这个key不存在”还是“这个key对应的value是null”,这在并发编程里会造成歧义。

4.2 get方法为什么可以不加锁

get方法的逻辑很简单:算hash,定位桶,如果桶头节点匹配就返回,否则遍历链表或红黑树。整个过程没有任何加锁操作,靠的是volatile的可见性保证。

具体来说,table数组的引用是volatile的,Node的val和next也是volatile的。当一个线程修改了某个节点的val,其他线程读这个节点时能立刻看到最新值。如果读的过程中发生了扩容,读线程可能读到ForwardingNode,这时候它会去新表里继续找。这个设计保证了读操作永远不会被阻塞,也不会读到中间状态。

这里有个容易混淆的点:get不加锁不代表get一定能读到最新值。如果put操作还没完成,get可能读到旧值。这是弱一致性的体现,在大多数场景下是可以接受的。如果你需要强一致性,那ConcurrentHashMap不适合你,得用别的同步手段。

4.3 size方法为什么返回的是估计值

size()方法的实现经历了几次演变。JDK7里用Segment的count累加,JDK8改成了类似LongAdder的CounterCell数组机制。

核心思路是:维护一个baseCount,再维护一个CounterCell数组。每次addCount时,先尝试CAS更新baseCount,失败就把计数分散到CounterCell数组的不同槽位上。这样多个线程同时计数时,冲突概率大大降低。size()就是把baseCount和所有CounterCell的值加起来。

问题在于,这个累加过程不是原子的。你遍历CounterCell数组的时候,可能有线程正在修改某个槽位的值,所以最终结果可能偏大也可能偏小。官方文档明确说了,size()返回的是一个估计值,不是精确值。如果你需要精确的size,得自己加锁,但那样就失去了ConcurrentHashMap的意义。

实操心得:我在项目里从来不用ConcurrentHashMap的size()做业务判断。如果需要精确计数,我会单独用一个AtomicLong或者LongAdder来维护。ConcurrentHashMap的size()只适合做监控指标、日志输出这类对精度要求不高的场景。

4.4 扩容机制的并发协作

扩容是ConcurrentHashMap里最复杂的部分。JDK8的扩容支持多线程协助迁移,这是它比JDK7先进的地方。

触发扩容后,第一个线程会创建一个新表,容量是旧表的两倍。然后它把旧表的桶分成若干段,每个线程负责迁移一段。迁移完成的桶会被替换成ForwardingNode,里面保存了新表的引用。其他线程访问到这个桶时,会调用helpTransfer()加入迁移工作。

迁移的最小单位是一个桶,但实际实现里会一次迁移多个桶,具体数量取决于CPU核数和表的大小。迁移过程中,旧表的桶被锁住,保证不会有新的写入。这个设计让扩容不再是单线程的瓶颈,并发度越高,扩容越快。

这里有个参数值得注意:扩容阈值是容量乘以负载因子。默认容量16,负载因子0.75,阈值就是12。当元素数量达到12时触发扩容,新容量32,新阈值24。这个计算很简单,但很多人面试时说不清楚。

5. 实际使用中的坑与排查技巧

5.1 复合操作不是原子的

这是最常见的坑。ConcurrentHashMap的单个方法是线程安全的,但多个方法组合起来就不是了。比如“先检查再插入”这个经典模式:

// 错误示范:复合操作不是原子的 if (!map.containsKey(key)) { map.put(key, value); }

两个线程可能同时通过containsKey检查,然后都执行put,导致后一个覆盖前一个。正确的做法是用putIfAbsent、computeIfAbsent、compute这些原子复合方法。

// 正确做法:使用原子复合方法 map.putIfAbsent(key, value); // 或者 map.computeIfAbsent(key, k -> createValue(k));

computeIfAbsent特别有用,它把“检查-创建-插入”三步合成一个原子操作。但要注意,computeIfAbsent的映射函数里不要做太耗时的操作,因为它会锁住桶,阻塞其他线程访问同一个桶。

5.2 遍历时的弱一致性

ConcurrentHashMap的迭代器是弱一致的,遍历过程中其他线程的修改可能反映出来,也可能不反映。它不会抛ConcurrentModificationException,这是和HashMap的重要区别。

这个特性意味着你不能依赖遍历结果做精确判断。比如你遍历一遍统计元素个数,结果可能和size()对不上。如果你需要一致性快照,得自己复制一份或者加锁。

注意:弱一致性迭代器不会抛异常,但不代表遍历过程中数据不会变。如果你在遍历时做业务逻辑,要考虑到数据可能已经过期。

5.3 常见问题速查表

问题现象可能原因排查思路解决方案
数据莫名丢失用了containsKey+put的复合操作检查代码里是否有先检查后插入的逻辑改用putIfAbsent或computeIfAbsent
size()结果对不上弱一致性设计,返回估计值确认是否用size()做了精确判断单独维护计数器或接受估计值
CPU飙高扩容时大量线程协助迁移jstack看是否有transfer相关栈预估容量,初始化时指定合理大小
内存占用大初始容量太小导致频繁扩容看GC日志里ConcurrentHashMap的扩容记录初始化时指定容量,避免多次扩容
遍历时数据不一致弱一致性迭代器确认是否依赖遍历结果做精确统计加锁遍历或复制快照

5.4 初始化容量的计算技巧

很多人初始化ConcurrentHashMap时随便写个new ConcurrentHashMap<>(),默认容量16。如果实际要存1000个元素,就会经历多次扩容,每次扩容都要迁移数据,性能损耗不小。

正确的做法是预估元素数量,然后反推初始容量。ConcurrentHashMap的构造方法里有个tableSizeFor方法,会把传入的容量向上取到最近的2的幂。但要注意,它还会根据负载因子调整。如果你想存n个元素,初始容量应该设为n / 0.75 + 1,然后再向上取2的幂。

比如要存1000个元素,1000/0.75≈1334,向上取2的幂是2048。所以new ConcurrentHashMap<>(2048)比默认的16要高效得多。这个计算不复杂,但能省下好几次扩容的开销。

6. 性能对比与选型建议

6.1 和Hashtable、synchronizedMap的性能差距

我做过一个简单的压测,100个线程并发读写,读多写少(9:1)的场景下,ConcurrentHashMap的吞吐量大约是Hashtable的8到10倍,是synchronizedMap的6到8倍。写多读少(1:9)的场景下,差距会缩小到3到5倍,因为写操作还是要抢锁,但锁粒度细的优势依然明显。

这个差距的来源就是锁粒度。Hashtable和synchronizedMap是一把大锁,所有操作串行化。ConcurrentHashMap是桶级锁,不同桶的操作可以并行。并发度越高,差距越明显。

6.2 什么时候不该用ConcurrentHashMap

ConcurrentHashMap不是万能的。以下几种情况要慎重:

一是需要强一致性。如果你要求读到的数据一定是最新的,ConcurrentHashMap的弱一致性满足不了,得用加锁的Map或者自己控制同步。

二是需要排序。ConcurrentHashMap不保证顺序,如果需要按key排序,用ConcurrentSkipListMap,它是J.U.C里的有序并发容器。

三是写操作极其频繁且集中在少数key上。如果所有线程都在写同一个桶,那桶级锁就退化成了大锁,性能优势消失。这种场景要考虑是不是数据结构设计有问题。

四是需要原子性的复合操作且computeIfAbsent搞不定。虽然computeIfAbsent能解决大部分问题,但有些复杂逻辑它覆盖不了,这时候可能得用锁。

6.3 和ConcurrentSkipListMap的选型对比

ConcurrentSkipListMap是基于跳表实现的有序并发容器,和ConcurrentHashMap是互补关系。选型主要看两点:要不要排序、对性能的要求。

对比维度ConcurrentHashMapConcurrentSkipListMap
底层结构数组+链表+红黑树跳表
是否有序否是,按key排序
平均时间复杂度O(1)O(log n)
并发度桶级锁,更高跳表节点级,稍低
适用场景高频读写、无需排序需要范围查询、有序遍历

如果你的场景需要按key范围查询,比如“找出所有key在A到B之间的元素”,ConcurrentSkipListMap是唯一选择。如果只是普通的增删改查,ConcurrentHashMap性能更好。

7. 几个容易被忽略的细节

7.1 spread方法的扰动计算

ConcurrentHashMap的spread方法比HashMap的hash方法多了一步:

static final int spread(int h) { return (h ^ (h >>> 16)) & 0x7fffffff; }

h ^ (h >>> 16)是扰动函数,把高16位和低16位异或,让高位也参与运算,减少碰撞。& 0x7fffffff是把符号位抹掉,保证结果非负。因为数组下标是(n - 1) & hash,如果hash是负数,与运算的结果可能还是负数,会导致数组越界。这一步很多讲ConcurrentHashMap的文章会忽略,但它是保证正确性的关键。

7.2 红黑树的转换条件

链表转红黑树的条件是链表长度≥8且数组容量≥64。如果数组容量小于64,即使链表长度到了8,也优先扩容而不是树化。为什么?因为数组小的时候,碰撞概率高,扩容能更有效地分散元素,比树化的收益大。

红黑树退化成链表的条件是节点数≤6。这个6和8之间的差值是为了避免在7附近反复转换。如果阈值都是7,一个元素频繁增删就会导致链表和树之间来回切换,性能反而下降。

7.3 ForwardingNode的作用

ForwardingNode是扩容时的关键节点。它的hash值是MOVED(-1),当读线程遇到ForwardingNode时,就知道这个桶已经迁移了,会去新表里继续查找。当写线程遇到ForwardingNode时,会调用helpTransfer()协助迁移。

这个设计让扩容和读写可以并行进行,不会因为扩容而阻塞整个Map。这是ConcurrentHashMap比Hashtable高明的地方,也是它能在高并发下保持低延迟的原因。

7.4 批量操作的并发控制

ConcurrentHashMap提供了putAll、clear等批量方法,这些方法在并发场景下要小心使用。putAll不是原子的,遍历过程中其他线程可能插入数据。clear也不是原子的,清空过程中可能有新数据进来。

如果需要在并发环境下做批量操作,建议自己加锁或者用compute系列方法逐个处理。不要指望putAll能保证原子性,它只是循环调用put而已。

8. 从源码里学到的设计思想

8.1 用状态字段表达多种含义

sizeCtl这个字段的设计很值得学习。一个int字段,正数表示扩容阈值,-1表示正在初始化,-(1+n)表示有n个线程在协助扩容。这种“一个字段多种用途”的设计在并发编程里很常见,目的是减少字段数量,降低内存占用,同时保证状态转换的原子性。

但这样做的代价是代码可读性下降。你看源码的时候得时刻记住sizeCtl当前可能是什么状态,不然很容易看晕。这是典型的“性能换可读性”的取舍。

8.2 CAS加自旋替代阻塞

ConcurrentHashMap里大量使用了CAS加自旋的模式。比如initTable、addCount、transfer里的很多操作,都是先CAS尝试,失败就自旋重试。这种模式在竞争不激烈的时候性能很好,因为避免了线程上下文切换的开销。

但自旋也有代价,如果竞争激烈,大量线程空转会浪费CPU。所以ConcurrentHashMap在自旋到一定次数后,会退化成阻塞等待。这个“自适应”的策略在J.U.C里很常见,AQS里也有类似的设计。

8.3 分散热点减少竞争

CounterCell数组是分散热点的经典案例。如果所有线程都去CAS同一个baseCount,竞争会非常激烈。把计数分散到多个Cell上,每个线程操作不同的Cell,竞争就分散了。这个思想和LongAdder一模一样,本质上是用空间换并发度。

类似的思路在ConcurrentHashMap里还有很多,比如扩容时把迁移任务分段,让多个线程各负责一段。这种“分而治之”的策略是并发编程里的通用套路。

9. 实际项目中的使用建议

9.1 缓存场景的正确用法

ConcurrentHashMap最常见的用途是做本地缓存。用的时候有几个建议:

一是设置合理的初始容量,避免频繁扩容。二是用computeIfAbsent做缓存加载,保证只有一个线程去加载数据。三是注意缓存过期,ConcurrentHashMap本身不支持过期,得自己维护时间戳或者用定时任务清理。四是控制缓存大小,无限增长的缓存迟早会OOM,可以配合LRU或者定期清理。

// 本地缓存的典型用法 ConcurrentHashMap<String, CacheEntry> cache = new ConcurrentHashMap<>(1024); public Object get(String key) { return cache.computeIfAbsent(key, k -> loadFromDb(k)).value; }

9.2 计数器场景的替代方案

如果只是做计数器,ConcurrentHashMap不是最优选择。LongAdder的性能比ConcurrentHashMap的merge方法好得多,因为LongAdder专门为计数优化,没有Map的哈希和桶定位开销。只有在需要按key分组计数时,才用ConcurrentHashMap。

9.3 并发度预估与容量规划

初始化ConcurrentHashMap时,容量规划要考虑两个因素:预期元素数量和并发写入的key分布。如果key分布均匀,桶级锁的并发度就高;如果key集中在少数几个,并发度就低。可以通过调整hash策略或者加盐来分散key。

容量方面,前面说过,初始容量设为预期元素数 / 0.75 + 1再向上取2的幂。如果预期元素数会持续增长,可以适当放大,减少扩容次数。但也不要设太大,浪费内存。

9.4 监控与问题定位

线上环境如果怀疑ConcurrentHashMap有问题,可以从几个方面入手:jstack看线程栈,如果有大量线程卡在transfer或helpTransfer,说明扩容压力大;jmap看堆内存,如果ConcurrentHashMap占用异常,可能是容量设得太大或者有内存泄漏;GC日志看扩容频率,频繁扩容说明初始容量太小。

我个人的经验是,ConcurrentHashMap本身很少出问题,出问题的大多是使用方式不对。比如复合操作没加原子保护、size()当精确值用、缓存没设上限。把这几类问题规避掉,ConcurrentHashMap用起来还是很省心的。

最后分享一个我踩过的坑:有一次做本地缓存,用computeIfAbsent加载数据,加载函数里又调用了同一个Map的computeIfAbsent,结果死锁了。原因是computeIfAbsent会锁住桶,递归调用同一个桶就死锁了。这个坑很隐蔽,因为单线程测试不会复现,只有并发场景才会暴露。后来我把加载逻辑改成先查再加载,用双重检查加锁的方式解决。所以用computeIfAbsent的时候,映射函数里千万不要再操作同一个Map。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询