结论先行:JDK8 为什么放弃分段锁?
JDK8 的 ConcurrentHashMap(以下简称 CHM)放弃了 JDK7 的 Segment 分段锁,改用CAS + synchronized + 红黑树的组合方案。核心变化是:锁粒度从「段」细化到「桶」,即每个哈希桶(bin)的头节点作为锁对象,配合 CAS 无锁操作,在保证线程安全的同时大幅提升并发度。
为什么放弃分段锁?主要有三个原因:
- 锁粒度更细:JDK7 中一个 Segment 锁保护多个桶,并发写同一 Segment 内的不同桶仍会互斥;JDK8 锁粒度到单个桶,不同桶的写操作可以完全并行。
- 查询效率更高:JDK7 的 get 需要两次哈希定位(先定位 Segment,再定位桶);JDK8 只需一次哈希定位,且读操作完全无锁。
- 内存占用更小:JDK7 的 Segment 数组本身占用额外内存;JDK8 直接使用 Node 数组,结构更紧凑。
一句话总结:JDK8 用「CAS 负责无竞争场景,synchronized 负责有竞争场景」,把锁的粒度压到最小,同时利用红黑树把链表过长时的查询复杂度从 O(n) 降到 O(log n)。
核心结构:Node、ForwardingNode、TreeBin、TreeNode
JDK8 CHM 的核心数据结构包括以下几个关键类:
- Node:哈希桶中链表的基本节点,包含 key、value、hash 和 next 指针。value 和 next 都用 volatile 修饰,保证可见性。
- ForwardingNode:扩容时用于标记已迁移完成的桶。它的 hash 值为 -1(MOVED),当线程访问到该节点时,会协助完成扩容。
- TreeBin:红黑树的容器节点,hash 值为 -2(TREEBIN)。它持有红黑树的根节点,并负责读写锁的协调。
- TreeNode:红黑树节点,继承自 Node,增加了 parent、left、right、red 等字段。
此外,还有两个核心字段:
- sizeCtl:控制表初始化和扩容的控制字段,在不同状态下含义不同(详见下文)。
- table:Node 数组,即哈希桶数组,用 volatile 修饰。
JDK7 vs JDK8:从 Segment 分段锁到桶级锁
JDK7 的 CHM 采用 Segment 分段锁设计:
- 内部维护一个 Segment 数组,每个 Segment 继承自 ReentrantLock,是一个独立的哈希表。
- 写入时先定位 Segment,再对 Segment 加锁,然后定位桶进行插入。
- 并发度受限于 Segment 数量(默认 16),不同 Segment 之间可以并行,但同一 Segment 内的写操作互斥。
JDK8 的 CHM 彻底重构:
- 不再有 Segment,直接使用 Node 数组 + 桶级锁。
- 写入时先通过 CAS 尝试无锁插入;CAS 失败(桶非空)才使用 synchronized 锁住头节点。
- 读操作完全无锁,利用 volatile 保证可见性。
对比表格如下:
| 维度 | JDK7 | JDK8 |
|---|---|---|
| 锁粒度 | Segment(段) | 桶(bin) |
| 锁实现 | ReentrantLock | CAS + synchronized |
| 读操作 | 无锁(但需两次哈希) | 无锁(一次哈希) |
| 查询复杂度 | 链表 O(n) | 链表 O(n) / 红黑树 O(log n) |
| 内存占用 | Segment 数组额外开销 | 更紧凑 |
JDK8 CHM 结构图
JDK8 CHM 的整体结构可以用下图表示:
flowchart TD CHM[ConcurrentHashMap] --> table[Node 数组 table] table --> b0[桶0: Node 链表] table --> b1[桶1: 空] table --> b2[桶2: TreeBin 红黑树] table --> b3[桶3: ForwardingNode 扩容中] b0 --> n1[Node] n1 --> n2[Node] b2 --> rb[TreeNode 红黑树] CHM --> sizeCtl[sizeCtl 控制字段] CHM --> baseCount[baseCount 计数]初始化 initTable:CAS 修改 sizeCtl
CHM 采用懒加载策略,构造时并不初始化 table 数组,而是在第一次 put 时通过 initTable() 完成初始化。核心代码如下:
private final Node<K,V>[] initTable() { Node<K,V>[] tab; int sc; while ((tab = table) == null || tab.length == 0) { if ((sc = sizeCtl) < 0) Thread.yield(); // 其他线程正在初始化,让出 CPU else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { // CAS 将 sizeCtl 从正数改为 -1,表示当前线程独占初始化 try { if ((tab = table) == null || tab.length == 0) { int n = (sc > 0) ? sc : DEFAULT_CAPACITY; @SuppressWarnings("unchecked") Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n]; table = tab = nt; sc = n - (n >>> 2); // 扩容阈值 = 0.75 * n } } finally { sizeCtl = sc; } break; } } return tab; }关键点:
- 多个线程同时进入 initTable() 时,只有一个线程能通过 CAS 把 sizeCtl 从正数改为 -1,获得初始化权。
- 其他线程看到 sizeCtl < 0,直接 Thread.yield() 让出 CPU,避免自旋空转。
- 初始化完成后,sizeCtl 被设置为扩容阈值(0.75 倍容量)。
懒加载与 get:无锁读为什么可以不锁
get 操作完全无锁,核心代码如下:
public V get(Object key) { Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek; int h = spread(key.hashCode()); if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) { if ((eh = e.hash) == h) { if ((ek = e.key) == key || (ek != null && key.equals(ek))) return e.val; } else if (eh < 0) // 遇到 ForwardingNode 或 TreeBin return (p = e.find(h, key)) != null ? p.val : null; while ((e = e.next) != null) { if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) return e.val; } } return null; }为什么 get 可以不锁?核心原因有三个:
- volatile 保证可见性:table 数组引用、Node 的 val 和 next 都用 volatile 修饰,读线程能立即看到其他线程的写入结果。
- tabAt 使用 Unsafe.getObjectVolatile:读取桶头节点时使用 volatile 读,保证读到的是最新值。
- 链表节点一旦插入就不会修改 next 指向:put 操作只会新增节点,不会修改已有节点的 next 引用(除了树化时整体替换),因此读线程遍历链表时不会读到不一致的中间状态。
需要注意的是:get 读到的是「某一时刻的近似值」,并不保证强一致。如果读线程遍历链表的同时,写线程正在树化该桶,读线程可能读到旧链表,但不会读到损坏数据。
put 流程:从 CAS 到 synchronized
putVal() 是 CHM 写入的核心方法,完整流程如下:
final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException(); int hash = spread(key.hashCode()); int binCount = 0; for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; K fk; V fv; if (tab == null || (n = tab.length) == 0) tab = initTable(); // 1. 表为空,先初始化 else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { // 2. 桶为空,CAS 直接放入新节点 if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value))) break; } else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); // 3. 桶正在扩容,协助迁移 else { V oldVal = null; synchronized (f) { // 4. 锁住头节点 if (tabAt(tab, i) == f) { // 双重检查,防止头节点被替换 if (fh >= 0) { // 链表插入 binCount = 1; for (Node<K,V> e = f;; ++binCount) { K ek; if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { oldVal = e.val; if (!onlyIfAbsent) e.val = value; break; } Node<K,V> pred = e; if ((e = e.next) == null) { pred.next = new Node<K,V>(hash, key, value); break; } } } else if (f instanceof TreeBin) { // 红黑树插入 Node<K,V> p; binCount = 2; if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) { oldVal = p.val; if (!onlyIfAbsent) p.val = value; } } else if (f instanceof ReservationNode) throw new IllegalStateException("Recursive update"); } } if (binCount != 0) { // 5. 链表长度达到阈值,树化 if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); if (oldVal != null) return oldVal; break; } } } addCount(1L, binCount); // 6. 更新元素计数 return null; }put 流程可以归纳为四步:
- 表为空:调用 initTable() 初始化。
- 桶为空:通过 CAS 直接放入新节点,无锁完成。
- hash == MOVED:桶正在扩容,调用 helpTransfer() 协助迁移。
- 桶非空:synchronized 锁住头节点,在链表或红黑树中插入。
put 流程图
flowchart TD A[putVal 开始] --> B{table 为空?} B -- 是 --> C[initTable 初始化] C --> D{桶为空?} B -- 否 --> D D -- 是 --> E[CAS 放入新节点] E --> F[addCount 更新计数] D -- 否 --> G{hash == MOVED?} G -- 是 --> H[helpTransfer 协助扩容] H --> F G -- 否 --> I[synchronized 锁头节点] I --> J{链表 or 红黑树?} J -- 链表 --> K[遍历链表插入] J -- 红黑树 --> L[putTreeVal 插入] K --> M{binCount >= 8?} L --> F M -- 是 --> N[treeifyBin 树化] N --> F M -- 否 --> F树化:binCount >= TREEIFY_THRESHOLD
当链表长度达到阈值(默认 8)时,CHM 会调用 treeifyBin() 将链表转换为红黑树。但树化还有一个前置条件:table 长度必须大于等于 MIN_TREEIFY_CAPACITY(默认 64)。如果 table 长度不足 64,会优先触发扩容而不是树化。
为什么是 8?这是基于泊松分布的统计结果:在负载因子 0.75 的前提下,链表长度达到 8 的概率极低(约千万分之一),此时说明哈希分布严重不均,用红黑树替代链表可以保证查询效率。
树化过程:
- 将链表节点逐个转换为 TreeNode。
- 通过 TreeBin 容器持有红黑树根节点。
- 桶头节点替换为 TreeBin 对象(hash 值为 -2)。
addCount 与 sizeCtl 含义
put 完成后调用 addCount() 更新元素计数。CHM 的计数采用baseCount + CounterCell 数组的方式:无竞争时直接 CAS 更新 baseCount;竞争激烈时分散到 CounterCell 数组中,减少 CAS 冲突。
sizeCtl 是 CHM 最核心的控制字段,不同取值含义如下:
| sizeCtl 值 | 含义 |
|---|---|
| 0 | 默认值,表示尚未初始化 |
| -1 | 正在初始化(某个线程独占) |
| 负数(-(1+n)) | 正在扩容,n 表示参与扩容的线程数 |
| 正数 | 下次扩容的阈值(容量 × 0.75) |
sizeCtl 状态图
flowchart LR S0[0: 未初始化] -->|CAS 置为 -1| S1[-1: 初始化中] S1 -->|初始化完成| S2[正数: 扩容阈值] S2 -->|触发扩容, 置为 -(1+n)| S3[负数: 扩容中] S3 -->|扩容完成| S2面试题与总结
以下是本章的核心面试题,建议重点掌握:
1. JDK8 CHM 怎么保证线程安全?
JDK8 CHM 通过三种机制协同保证线程安全:
- CAS:用于无竞争场景,如桶为空时的节点插入、sizeCtl 的修改、计数的更新。
- synchronized:用于有竞争场景,锁住桶头节点,保护链表或红黑树的插入操作。
- volatile:保证