ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

ConcurrentHashMap深度解析:从线程安全到高并发设计

ConcurrentHashMap深度解析:从线程安全到高并发设计 1. 为什么ConcurrentHashMap是J.U.C里最值得啃的容器聊到Java多线程绕不开J.U.C这个包而J.U.C里面真正在生产环境里天天被用的并发容器ConcurrentHashMap说第二没人敢说第一。很多同学背八股文的时候能把“分段锁”“CASsynchronized”“红黑树”这些词说得滚瓜烂熟但真到了项目里让他解释为什么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表示正在初始化-(1n)表示有n个线程正在协助扩容正数时表示下一次扩容的阈值。这个设计很巧妙用一个字段表达了多种状态。3.2 为什么放弃Segment分段锁JDK7的ConcurrentHashMap用的是Segment分段锁把整个表分成若干段每段独立加锁。这个设计在当时是很先进的但有几个问题。一是内存开销大。每个Segment本身就是一个ReentrantLock还要维护自己的HashEntry数组段数一多内存占用就上去了。二是锁粒度还是偏粗。Segment的数量在初始化时就固定了并发度上限就是段数没法动态调整。三是实现复杂。定位一个元素要先定位Segment再定位桶两层hash代码可读性差。JDK8改成CASsynchronized锁单个桶锁粒度直接降到桶级别。桶是动态的数组扩容后桶的数量增加并发度自然提升。而且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 (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 (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 常见问题速查表问题现象可能原因排查思路解决方案数据莫名丢失用了containsKeyput的复合操作检查代码里是否有先检查后插入的逻辑改用putIfAbsent或computeIfAbsentsize()结果对不上弱一致性设计返回估计值确认是否用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 什么时候不该用ConcurrentHashMapConcurrentHashMap不是万能的。以下几种情况要慎重一是需要强一致性。如果你要求读到的数据一定是最新的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表示正在初始化-(1n)表示有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或者定期清理。// 本地缓存的典型用法 ConcurrentHashMapString, 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。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表