先用一个日常场景把话题引出来吧:搞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无锁操作处理最简单的场景,另一方面把锁的粒度细化到单个桶节点。这样一来,不同线程操作不同桶时互不干扰,极端竞争时也只是在同一个桶上小范围阻塞。
同样是线程安全的Map,Hashtable和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 Segment | JDK8 Node数组+CAS+synchronized |
|---|---|---|
| 锁粒度 | 一段(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),分别走对应查询逻辑。
- hash>=0,说明是普通链表,直接遍历找key,比对哈希值和key引用或equals。
- 全程无锁,多线程同时get完全自由。
那么问题来了:一个线程正在put更新某个Node的val,另一个线程正在get这个Node,get会不会读到脏数据?答案是不会,因为val是volatile的,get能看到put写入的最新可见值。这里的可见性由volatile的happens-before规则保证,不会出现读到半个写入的状态。
3.3 size()不再精确统计:为什么要容忍"近似值"
JDK8的size()返回的是int,但JDK8之后还提供了mappingCount()返回long。为什么不用精确值?因为在高并发下要实时精确地统计元素个数,就得不断加锁或阻塞写,代价太大,不值得。
ConcurrentHashMap维护了一个baseCount作为主计数器,并发写时先用CAS递增baseCount,CAS竞争激烈时改为把增量累加到CounterCell数组中每个线程自己的槽位上,最后统计时遍历baseCount和CounterCell数组求和。这个思路和LongAdder完全一致:把单点计数压力打散到多个槽位。
实际拿到的size()在并发写期间是个弱一致性的近似值,可能比真实值小一点或大一点。业务上如果必须精确,要么在无写操作时再调size(),要么自行维护计数并配合读写锁。很多人在监控系统里用size()当指标,短期抖动是正常的,不用大惊小怪。
4. ConcurrentHashMap的常见坑与正确使用姿势
这个部分是我在实际项目里摔过跟头之后总结的,每一个坑都对应过线上事故或诡异现象。
4.1 computeIfAbsent的锁粒度陷阱:不要在映射函数里再操作同一个Map
JDK8的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.75=96。也就是说,放进约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固定为-1(MOVED)。其他线程无论是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不存在则初始化,存在则累加更新",直接写get+put会有竞态窗口,正确做法是用compute。
map.compute(key, (k, v) -> { if (v == null) { return initValue(k); } return v + delta; });compute方法保证"判断-计算-更新"整段逻辑都在同一个桶锁内执行,中间的读改写是原子的。类似的还有merge:
map.merge(key, 1, Integer::sum);上面一行就能实现"不存在则赋初始值,存在则加一"的原子操作,用在统计访问次数、频次累加这类场景非常顺手。注意merge的映射函数里也不要再操作同一个Map,规则和computeIfAbsent一样。
7.2 读多写少的本地缓存:双重检查加volatile
如果你的缓存场景是"启动时加载、运行期只读、偶尔手动刷新",一种常见的模式是维护一个volatile修饰的ConcurrentHashMap引用,刷新时整表替换:
public class LocalConfigCache { private volatile ConcurrentHashMap<String, ConfigItem> cache = new ConcurrentHashMap<>(); public ConfigItem get(String key) { return cache.get(key); } public void reload(Map<String, ConfigItem> newData) { ConcurrentHashMap<String, ConfigItem> newMap = new ConcurrentHashMap<>(newData); cache = newMap; // 发布新引用,读写线程看到的是同一份快照 } }volatile引用保证替换操作的可见性,旧Map在没有任何线程引用后自然被GC回收。这种"CopyOnWrite式整表替换"避免了逐条put时的数据不一致,适合配置量不大、更新频率低的场景。
7.3 只在必要时使用mappingCount而不是size
size()返回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为-3(RESERVED),并临时放在桶位上,避免其他线程对这个桶做并发写的结构修改。get操作遇到RESERVED节点时,会老老实实等待(自旋或让步),直到compute完成后才能读取。这个细节解释了为什么有时在线程栈里能看到Thread.yield或LockSupport.parkNanos调用。
8.3 防止扩容风暴:负载因子真的不能改
构造时填的负载因子影响扩容阈值。默认0.75是时间和空间的一个均衡点:太大会让桶内链表变长,查询性能劣化;太小又频繁扩容,迁移开销大。除非你非常明确地知道数据分布特性,否则不要乱调负载因子。寄希望于"调小负载因子减少冲突"通常得不偿失。
8.4 性能对比:ConcurrentHashMap vs 加锁的HashMap
用JMH实测过几个版本的读写吞吐(8个线程并发混读写),结果大致如下:
| 实现 | 读:写=8:1 | 读:写=1:1 | 读:写=1:8 |
|---|---|---|---|
| ConcurrentHashMap | 最高 | 高 | 中高 |
| 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、锁细化、多线程协作、弱一致性计数这些机制,拆开看每一个都不算特别复杂,但能组合得这么恰到好处,确实值得深入读源码。读懂了它,你不仅会用它,还能在遇到其他并发容器时快速迁移这种分析套路。