⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 小哈学Java 「犬小哈」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

这道题真正在考什么

不是在考你背 JDK 1.7 和 1.8 的实现差异——这种纯记忆题没有区分度。面试官真正想看的是:

  1. 你能不能讲清楚分段锁”哪里不好”——不是”不好”两个字就完事,要说出具体的性能瓶颈和设计缺陷。
  2. 你理不理解”为什么选 CAS + synchronized 而不是别的方案”——背后是对锁粒度、硬件特性、JVM 优化的综合判断。
  3. 你有没有批判性思维——比如 synchronized 到底比 ReentrantLock 差在哪?在这个场景下真的差吗?
  4. 你能不能把并发理论落地到具体数据结构——锁粒度、内存布局、CAS 这些概念,映射到 ConcurrentHashMap 里是什么样的。

一句话讲透

分段锁的并发度在创建时就被钉死了(默认 16),而 JDK 1.8 把锁粒度打散到每个桶,并发度随数组一起扩容——锁竞争的概率从”16 分之一”降到了”数组长度分之一”。

分段锁的三宗罪

先回顾一下 JDK 1.7 的架构:ConcurrentHashMap 内部是一个 Segment 数组,每个 Segment 继承 ReentrantLock,管着自己的一小块 HashEntry 数组。本质上就是把一个大 HashMap 拆成 16 个小 HashMap,每个配一把锁。

这个设计在 2006 年(Java 6 时代)算得上精妙,但到了 2014 年的 JDK 1.8,它的三个硬伤已经无法忍受:

罪状一:并发天花板写死了

并发度 = Segment 数量,默认 16,创建后不可更改。也就是说,不管你机器有多少核,最多只有 16 个线程能同时写。在 64 核甚至 128 核的时代,16 这个数字已经是明显的瓶颈。你可以初始化时调大,但一旦定了就没法再变——这和”动态扩容”的理念完全背道而驰。

罪状二:两次哈希定位,白白多走一步路

每次 put 或 get,要先对 key 做一次哈希找到 Segment,再做一次哈希找到桶。两次寻址意味着两次 cache miss 的可能。在高吞吐场景下,这种性能损耗是可以被测量到的。

罪状三:Segment 本身就是内存黑洞

每个 Segment 继承了 ReentrantLock,自带 AQS 同步队列、state 字段等一堆对象头和属性。16 个 Segment 在数据量很小时照样吃内存。对于那些只放几十个 key-value 的场景,这笔开销纯属浪费。

JDK 1.8 的三板斧:CAS + synchronized + 红黑树

Doug Lea 在 JDK 1.8 里把整个 ConcurrentHashMap 推倒重来,核心思路就一句话:能不加锁就不加锁,必须加锁就只锁一个桶。

第一斧:CAS 抢先手——桶为空时直接无锁插入

当目标桶还没有任何节点时,用 CAS 直接把新 Node 塞进去。CAS 是 CPU 硬件级别的原子操作,成功了就完事,失败了再走加锁逻辑。大多数 put 操作在桶为空时就搞定了,全程零锁开销。

// 插入新节点的典型路径
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value))) {
break; // CAS 成功,全程无锁
}
// CAS 失败意味着有竞争,进入 synchronized
synchronized (f) { // f 是桶的头节点
// 在同步块内进行链表或红黑树的插入/更新
}

第二斧:synchronized 兜底——只锁一个桶的头节点

有人可能会问:为什么不继续用 ReentrantLock?原因很现实:

  • synchronized 从 JDK 1.6 起做了偏向锁、轻量级锁、自旋、锁消除、锁粗化等一整套优化,在短临界区、低竞争的场景下性能极佳。
  • ReentrantLock 的优势是可中断、可超时、公平锁、条件变量——但在”锁一个桶的头节点做几次指针操作”这种微操场景下,这些高级功能压根用不上。
  • synchronized 是 JVM 原生支持的关键字,省掉了 lock()/unlock() 的 API 调用开销和忘记释放锁的风险。

说白了,synchronized 在这里不是”退而求其次”,而是”正好最合适”。

第三斧:链表过长自动转红黑树

桶内链表长度达到 8 时自动转红黑树,查询时间复杂度从 O(n) 降到 O(log n)。这不是并发层面的改进,但直接拉高了极端哈希冲突场景下的性能下限——JDK 1.7 的分段锁方案没有这个能力。

一张表看清新旧方案差距

维度 JDK 1.7(分段锁) JDK 1.8(CAS + synchronized)
数据结构 Segment 数组 + HashEntry 数组 Node 数组 + 链表/红黑树
锁粒度 一个 Segment(管一组桶) 一个桶的头节点
并发度 创建时固定(默认 16) 随数组扩容动态增长
锁实现 ReentrantLock CAS(无锁优先)+ synchronized(兜底)
内存开销 大(16 个 Segment 对象 + AQS 队列) 小(数据结构和 HashMap 基本一致)
哈希冲突 纯链表 链表 → 红黑树(长度 ≥ 8 时转换)
寻址次数 两次(先找 Segment,再找桶) 一次(直接定位桶)

总结成一句话:JDK 1.8 的 ConcurrentHashMap 用更少的内存、更细的锁、更聪明的无锁策略,换来了更高的并发吞吐——分段锁被淘汰不是因为它不好,而是硬件和 JVM 都进化了,它跟不上了。

最后澄清一个常见误解:synchronized 并不比 ReentrantLock 慢。 在 ConcurrentHashMap 这种临界区极短(就几次指针操作)的场景下,synchronized 经过 JDK 1.6 以来的持续优化,性能反而更佳。ReentrantLock 的真正优势在于可中断、可超时、公平锁等高级能力——但这些能力在”锁一个桶的头节点”这种场景下根本用不到。

文章目录
  1. 1. 这道题真正在考什么
  2. 2. 一句话讲透
  3. 3. 分段锁的三宗罪
  4. 4. JDK 1.8 的三板斧:CAS + synchronized + 红黑树
  5. 5. 一张表看清新旧方案差距