一、引言: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——基本存储节点
Node 是 ConcurrentHashMap 中最基础的存储单元:
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;
}
}
关键设计:val 和 next 都使用了 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——最核心的控制字段
sizeCtl 是 ConcurrentHashMap 中出镜率最高的字段,它的值在不同阶段代表不同含义:
|
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); // 当前线程协助扩容
如果桶位的头节点是 ForwardingNode(hash == 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 不需要加锁?
volatile 保证可见性:Node 的 val 和 next 都是 volatile 的,写入的结果对所有线程立即可见
table 是 volatile 的:数组引用本身保证可见性
Node 的 hash 和 key 是 final 的:一旦创建不可变
扩容时的特殊处理:通过 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 扩容的核心设计要点
任务分片:每个线程每次负责 stride 个桶的迁移(默认 16)
从后往前迁移:transferIndex 记录全局迁移进度,从高位向低位推进
FWD 标记:迁移完成的桶位设置为 ForwardingNode,hash 值为 MOVED
链表拆分:原链表被拆分为两个链表,分别放入新表的 i 和 i + n 位置
CAS 控制并发:通过 CAS 修改 sizeCtl 和 transferIndex 协调多线程
多线程扩容的优势:扩容时间随着参与线程数增加而缩短,充分利用多核 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 在并发编程领域的不断进步:
核心要点回顾
数据结构:数组 + 链表 + 红黑树,与 HashMap 1.8 保持一致
线程安全机制:CAS(无竞争场景)+ synchronized(锁头节点),摒弃了 Segment 分段锁
锁粒度:从 Segment 级别细化到单个桶节点,并发度大幅提升
无锁读取:get 方法全程不加锁,依赖 volatile 保证可见性
多线程扩容:支持多线程协同完成数据迁移,充分利用多核 CPU
延迟初始化:table 在第一次 put 时才真正创建
put操作流程:首先先判断key和value是否为空,如果为空,则抛出异常,然后计算哈希值,进入自旋操作(CAS),如果table为空,则CAS初始化table,如果是正在扩容,则协助扩容,如果桶为空,则直接CAS插入,如果桶位有节点,则synchronized锁住头节点,遍历链表,有相同的则替换,没有则插入,然后判断是否需要树化。
get操作流程:首先计算哈希值,定位桶,检查首节点,相同则直接返回,如果是fwd扩容中 则去nextable中寻找,如果是treebin,则去树中遍历,否则去链表中遍历。
性能对比
|
容器 |
线程安全 |
并发性能 |
适用场景 |
|
HashMap |
❌ |
最高 |
单线程环境 |
|
Hashtable |
✅(全表锁) |
极低 |
遗留代码 |
|
ConcurrentHashMap (JDK 7) |
✅(分段锁) |
中 |
中等并发 |
|
ConcurrentHashMap (JDK 8) |
✅(CAS + 锁头节点) |
高 |
高并发首选 |
ConcurrentHashMap 的成功,源于它对并发性能和线程安全的精妙平衡。无论是面试还是日常开发,深入理解它的设计思想,都能让你写出更高效、更安全的并发代码。
网硕互联帮助中心



评论前必须登录!
注册