ConcurrentHashMap 深度解析:从分段锁到 CAS 的进化之路
2026/8/2 3:39:39 网站建设 项目流程

一、引言:HashMap 的线程安全困境

HashMap 是 Java 中最常用的容器之一,但它有一个致命缺陷——线程不安全。在多线程环境下,HashMap 在扩容时可能形成环形链表,导致get()操作陷入死循环,CPU 飙升到 100%。

那用Hashtable呢?它所有方法都加了synchronized,相当于给整张表上了一把大锁,同一时刻只允许一个线程操作,并发性能极差。

于是,ConcurrentHashMap应运而生。它既保证了线程安全,又追求极高的并发性能,是 Java 并发容器中最闪耀的明星。

核心定位ConcurrentHashMap是一个线程安全的哈希表,设计目标是在最小化更新操作对哈希表占用的同时,保持与HashMap相当的空间消耗,并支持多线程高效并发访问。

从 Java 7 到 Java 8,ConcurrentHashMap经历了一次彻底的重写,代码量从 1000 多行暴涨到 6000 多行。下面我们从 JDK 8 的视角,深入源码一探究竟。

二、JDK 7 vs JDK 8:架构的全面进化

2.1 JDK 7:分段锁(Segment Lock)

Java 7 中的ConcurrentHashMap采用了分段锁技术:将数据分成多个Segment,每个Segment独立加锁。

  • Segment继承自ReentrantLock,每个 Segment 是一把独立的锁

  • 默认 16 个 Segment,理论上支持 16 个线程并发写入

  • 锁的粒度是整个 Segment,一个 Segment 内的所有操作互斥

2.2 JDK 8:CAS + synchronized

Java 8 彻底摒弃了Segment的设计,采用了与HashMap相同的数据结构——数组 + 链表 + 红黑树,并使用CAS + synchronized保证线程安全。

对比维度

JDK 7

JDK 8

数据结构

Segment数组 + HashEntry数组 + 链表

Node数组 + 链表 + 红黑树

锁机制

ReentrantLock(分段锁)

CAS + synchronized(锁头节点)

锁粒度

整个 Segment

单个桶(链表/树的首节点)

并发度

固定(默认16)

动态(数组长度)

扩容参与

单线程扩容

多线程协助扩容

JDK 8 的核心优势:锁粒度从 Segment 缩小到单个桶节点,只要 hash 不冲突,不同桶的操作可以完全并行。

三、核心数据结构与成员变量

3.1 Node——基本存储节点

NodeConcurrentHashMap中最基础的存储单元:

static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; volatile V val; // volatile 保证可见性 volatile Node<K,V> next; // volatile 保证可见性 Node(int hash, K key, V val, Node<K,V> next) { this.hash = hash; this.key = key; this.val = val; this.next = next; } }

关键设计valnext都使用了volatile修饰,确保一个线程对节点的修改对其他线程立即可见。

3.2 TreeNode——红黑树节点

当链表长度超过阈值时,链表会转换为红黑树。TreeNode继承自Node,增加了红黑树所需的指针:

static final class TreeNode<K,V> extends Node<K,V> { TreeNode<K,V> parent; // 父节点 TreeNode<K,V> left; // 左子节点 TreeNode<K,V> right; // 右子节点 TreeNode<K,V> prev; // 前驱节点(用于维持双向链表) boolean red; // 红/黑颜色标记 }

3.3 TreeBin——红黑树代理

注意:桶位中存储的不是TreeNode对象,而是TreeBin对象。TreeBin是红黑树的代理容器,内部维护着红黑树的根节点root和双向链表的头节点first

3.4 ForwardingNode——扩容标记节点

当某个桶的数据迁移完成后,该桶位会被设置为ForwardingNode(简称 FWD 节点),其hash值为MOVED(-1)。后续其他线程看到这个标记,就知道该桶正在扩容或已迁移完成。

3.5 核心成员变量

// 存储数据的数组,volatile 保证可见性 transient volatile Node<K,V>[] table; // 扩容时使用的新数组(仅在扩容期间非空) private transient volatile Node<K,V>[] nextTable; // 核心控制字段:控制初始化和扩容 private transient volatile int sizeCtl; // 默认初始容量 16 private static final int DEFAULT_CAPACITY = 16; // 最大容量 2^30 private static final int MAXIMUM_CAPACITY = 1 << 30; // 负载因子 0.75(固定,不可修改) private static final float LOAD_FACTOR = 0.75f; // 链表转红黑树阈值:8 static final int TREEIFY_THRESHOLD = 8; // 红黑树转链表阈值:6 static final int UNTREEIFY_THRESHOLD = 6; // 转红黑树的最小数组容量:64(小于此值优先扩容) static final int MIN_TREEIFY_CAPACITY = 64;

3.6 sizeCtl——最核心的控制字段

sizeCtlConcurrentHashMap出镜率最高的字段,它的值在不同阶段代表不同含义:

sizeCtl 值

含义

0

默认值,尚未初始化

-1

正在初始化 table

< -1

正在扩容,低 16 位表示参与扩容的线程数(如 -N 表示有 N-1 个线程参与)

> 0

初始化完成后的扩容阈值(容量 × 0.75)

四、构造方法:延迟初始化

ConcurrentHashMap采用了延迟初始化策略——构造方法只计算容量,并不真正创建 table 数组。

// 无参构造:什么也不做 public ConcurrentHashMap() { } // 指定初始容量的构造方法 public ConcurrentHashMap(int initialCapacity) { if (initialCapacity < 0) throw new IllegalArgumentException(); // 计算大于 1.5 * initialCapacity + 1 的最小 2 的幂 int cap = tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1); this.sizeCtl = cap; // 只设置 sizeCtl,不创建 table }

延迟初始化的好处:只有在第一次put时才真正创建数组,避免了不必要的内存占用。

tableSizeFor方法确保容量始终是2 的幂,这是为了后续使用位运算((n - 1) & hash)替代取模运算,提升性能。

注意loadFactor虽然在构造方法中作为参数传入,但计算完size后并未被保存为成员变量,后续扩容阈值计算固定使用 0.75。

五、put 方法:线程安全的核心

put方法是ConcurrentHashMap最复杂的部分,涉及初始化、CAS 插入、锁扩容、链表/树操作等多个环节。

5.1 入口与 hash 计算

public V put(K key, V value) { return putVal(key, value, false); } final V putVal(K key, V value, boolean onlyIfAbsent) { // key 和 value 都不能为 null if (key == null || value == null) throw new NullPointerException(); // spread:让高位参与寻址,使 hash 更分散 int hash = spread(key.hashCode()); int binCount = 0; // 记录桶中元素个数,用于判断是否树化 // 自旋(无限循环),直到操作成功 for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; // ... 四种情况处理 } }

spread方法将key.hashCode()的高位与低位混合,减少 hash 冲突:

static final int spread(int h) { return (h ^ (h >>> 16)) & HASH_BITS; }

5.2 四种情况处理

putVal的核心是一个自旋循环,处理四种不同的情况:

Case 1:table 未初始化
if (tab == null || (n = tab.length) == 0) tab = initTable(); // 初始化 table

initTable()通过 CAS 控制sizeCtl,确保只有一个线程执行初始化:

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(); else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) { // CAS 将 sizeCtl 设为 -1,当前线程获得初始化权 try { if ((tab = table) == null || tab.length == 0) { int n = (sc > 0) ? sc : DEFAULT_CAPACITY; 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; }
Case 2:桶位为空(无 hash 冲突)
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { // 使用 CAS 将新节点放入空桶位 if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; // CAS 成功,跳出循环 }

这里使用CAS 无锁操作,不需要加锁,是最高效的插入场景。

Case 3:桶位正在扩容(FWD 节点)
else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); // 当前线程协助扩容

如果桶位的头节点是ForwardingNodehash == MOVED),说明该桶正在扩容迁移,当前线程会协助扩容

Case 4:正常插入(链表或红黑树)
else { V oldVal = null; synchronized (f) { // 锁住头节点 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, null); 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; } } } } // 判断是否需要树化 if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) // >= 8 treeifyBin(tab, i); if (oldVal != null) return oldVal; break; } }

关键设计:使用synchronized锁住桶的头节点f),而不是锁整张表。这意味着只有操作同一个桶的线程才会竞争锁,不同桶的操作完全并行。

5.3 addCount:元素计数与扩容触发

插入完成后,调用addCount增加元素数量,并检查是否需要扩容:

addCount(1L, binCount);

addCount内部会判断当前元素数量是否超过sizeCtl(扩容阈值),如果超过则触发transfer扩容。

六、get 方法:无锁读取的秘密

get方法是ConcurrentHashMap性能的又一体现——全程不加锁

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) { // 情况1:首节点就是目标节点 if ((ek = e.key) == key || (ek != null && key.equals(ek))) return e.val; } else if (eh < 0) { // 情况2:hash < 0,可能是 TreeBin 或 ForwardingNode // - 如果是 ForwardingNode(扩容中),去 nextTable 中查找 // - 如果是 TreeBin,遍历红黑树 return (p = e.find(h, key)) != null ? p.val : null; } // 情况3:遍历链表 while ((e = e.next) != null) { if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) return e.val; } } return null; }

为什么 get 不需要加锁?

  1. volatile保证可见性Nodevalnext都是volatile的,写入的结果对所有线程立即可见

  2. tablevolatile:数组引用本身保证可见性

  3. Nodehashkeyfinal:一旦创建不可变

  4. 扩容时的特殊处理:通过ForwardingNode.find()到新表查找

这种设计使得get操作几乎不受锁竞争影响,性能极高。

七、扩容机制:多线程协同作战

扩容是ConcurrentHashMap最复杂的部分,也是它区别于普通HashMap的核心优势——支持多线程协同扩容

7.1 扩容触发条件

扩容在addCount方法中被触发:

if (check >= 0) { Node<K,V>[] tab, nt; int n, sc; while (s >= (long)(sc = sizeCtl) && (tab = table) != null && (n = tab.length) < MAXIMUM_CAPACITY) { int rs = resizeStamp(n); if (sc < 0) { // 已有线程在扩容 if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0) break; if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1)) transfer(tab, nt); // 当前线程协助扩容 } else if (U.compareAndSetInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)) transfer(tab, null); // 当前线程是第一个发起扩容的 s = sumCount(); } }

7.2 transfer:数据迁移

transfer方法负责将旧 table 的数据迁移到新 table(容量翻倍):

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) { int n = tab.length, stride; // 计算每个线程负责的桶位数(步长) if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE) stride = MIN_TRANSFER_STRIDE; // 最小 16 if (nextTab == null) { // 第一个发起扩容的线程创建新数组 try { Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1]; // 容量翻倍 nextTab = nt; } catch (Throwable ex) { sizeCtl = Integer.MAX_VALUE; return; } nextTable = nextTab; transferIndex = n; // 从最后一个桶开始分配任务 } int nextn = nextTab.length; ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab); boolean advance = true; boolean finishing = false; // 自旋迁移数据 for (int i = 0, bound = 0;;) { // ... 分配任务、迁移数据 ... } }

7.3 扩容的核心设计要点

  1. 任务分片:每个线程每次负责stride个桶的迁移(默认 16)

  2. 从后往前迁移transferIndex记录全局迁移进度,从高位向低位推进

  3. FWD 标记:迁移完成的桶位设置为ForwardingNode,hash 值为MOVED

  4. 链表拆分:原链表被拆分为两个链表,分别放入新表的ii + n位置

  5. CAS 控制并发:通过 CAS 修改sizeCtltransferIndex协调多线程

多线程扩容的优势:扩容时间随着参与线程数增加而缩短,充分利用多核 CPU 能力。

八、链表 ↔ 红黑树转换

8.1 链表转红黑树

当链表长度 ≥8且数组长度 ≥64时,链表转换为红黑树:

private final void treeifyBin(Node<K,V>[] tab, int index) { Node<K,V> b; int n, sc; if (tab != null) { if ((n = tab.length) < MIN_TREEIFY_CAPACITY) // < 64 tryPresize(n << 1); // 优先扩容 else if ((b = tabAt(tab, index)) != null && b.hash >= 0) { synchronized (b) { if (tabAt(tab, index) == b) { // 将链表节点转为 TreeNode,然后构建红黑树 TreeNode<K,V> hd = null, tl = null; for (Node<K,V> e = b; e != null; e = e.next) { TreeNode<K,V> p = new TreeNode<K,V>(e.hash, e.key, e.val, null, null); if ((p.prev = tl) == null) hd = p; else tl.next = p; tl = p; } // 用 TreeBin 替代原链表头节点 setTabAt(tab, index, new TreeBin<K,V>(hd)); } } } } }

为什么阈值是 8?源码注释指出,在理想情况下,桶中节点数服从泊松分布,一个桶中出现 8 个节点的概率仅为0.00000006。因此 8 是一个足够保守的阈值。

8.2 红黑树转链表

当红黑树节点数减少到 ≤6时,树退化为链表。

九、总结

ConcurrentHashMap是 Java 并发容器中最闪耀的明星,它的设计体现了 Java 在并发编程领域的不断进步:

核心要点回顾

  1. 数据结构:数组 + 链表 + 红黑树,与 HashMap 1.8 保持一致

  2. 线程安全机制CAS(无竞争场景)+synchronized(锁头节点),摒弃了 Segment 分段锁

  3. 锁粒度:从 Segment 级别细化到单个桶节点,并发度大幅提升

  4. 无锁读取get方法全程不加锁,依赖volatile保证可见性

  5. 多线程扩容:支持多线程协同完成数据迁移,充分利用多核 CPU

  6. 延迟初始化:table 在第一次put时才真正创建

  7. put操作流程:首先先判断key和value是否为空,如果为空,则抛出异常,然后计算哈希值,进入自旋操作(CAS),如果table为空,则CAS初始化table,如果是正在扩容,则协助扩容,如果桶为空,则直接CAS插入,如果桶位有节点,则synchronized锁住头节点,遍历链表,有相同的则替换,没有则插入,然后判断是否需要树化。

  8. get操作流程:首先计算哈希值,定位桶,检查首节点,相同则直接返回,如果是fwd扩容中 则去nextable中寻找,如果是treebin,则去树中遍历,否则去链表中遍历。

性能对比

容器

线程安全

并发性能

适用场景

HashMap

最高

单线程环境

Hashtable

✅(全表锁)

极低

遗留代码

ConcurrentHashMap (JDK 7)

✅(分段锁)

中等并发

ConcurrentHashMap (JDK 8)

✅(CAS + 锁头节点)

高并发首选

ConcurrentHashMap的成功,源于它对并发性能线程安全的精妙平衡。无论是面试还是日常开发,深入理解它的设计思想,都能让你写出更高效、更安全的并发代码。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询