为什么 ConcurrentHashMap 1.8 重新用回了笨重的 synchronized 锁?

张开发
2026/6/16 2:19:52 15 分钟阅读
为什么 ConcurrentHashMap 1.8 重新用回了笨重的 synchronized 锁?
在多线程环境下普通的HashMap就像一颗定时炸弹。在 JDK 1.7 及以前多线程同时触发扩容Resize时极易导致链表死循环直接把服务器 CPU 打到 100%。为了排雷Java 演进出了三代解决方案。 一、上古时代Hashtable 的暴力美学最简单的办法就是给整个 Map 上一把大锁。Hashtable的源码极其粗暴在所有的put、get方法上都加了synchronized关键字。后果100 个线程来操作99 个在排队睡觉。吞吐量惨不忍睹。这就是典型的**“为了安全牺牲一切”**。 二、中古时代JDK 1.7 的分段锁 (Segment)既然一把大锁太慢那就把它切碎JDK 1.7 创造性地引入了Segment(分段锁)。它把一个大的大数组切分成了 16 个小的Segment。每个Segment拥有一把独立的ReentrantLock。战果如果线程 A 操作第 1 段线程 B 操作第 2 段两把锁互不干扰并发度瞬间提升了 16 倍为什么最终被抛弃因为程序员的追求是无止境的。内存浪费严重Segment本身就带有一堆与锁相关的属性极其臃肿。并发上限被锁死并发度最高就是Segment的个数默认 16。如果有 100 个并发依然会有大量线程在同一个Segment门外排队。 三、文艺复兴JDK 1.8 的终极形态到了 JDK 1.8Doug Lea 做出了一个震惊整个 Java 界的决定干掉 Segment 分段锁回归和普通 HashMap 一模一样的底层结构数组 链表 红黑树。既然没有了分段锁它是如何保证线程安全的1. 极致的锁粒度细化到每一个“桶”在 1.8 中锁的粒度不再是 1/16而是数组里的每一个 Node 节点或者说每一个桶 / Bucket如果数组长度是 1024那就相当于有 1024 把锁并发度直接拉满。2. 核心源码推演put()方法的极限拉扯当一个线程调用put(key, value)时底层会经历一场极其严密的“安检”第一步计算 Hash 找位置。如果发现这个位置的数组元素还是null空桶。神级操作此时绝对不加锁而是直接使用CAS 操作casTabAt把新节点塞进去。因为是空位置直接无锁竞争谁手快谁赢。第二步遇到哈希冲突坑位有人了。如果发现坑位里已经有一个 Node 了可能是个链表头也可能是红黑树根节点。神级操作它直接在这个头节点上套一把synchronized锁// 截取自 JDK 1.8 ConcurrentHashMap 源码synchronized(f){// f 就是当前这个桶的头节点if(tabAt(tab,i)f){// 再次校验防止在加锁的间隙头节点被别的线程干掉了// ... 开始将新节点追加到链表尾部或者插入红黑树}}灵魂拷问为什么用回了曾经最慢的synchronized而不用ReentrantLock因为从 JDK 1.6 开始JVM 团队对synchronized进行了史诗级加强引入了偏向锁、轻量级锁、重量级锁的锁升级机制。在桶级别加锁时发生激烈冲突的概率其实非常小。大多数情况下synchronized只会处于轻量级锁状态根本不会向操作系统申请重量级锁。加上synchronized减少了内存开销它成了 1.8 版本的完美选择。 四、深水区核弹多线程协同扩容 (Transfer)如果面试官问你“ConcurrentHashMap 最复杂的地方是哪里”如果你回答“加锁逻辑”那你只在第一层。真正的大气层是它的扩容机制 (transfer)。普通的 HashMap 扩容是一个线程苦哈哈地把老数组的数据挨个搬到新数组。如果数组长达百万这个线程能搬到吐血在此期间所有其他线程全得卡住。ConcurrentHashMap 1.8 的魔法全员大冲鸟协助扩容当线程 A 发现数组装满了准备扩容它会建一个两倍大的新数组。此时线程 B 进来想执行put算好 Hash 准备插入却发现那个桶的头节点变成了一个神奇的节点ForwardingNode(正在搬迁状态)。线程 B 说“既然你在搬家那我不塞数据了我来帮你搬”于是系统自动把老数组按照步长切分。线程 A 搬区段 1线程 B 帮忙搬区段 2如果有线程 C 进来也得被迫去帮忙搬区段 3。这就是无产阶级的大联合所有碰巧在这个时间点想要操作 Map 的线程都被抓了壮丁大家一起多线程并发搬砖搬运速度呈指数级提升。 五、不可思议的size()计数器再看一个小细节如果并发度极高每秒有上万个线程在执行putMap 的大小在疯狂增加。如果只用一个int count变量来记录大小上万个线程同时去 CAS 更新这个值会导致无数次 CAS 失败极度浪费 CPU。Doug Lea 的解法是大名鼎鼎的LongAdder思想。Map 内部不仅有一个基础计数器baseCount还有一个计数组件CounterCell[]。竞争不激烈时大家一起加baseCount。一旦竞争激烈CAS 失败线程就会被分散到CounterCell数组的不同格子里去加数字。当你调用size()时它只需把baseCount和所有CounterCell里的值加起来就是最终的大小化单点为分散这就是顶级架构师对极限并发的降维打击。 总结Java 并发数据结构的巅峰之作看完源码你会对ConcurrentHashMap产生深深的敬畏空桶无锁有桶加锁CAS Synchronized极限压榨锁粒度。多线程协同搬砖把扩容的性能损耗均摊给了所有线程。分散计数解决高并发下单一计数器的热点问题。理解了它你再去理解分布式系统架构中的分片、限流、热点打散其实都是在运用同一套底层哲学。

更多文章