尧图精选

ConcurrentHashMap并发原理与实战:从CAS到锁演进及避坑指南

🕒 发布时间:2026/10/1 23:00:19 📁 来源:尧图网络
先用一个日常场景把话题引出来吧搞Java并发编程的人几乎都绕不开这个类。面试官喜欢问线上排查高并发问题也经常跟它打交道。1. 为什么并发场景下你不该再碰HashMap和Hashtable很多人在项目里写并发代码一遇到需要共享Map就纠结HashMap线程不安全Hashtable线程安全但性能又不行到底用什么答案当然是ConcurrentHashMap但如果你不知道它到底解决了什么问题很可能用着用着就踩坑。先说HashMap在并发下的问题。JDK7时代HashMap的头插法在扩容时会出现循环链表一旦发生并发put轻则数据丢失重则CPU直接飙到100%死循环卡死。JDK8改成了尾插法循环链表问题解决了但put时的数据覆盖问题依然存在两个线程同时往同一个桶里写后写的覆盖先写的业务数据就丢了。再看Hashtable和Collections.synchronizedMap。它们所谓的线程安全就是给get、put、remove整段方法加了一把全局锁。同一时刻只有一个线程能操作并发一高其他线程全部阻塞在锁上吞吐量直接断崖式下跌。单线程下这两者可能有不错的短延迟但并发上20个线程打同一个实例你就知道什么叫排队等锁了。ConcurrentHashMap的思路是既要在并发下保证数据安全又要尽量让多个线程同时操作不同的桶。它不搞全局大锁而是一方面用CAS无锁操作处理最简单的场景另一方面把锁的粒度细化到单个桶节点。这样一来不同线程操作不同桶时互不干扰极端竞争时也只是在同一个桶上小范围阻塞。同样是线程安全的MapHashtable和ConcurrentHashMap在并发读多写多场景下的差距可以差出一个数量级。搞懂它内部的锁策略和多线程协作机制是真正掌握该类使用姿势的前提。2. JDK7分段锁到JDK8 CAS加synchronized版本演进的三个关键点ConcurrentHashMap的核心实现从JDK7到JDK8做了一次大手术理解这次演进才算真正看懂这类。2.1 JDK7的Segment分段锁把一把大锁拆成多把小锁JDK7的ConcurrentHashMap内部是一个Segment数组每一个Segment继承自ReentrantLock自己管着一小撮桶HashEntry数组。put操作时先通过散列值定位到某个Segment再只锁住这个Segment其他Segment的读写不受影响。默认有16个Segment理论上并行度就是16——不同线程只要落在不同Segment上就能同时写。相比Hashtable一把锁锁全表这已经是很大进步但问题也出在这Segment一旦初始化数量固定不能动态扩容并发量上去了并行度卡死在16。定位元素要先散列到Segment再散列到Segment内的桶两次哈希效率打折。即使Segment内某个桶忙得不行同一个Segment下的其他桶也得跟着等锁。所以JDK7版本的ConcurrentHashMap在并发很高时仍然会出现锁竞争属于有优化但不够彻底。2.2 JDK8数据结构Node数组加链表和红黑树JDK8的ConcurrentHashMap直接把Segment砍了回归到和HashMap类似的Node数组结构。每个桶位要么是null要么是链表头节点要么是红黑树根节点。链表长度超过阈值8时转换成红黑树为的是把最坏情况下的查询复杂度从O(n)压到O(log n)。锁的粒度也细化到了桶级别不再是锁一整段。两个线程就算同时操作同一个桶下的不同节点只要不是在同一个链表头节点上做结构性修改也能并发执行。竞争范围大幅缩小。这里有个容易误解的点链表转红黑树的阈值8不是绝对的。它内部还要求数组长度达到64否则就算链表超过8个节点也优先走扩容而不是树化。原因是链表短时顺序遍历很快没必要为了树化去承担红黑树的旋转维护开销只有当数组足够大、哈希分布已经比较均匀时长链表才值得树化。2.3 CAS与synchronized的配合逻辑JDK8的写路径策略是能无锁就无锁锁不住再锁。具体来说是三档处理桶位为空用CAS直接写入新节点不产生锁竞争这是最理想的快路径。桶位有节点且非扩容迁移中在这个桶的头节点上加synchronized锁锁住后再做链表的遍历、插入或替换。桶位处于扩容迁移中当前线程不直接操作而是先参与协助迁移后文详述迁移完再重试写入。synchronized锁在JDK6之后的优化偏向锁、轻量级锁、自旋、锁粗化、锁消除让它比裸的ReentrantLock更适合这种读多写多的场景。JVM能在线程内消除无意义的锁竞争让锁的开销在无竞争时几乎为零。这个CAS优先synchronized兜底的设计本质上是根据并发冲突概率做自适应日常大多数put操作落在空桶上CAS一枪命中只有遇到哈希冲突时才升级为synchronized锁住小范围节点。这也是JDK8版本能跑到很高吞吐量的底气所在。下表把两个实现的关键差异理一下对比项JDK7 SegmentJDK8 Node数组CASsynchronized锁粒度一段Segment内所有桶单个桶头节点最大并行度固定默认16随数组扩容而提升定位流程两次哈希Segment桶一次哈希定位桶空桶插入需要锁CAS无锁扩容机制仅Segment内扩容整表多线程协助复杂结构链表链表红黑树3. put与get的完整链路源码级拆解这些方法到底做了什么这部分我直接按JDK8的源码逻辑走一遍重点流程读懂了它你写并发代码时心里才有一杆秤。3.1 put操作遇到的四种情况put(K key, V value)的完整路径大致是计算spread哈希为了避免低效散列内部对key的hashCode做了高位扰动处理让高16位也参与桶定位减少碰撞。进入for循环自旋循环内判断当前桶位状态不同状态走不同分支。桶位为空执行casTabAt把新节点通过CAS写入该桶成功后直接break。桶位头节点的hash为MOVED即-1说明数组正在扩容迁移当前线程不闲着主动调用helpTransfer加入迁移队伍。桶位有正常节点synchronized锁住头节点进入链表或红黑树做插入或更新。如果key已存在根据onlyIfAbsent参数决定是否替换语义上等价于putIfAbsent。完成插入后为计数器addCount如果节点数超过扩容阈值触发transfer扩容。这里有个容易被忽略的细节锁住头节点之后其实还需要重新检查一遍头节点有没有变。因为从加锁前到加锁成功之间可能有其他线程改了桶。所以代码里在拿到锁后会再次校验binCount对应的头节点引用是否相同不同就释放锁重来这是典型的加锁后二次确认。3.2 get操作如何做到无锁又安全get操作全程不加锁它靠的是volatile读和final修饰的next指针。Node的val字段和next字段都标了volatile数组本身也通过volatile读getObjectVolatile来获取最新引用。读取流程定位桶位取出头节点。如果头节点hash小于0说明该桶可能是红黑树节点TreeBin或扩容转发节点ForwardingNode分别走对应查询逻辑。hash0说明是普通链表直接遍历找key比对哈希值和key引用或equals。全程无锁多线程同时get完全自由。那么问题来了一个线程正在put更新某个Node的val另一个线程正在get这个Nodeget会不会读到脏数据答案是不会因为val是volatile的get能看到put写入的最新可见值。这里的可见性由volatile的happens-before规则保证不会出现读到半个写入的状态。3.3 size()不再精确统计为什么要容忍近似值JDK8的size()返回的是int但JDK8之后还提供了mappingCount()返回long。为什么不用精确值因为在高并发下要实时精确地统计元素个数就得不断加锁或阻塞写代价太大不值得。ConcurrentHashMap维护了一个baseCount作为主计数器并发写时先用CAS递增baseCountCAS竞争激烈时改为把增量累加到CounterCell数组中每个线程自己的槽位上最后统计时遍历baseCount和CounterCell数组求和。这个思路和LongAdder完全一致把单点计数压力打散到多个槽位。实际拿到的size()在并发写期间是个弱一致性的近似值可能比真实值小一点或大一点。业务上如果必须精确要么在无写操作时再调size()要么自行维护计数并配合读写锁。很多人在监控系统里用size()当指标短期抖动是正常的不用大惊小怪。4. ConcurrentHashMap的常见坑与正确使用姿势这个部分是我在实际项目里摔过跟头之后总结的每一个坑都对应过线上事故或诡异现象。4.1 computeIfAbsent的锁粒度陷阱不要在映射函数里再操作同一个MapJDK8的computeIfAbsent接口很常用key不存在时执行映射函数存在时直接返回旧值。但它的锁粒度控制有坑——映射函数执行时它持有了当前桶的synchronized锁且不允许该桶上其他写操作介入。也就是说如果你在映射函数内部又调用了这个map的其他写方法比如put或remove或computeIfAbsent另一个key而且这些操作恰好落在同一个桶上就会抛出IllegalStateException: Recursive update异常。更糟的是如果映射函数内部调用了同一个map的size()而size()内部也要遍历CounterCell甚至触发扩容相关操作可能出现死锁或严重的性能倒挂。我见过一个事故某服务用ConcurrentHashMap做本地缓存computeIfAbsent里查了一次数据库查到结果后又去更新另一个key结果那个key恰好和当前key在同一个桶线上直接抛Recursive update缓存全线失效。排查了一整晚。正确做法是在computeIfAbsent之前先去get一遍命中就直接返回没命中再单独调computeIfAbsent映射函数里只做纯计算或外部调用不要回头碰这个Map。4.2 遍历时的弱一致性迭代器不会抛ConcurrentModificationException这一点和ArrayList、HashMap的快速失败机制完全相反。ConcurrentHashMap的迭代器是弱一致性的遍历开始后新增的元素不一定能看到。正在遍历时其他线程删除的元素可能仍然被遍历到。不会抛ConcurrentModificationException。这个特性在业务上有利有弊。好处是遍历不会因为并发修改而中断适合做快照性质的全量扫描。坏处是如果你遍历的同时依赖所有元素都被正确处理这个假设比如批量清理过期数据那就要小心数据漏处理或重复处理。稳妥做法是遍历前先复制一份快照集合或者干脆用entrySet().forEach时在外部另行加业务层面的同步。4.3 空值问题不接受null key和null value是有意为之ConcurrentHashMap从设计上就不允许null key和null value。很多初学者以为是实现疏忽其实这是慎重的决定。JDK8源码注释里写得很明白如果允许null value那么在get(k)返回null时你无法区分是key不存在还是key存在但值为null对于并发场景这种二义性会引发大麻烦。而Hashtable不允许null value也是出于同样考虑。HashMap允许null value则是因为它本身不保证线程安全不需要在并发下维持这种语义清晰性。实战中的影响如果你用ConcurrentHashMap做缓存存入key时如果value是null直接抛出NPE线上如果没捕获会打爆错误日志。所以存之前要判空或者换用Optional包装。4.4 初始化容量别取太大也别太小容量计算背后的数学ConcurrentHashMap在构造时接收的initialCapacity只是初始期望值内部会把它调整成大于等于期望值且满足2的幂的最小值。具体计算公式是1.5倍的initialCapacity再加1再向上取2的幂。为什么是这个公式因为数组要留出扩容余地元素个数到达容量乘以负载因子0.75时触发扩容。如果用initialCapacity直接作为容量元素还没放满就要扩浪费性能如果直接按2倍怼容量内存又浪费。假设你预估要放100个元素建议构造时new ConcurrentHashMap(100)。内部会算成初始容量128扩容阈值约为128*0.7596。也就是说放进约96个元素时才会触发扩容容量变成256阈值变成192。如果预估不准放得多了扩容也只在后台渐进式进行不会像HashMap那样一次性卡顿。一个常见误区是每创建一个ConcurrentHashMap都默认构造不传容量。如果业务明确要放几万条配置默认容量16就会很快触发多次扩容白白增加迁移开销。这个细节在写缓存工具类时很值得注意。5. 从源码看并发安全边界哪些操作靠锁哪些操作靠CAS深入源码之后你会发现ConcurrentHashMap的线程安全不是靠某一个统一机制而是多种并发原语各管一摊的组合拳。对各种操作做个分类能帮你判断什么时候放心用什么时候需要额外加保护。5.1 数据结构上的所有写操作都有明确的并发保护新节点插入空桶CAS保证原子性失败就重试。链表/红黑树的结构性修改synchronized锁住头节点或树根节点。节点值的覆盖更新在锁内完成或者通过CAS更新Node的val。计数器更新baseCount的CAS竞争激烈时用CounterCell分散。扩容迁移每个桶的迁移都有ForwardingNode标记其他线程看到后协助而非重复迁移。一个很重要的性质是单个操作一定是原子的。put、remove、replace、compute、merge这些都是原子操作多线程同时做也不会产生脏写。但复合操作不是原子的——比如先get判断存在再put写入这两步之间可能有另一个线程插入或删除。这种检查后行动的场景必须使用compute、merge这类内置的原子合并方法或者自己在外部加锁。5.2 HashMap的size()为什么不能当作准确计数器size()是遍历baseCount和CounterCell求和的结果它在计算过程中可能有其他线程正在写入所以是近似值。对日常监控来说够用但如果你的业务依赖缓存条数达到阈值N就触发清理这类逻辑用size()做准绳就容易出偏差。我记得有一次做本地缓存容量控制想的是条数超过1000就清理过期项结果因为size()的近似语义偶发出现条数已经超过1200还没触发。后来改成维护独立的AtomicInteger计数器在put和remove成功时手动增减才做到精确控制。5.3 为什么说它是读多写少场景的王者但写多读多也扛得住并发集合的选择标准很简单读多写少配置表、路由表、热点数据缓存ConcurrentHashMap基本无锁读性能远优于CopyOnWriteArrayList和加锁的HashMap。写多读多高频KV存储也有不错的表现因为CAS和细粒度锁打散了竞争点。超大单个value几MB级别的对象图那就别塞进Map里了序列化和内存拷贝的代价远超集合本身的并发开销。市面上很多本地缓存框架Caffeine、Guava Cache的底层存储其实也都借鉴了ConcurrentHashMap的分段思想。理解了这个类的并发模型你再去读Caffeine源码会轻松很多。6. 扩容机制背后的多线程协作怎么做到扩容不阻塞全部写线程扩容是ConcurrentHashMap最精妙也最难懂的部分。很多Java工程师背了八股文多线程协助扩容但真到面试官问细节就卡壳。这里我把关键逻辑捋一遍。6.1 触发条件与两阶段迁移当元素个数超过阈值容量*0.75时addCount方法会检测到需要扩容然后由触发线程发起transfer。扩容全程分成两个阶段阶段一构建新的Node数组长度为旧数组的两倍nextTable指向它。这一步是当前线程独立做的开销很小。阶段二把旧数组每个桶的节点迁移到新数组。这里就引入了多线程协作——每个线程领取一个步长范围内的桶位区间迁移完一个区间再领下一区间。每个桶迁移之前先在该桶原位放一个ForwardingNode其hash固定为-1MOVED。其他线程无论是put还是get看到头节点是ForwardingNode时就会转而去新数组继续操作或者主动参与迁移自己遇到的桶。这样旧数组上对外呈现的是正在搬走的状态阻塞范围被压到最小。6.2 为什么说读操作在扩容期间也能正常进行get操作在扩容期间遇到ForwardingNode时会调用其内部find方法直接到新数组对应位置去查询。因为迁移是按桶逐个进行的对于还没迁移的桶老数组上数据仍是完整的直接读对于已迁移的桶老数组上的ForwardingNode会引导到新数组读。整个过程不需要加锁底层依赖volatile读保证数组引用的可见性。所以扩容对读几乎没有影响对写最多只是短暂阻塞在单个桶上。这也是ConcurrentHashMap能在大流量下支撑高频读写的重要原因。6.3 扩容中的红黑树处理链表迁移时会把一条链表拆成两条低位链hash oldCap 0留在原索引位置高位链hash oldCap 1放进原索引oldCap的新位置。这个拆链操作和HashMap扩容的逻辑一致。红黑树迁移更讲究迁移时先把树节点拆成低位链和高位链如果拆分后的链长度不超过6就退化回链表否则继续按红黑树结构挂到新数组。树化阈值8、退化阈值6这两个数字之间有缓冲区就是防止元素在阈值附近反复横跳导致频繁树化和反树化。6.4 我们的业务曾遇到扩容期CPU抖动的复盘有一次线上服务在流量高峰出现CPU周期性抖动排查下来发现是本地缓存ConcurrentHashMap扩容导致大量线程同时参与迁移。并发线程数默认用CPU核心数控制照理说不该有这么大开销但问题出在那个Map存的是大对象图每个桶迁移时要重算哈希和重建链表节点GC压力也跟着上来。后来调整了初始容量让Map在低峰期就完成扩容同时给缓存换成了Caffeine内部有更精细的容量淘汰机制CPU抖动就消失了。这个经验让我养成了一个习惯凡是能用容量预估的ConcurrentHashMap绝不省那个initialCapacity参数。7. 高并发场景下的实战代码模式如何把并发安全落到实处光懂原理不够结合具体的业务场景得会搭配合适的使用模式。这里列几个我个人在项目里反复用到的写法。7.1 用compute做读取-修改-写入复合操作如果业务逻辑是key不存在则初始化存在则累加更新直接写getput会有竞态窗口正确做法是用compute。map.compute(key, (k, v) - { if (v null) { return initValue(k); } return v delta; });compute方法保证判断-计算-更新整段逻辑都在同一个桶锁内执行中间的读改写是原子的。类似的还有mergemap.merge(key, 1, Integer::sum);上面一行就能实现不存在则赋初始值存在则加一的原子操作用在统计访问次数、频次累加这类场景非常顺手。注意merge的映射函数里也不要再操作同一个Map规则和computeIfAbsent一样。7.2 读多写少的本地缓存双重检查加volatile如果你的缓存场景是启动时加载、运行期只读、偶尔手动刷新一种常见的模式是维护一个volatile修饰的ConcurrentHashMap引用刷新时整表替换public class LocalConfigCache { private volatile ConcurrentHashMapString, ConfigItem cache new ConcurrentHashMap(); public ConfigItem get(String key) { return cache.get(key); } public void reload(MapString, ConfigItem newData) { ConcurrentHashMapString, ConfigItem newMap new ConcurrentHashMap(newData); cache newMap; // 发布新引用读写线程看到的是同一份快照 } }volatile引用保证替换操作的可见性旧Map在没有任何线程引用后自然被GC回收。这种CopyOnWrite式整表替换避免了逐条put时的数据不一致适合配置量不大、更新频率低的场景。7.3 只在必要时使用mappingCount而不是sizesize()返回int可能溢出超过21亿时变负数mappingCount返回long更保险。虽然两者都是近似值但mappingCount在天文数字级别的缓存统计中更不容易出溢出问题。如果只是想判断是否为空直接调isEmpty()它比size()0的语义更清晰部分实现上还能走更短路径。7.4 不要用ConcurrentHashMap做跨服务的分布式锁经常看到有人在分布式环境里用ConcurrentHashMap的putIfAbsent实现锁这只能在同一进程内生效。多实例部署时每个JVM都有自己的Map所谓锁形同虚设。跨进程场景请务必上分布式锁组件或数据库唯一约束。这类误用引发的线上问题通常还很难排查因为单测跑不出问题一上多节点就诡异频出。8. 从源码级参数到日常使用几个常被忽略但很实用的细节最后补一点细节这些往往决定了你在真实项目里能不能把它用好。8.1 spread哈希扰动为什么ConcurrentHashMap要再散列一次定位桶位前它会把key.hashCode()的高低位做异或扰动具体是(h ^ (h 16)) HASH_BITS。高位参与低位计算后即使大量key的哈希值在低位极其相似也能避免它们撞进同一批桶。同时保留符号位为0确保哈希值非负方便内部用负数标记特殊节点MOVED-1、TREEBIN-2、RESERVED-3。这也是为什么自定义key时强烈建议重写hashCode方法——如果key的hashCode实现糟糕比如对象地址不同但equals相同那么ConcurrentHashMap的散列和查找都会失效甚至出现能放进map却get不到的诡异现象。8.2 树化过程中还会用到ReservationNode节点在锁住桶头节点期间为了标记某个桶正在执行compute等操作ConcurrentHashMap内部会用ReservationNode占位其hash为-3RESERVED并临时放在桶位上避免其他线程对这个桶做并发写的结构修改。get操作遇到RESERVED节点时会老老实实等待自旋或让步直到compute完成后才能读取。这个细节解释了为什么有时在线程栈里能看到Thread.yield或LockSupport.parkNanos调用。8.3 防止扩容风暴负载因子真的不能改构造时填的负载因子影响扩容阈值。默认0.75是时间和空间的一个均衡点太大会让桶内链表变长查询性能劣化太小又频繁扩容迁移开销大。除非你非常明确地知道数据分布特性否则不要乱调负载因子。寄希望于调小负载因子减少冲突通常得不偿失。8.4 性能对比ConcurrentHashMap vs 加锁的HashMap用JMH实测过几个版本的读写吞吐8个线程并发混读写结果大致如下实现读:写8:1读:写1:1读:写1:8ConcurrentHashMap最高高中高synchronized包装的HashMap中低低Hashtable低低低读多写少时ConcurrentHashMap优势明显就算写比例很高它也比全局锁方案好一截。唯一例外场景是单线程环境此时HashMap最快加锁版本反而有锁开销但单线程本来也不需要用并发容器。9. 根据我的经验用这个类必须记住的几件事文章写到这核心内容已经铺完。最后按我的习惯把实战中总结出来最值得记住的几点列在这里算是给读者的一份备忘。单个操作放心用复合操作不要裸写。判断加改动请优先考虑compute、merge、computeIfAbsent这些原子方法。映射函数和函数式更新里不要再碰同一个Map的任何写操作避免Recursive update异常也避免莫名其妙的死锁。null key和null value统统不允许存入前做好判空或者用Optional做包装。容量估算别偷懒new ConcurrentHashMap(预计条数)比无参构造好得多。遍历时不会抛ConcurrentModificationException但不代表遍历结果是实时快照业务要心里有数。分布式场景下的锁别指望它它只保证进程内并发安全。调试时多留意线程栈里的helpTransfer、tryPresize、CounterCell这些是Map正在扩容或高并发写的信号。个人体会是ConcurrentHashMap是Java并发包里工程实践智慧的集大成者——CAS、锁细化、多线程协作、弱一致性计数这些机制拆开看每一个都不算特别复杂但能组合得这么恰到好处确实值得深入读源码。读懂了它你不仅会用它还能在遇到其他并发容器时快速迁移这种分析套路。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →