CAS + 锁桶头,JDK8 把并发粒度细化到桶
HashMap非线程安全、多线程会数据错乱。那并发场景下该用什么?老一辈的答案是Hashtable或Collections.synchronizedMap,但它们都用一把大锁,高并发下吞吐惨不忍睹。ConcurrentHashMap(CHM)才是现代 Java 的标配。它经历了 JDK 7 的分段锁、JDK 8 的"数组+链表/树 + CAS + synchronized 锁单个桶头"两次大重构。这一篇把它的演进和 JDK 8 实现讲透。
一、前辈们的局限:一把大锁
Hashtable在方法上加synchronized:
publicsynchronizedVput(Kkey,Vvalue){...}publicsynchronizedVget(Objectkey){...}任何读写都抢同一把对象锁,多线程下全是串行,CPU 多核优势完全浪费。Collections.synchronizedMap同理,只是把锁包在方法外。它们"安全"但"慢",在并发度高的缓存、计数场景直接成为瓶颈。
二、JDK 7:分段锁(Segment)
JDK 7 的 CHM 把数据分成 16 个Segment,每个 Segment 是一把独立的 ReentrantLock,内部各是一个小 HashMap:
ConcurrentHashMap ├── Segment[0] (独立锁) → 小 HashMap ├── Segment[1] (独立锁) → 小 HashMap ├── ...(默认 16 段) └── Segment[15]不同 Segment 的写操作互不阻塞,理论上并发度 16。这比一把大锁进步很大,但仍有问题:① Segment 数固定,扩不了并发度;② 哈希冲突集中在一个 Segment 时,该段仍是瓶颈;③ 内存开销大(每个 Segment 一套结构)。JDK 8 干脆推倒重来。
三、JDK 8:更细的粒度,锁单个桶头
JDK 8 的 CHM 放弃了 Segment,结构和 HashMap 几乎一致(数组桶 + 链表/红黑树),并发控制靠两把武器:
- CAS(无锁乐观尝试):用于简单状态变更,比如桶还是 null 时直接 CAS 放节点。
- synchronized 锁单个桶的头节点:只在真正发生冲突、要改链表/树时才锁"这一个桶",其他桶完全不受影响。
粒度从"16 个段"细化到"每个桶",并发度 ≈ 桶的数量(远多于 16)。
四、put 流程:CAS 试探 + 锁头节点
简化版putVal逻辑:
finalVputVal(Kkey,Vvalue,booleanonlyIfAbsent){if(key==null||value==null)thrownewNullPointerException();// CHM 不允许 nullinthash=spread(key.hashCode());for(Node<K,V>[]tab=table;;){Node<K,V>f;intn,i,fh;if(tab==null)initTable();// 懒初始化elseif((f=tabAt(tab,i=(n-1)&hash))==null){if(casTabAt(tab,i,null,newNode<>(hash,key,value)))break;// ① 桶为空:CAS 无锁放入}elseif((fh=f.hash)==MOVED)tab=helpTransfer(tab,f);// 正在扩容:协助搬迁else{synchronized(f){// ② 桶非空:锁住这个桶的头节点if(tabAt(tab,i)==f){if(fh>=0){/* 遍历链表,相同 key 覆盖,否则尾插 */...}elseif(finstanceofTreeBin)/* 走红黑树插入 */}}}if(++size>threshold)resize();// 计数用分段计数器,低竞争}returnnull;}三个关键点:
- 桶空用 CAS:最常见的"首次放入"完全无锁,性能拉满。
- 桶非空才 synchronized(f):只锁当前这个头节点,其他桶并行无碍。锁的粒度极小。
- 不允许 null:key/value 为 null 直接抛 NPE——避免"ConcurrentHashMap 里 null 到底表示’没这个 key’还是’value 就是 null’"的歧义(HashMap 单线程能区分,并发下 get 返回 null 无法判断,故干脆禁止)。
五、size 计数:分段的 CounterCell
HashMap用一个size字段,CHM 多线程下直接++会丢。JDK 8 用一个baseCount+CounterCell[]的分段计数:
privatetransientvolatilelongbaseCount;privatetransientvolatileCounterCell[]counterCells;线程先 CAS 改baseCount,竞争失败就落到某个CounterCell上各自累加,最后size()把baseCount和所有CounterCell求和。相当于把"一个热点计数器"拆成多个,避免所有线程抢同一变量。所以 CHM 的size()是弱一致的(返回某一刻的近似快照),不是实时精确值——高并发下追求精确 size 本身就没意义,要用mappingCount()当估算。
六、扩容:多线程协助搬迁(helpTransfer)
HashMap 扩容是单线程搬家,CHM 在扩容时允许其他线程一起搬:
- 某线程 put 时发现
f.hash == MOVED(标识正在扩容的ForwardingNode),就调用helpTransfer进去帮忙搬一部分桶,搬完再继续自己的 put。 - 用
transferIndex把桶区间分给不同线程,避免重复搬。 - 配合"高低位拆分"(同 HashMap),把每个旧桶拆成 低桶/高桶 两条链表,O(1) 重分配。
这让 CHM 在扩容时仍能保持高吞吐,不会因为"一个人搬家、所有人等着"而卡死。
七、为什么读几乎无锁?
get全程几乎不加锁:
publicVget(Objectkey){Node<K,V>[]tab;Node<K,V>e,p;intn,eh;Kek;inth=spread(key.hashCode());if((tab=table)!=null&&(n=tab.length)>0&&(e=tabAt(tab,(n-1)&h))!=null){if((eh=e.hash)==h&&((ek=e.key)==key||(ek!=null&&key.equals(ek))))returne.val;elseif(eh<0)returne.find(h,key);// 树或 ForwardingNode 各自处理while((e=e.next)!=null)// 遍历链表if(e.hash==h&&((ek=e.key)==key||...))returne.val;}returnnull;}table和节点 value 用volatile修饰(tabAt用Unsafe.getObjectVolatile读),保证可见性。读操作不阻塞写、写操作的锁也很短,所以读多写少场景 CHM 性能极好。这也是它取代 synchronizedMap 当缓存容器的根本原因。
八、和 Hashtable / synchronizedMap 的对比
| 维度 | Hashtable | synchronizedMap | ConcurrentHashMap |
|---|---|---|---|
| 锁粒度 | 整表一把锁 | 整表一把锁 | 桶头节点 / CAS |
| 读是否阻塞 | 是(方法锁) | 是 | 几乎无锁 |
| 并发度 | 1 | 1 | ≈ 桶数 |
| 允许 null | 否 | 是 | 否 |
| 迭代器 | 快速失败 | 快速失败 | 弱一致(不抛 CME) |
| 适用 | 淘汰 | 简单同步 | 高并发首选 |
CHM 的迭代器是弱一致的:遍历时其他线程改了,迭代器可能看到也可能看不到最新值,但绝不抛ConcurrentModificationException,可以边遍历边修改。
九、常见误用
map.putIfAbsent(k, v)不是"检查+创建"的原子组合?它本身就是原子的,常用于单例缓存:map.putIfAbsent(key, compute(key))——注意compute(key)仍然会先执行!要"不存在才计算"请用computeIfAbsent(JDK 8),它只在 key 缺失时才调用函数,避免无谓计算。get返回 null 不能区分"无 key"和"value 为 null":CHM 直接禁止 null value,别踩。- size() 不精确:高并发别依赖
size()==0做业务判断,用isEmpty()也弱一致,需要严格语义请加外部锁或用别的结构。
十、面试追问
- Q:JDK 7 和 JDK 8 的 CHM 最大区别?7 用 Segment 分段锁(16 段、粒度粗、不可扩并发度);8 去掉 Segment,用 CAS + synchronized 锁单个桶头,粒度更细、并发度更高、内存更省。
- Q:为什么 JDK 8 用 synchronized 而不是 ReentrantLock?锁的是单个桶头节点、持有时间极短,synchronized 在 JVM 里经过锁升级(偏向→轻量→重量)优化后,这种"极短临界区"性能已不输 ReentrantLock,还省了 AQS 的对象开销。而且锁的粒度已经足够小,没必要上更复杂的锁。
- Q:CHM 能完全替代 HashMap 吗?单线程优先 HashMap(更轻、允许 null);只有并发才上 CHM。别为了"显得高级"在无并发处用 CHM,反而多了 volatile/CAS 开销。
总结
ConcurrentHashMap 用"CAS 无锁试探 + synchronized 锁单个桶头"把并发粒度细化到桶,读几乎无锁、写互不阻塞;分段 CounterCell 做弱一致计数,多线程协助扩容避免搬家卡顿;JDK 8 相比 JDK 7 的 Segment 分段锁,粒度更细、并发更高、内存更省。它不允许 null、迭代弱一致,是并发键值存储的现代标配,彻底淘汰了 Hashtable 和 synchronizedMap。