ConcurrentHashMap 是 Java 并发包中线程安全哈希表的标杆实现,从 JDK 1.7 到 1.8 经历了架构级重构------从"分段锁"演进为"CAS + synchronized + 红黑树",大幅提升了并发性能和代码简洁度。下面从数据结构、核心操作、并发控制、扩容机制四个维度详细展开。
一、JDK 1.7 vs JDK 1.8 架构对比
| 维度 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 数据结构 | Segment 数组 + HashEntry 数组 + 链表 | Node 数组 + 链表 / 红黑树 |
| 锁机制 | ReentrantLock 分段锁(Segment 级别) | CAS + synchronized(桶头节点级别) |
| 并发度 | 最大并发度 = Segment 个数(默认 16) | 理论上无上限(桶级别细粒度锁) |
| 扩容 | 单个 Segment 内部扩容,其他 Segment 不受影响 | 多线程协作渐进式扩容 |
| 读操作 | 需要两次 hash(定位 Segment + 定位桶) | 一次 hash 定位,全程无锁 |
| 树化 | 无,纯链表 | 链表长度 ≥ 8 且数组长度 ≥ 64 时转红黑树 |
二、JDK 1.8 核心数据结构
2.1 Node 数组
java
// 基础节点,存储 key-value
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 哈希值
final K key; // key(final,保证可见性)
volatile V val; // value(volatile,保证读可见)
volatile Node<K,V> next; // next 指针(volatile)
// ...
}
关键设计:
- val 和 next 用 volatile 修饰,保证多线程读可见性
- key 和 hash 用 final 修饰,创建后不可变,天然线程安全
- 底层 Node\[\] table 也用 volatile 修饰,保证扩容后引用变更的可见性
2.2 特殊节点类型
| 节点类型 | 说明 |
|---|---|
| ForwardingNode | hash = -1(MOVED),扩容时标记已迁移的桶,读操作转发到 nextTable |
| TreeBin | 红黑树的包装节点,持有根节点引用,提供树化/链表化操作 |
| TreeNode | 红黑树节点,继承自 Node,增加 parent/left/right/red 属性 |
| ReservationNode | 占位节点,用于 computeIfAbsent 等方法的占位 |
三、核心操作源码解析
3.1 put 流程(JDK 1.8)
java
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;
// 1. 数组未初始化,CAS 初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 2. 目标桶为空,CAS 直接插入(无锁)
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break;
}
// 3. 发现 ForwardingNode,说明正在扩容,协助迁移
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 4. 桶非空且未扩容,synchronized 锁住头节点
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;
}
}
}
}
// 5. 链表长度 ≥ 8,尝试树化
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount); // 更新计数 + 检查扩容
return null;
}
put 流程总结:
- 计算 hash → 2. CAS 初始化数组 → 3. CAS 空桶插入 → 4. 发现扩容则协助迁移 → 5. synchronized 锁头节点插入链表/树 → 6. 检查树化 → 7. 更新计数并检查扩容
3.2 get 流程(全程无锁)
java
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;
}
// hash < 0,可能是 ForwardingNode 或 TreeBin
else if (eh < 0)
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 为什么不需要加锁:
- Node 的 val 和 next 是 volatile,保证写操作立即可见
- table 数组引用是 volatile,扩容后新数组引用立即可见
- 即使读到正在扩容的 ForwardingNode,也会被转发到 nextTable 继续查找
3.3 size / mappingCount(分段计数)
java
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}
public long mappingCount() {
long n = sumCount();
return (n < 0L) ? 0L : n;
}
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
计数机制(类似 LongAdder):
- baseCount:基础计数器,无竞争时 CAS 更新
- counterCells:计数器数组,竞争时将增量分散到不同槽位
- 每个线程根据 ThreadLocalRandom.getProbe() 映射到特定槽位,减少 CAS 竞争
四、扩容机制(JDK 1.8 核心难点)
4.1 触发条件
java
// 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) {
// 达到扩容阈值,触发扩容
}
}
4.2 多线程协作扩容(transfer)
java
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// 1. 计算每个线程负责的迁移范围(步长)
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // 最小步长 16
// 2. 初始化新表(2倍容量)
if (nextTab == null) {
Node<K,V>[] nt = (Node<K,V>[])new Node[n << 1];
nextTab = nt;
nextTable = nextTab;
transferIndex = n; // 从数组末尾开始分配任务
}
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false;
// 3. 自旋迁移
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
// 3.1 分配迁移任务(CAS 更新 transferIndex)
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1; advance = false; // 无任务可分配
}
else if (U.compareAndSwapInt(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ? nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
// 3.2 迁移完成判断
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) { // 全部迁移完成
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1); // 新阈值 = 0.75 * 新容量
return;
}
// 当前线程完成,CAS 减少扩容线程数
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return; // 还有其他线程在扩容
finishing = advance = true;
i = n; // 最后检查一遍
}
}
// 3.3 迁移空桶
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd); // 标记 ForwardingNode
// 3.4 已迁移过
else if ((fh = f.hash) == MOVED)
advance = true;
// 3.5 迁移非空桶(synchronized 锁住头节点)
else {
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
if (fh >= 0) { // 链表迁移
// 高低位拆分:hash & oldCap == 0 留在原位置,否则移到 i + n
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
// 构建高低位链表
if (runBit == 0) { ln = lastRun; hn = null; }
else { hn = lastRun; ln = null; }
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln); // 低位放原位置
setTabAt(nextTab, i + n, hn); // 高位放 i + n
setTabAt(tab, i, fwd); // 旧桶标记已迁移
advance = true;
}
else if (f instanceof TreeBin) { // 红黑树迁移
// 同样拆分为高低两棵树
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null) lo = p;
else loTail.next = p;
loTail = p; ++lc;
} else {
if ((p.prev = hiTail) == null) hi = p;
else hiTail.next = p;
hiTail = p; ++hc;
}
}
// 树节点数 < 6 则退化为链表
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
扩容核心要点:
- 渐进式扩容:不是一次性迁移所有数据,而是分批次、多线程协作
- 任务分配:通过 transferIndex CAS 分配迁移范围,每个线程负责一段
- 高低位拆分:利用 hash & oldCap 判断节点留在原位置还是移到 i + n
- ForwardingNode 标记:已迁移的桶用 hash = -1 的 ForwardingNode 标记,读操作转发到新表
- 协助扩容:其他线程发现桶正在迁移时,调用 helpTransfer 协助迁移
五、JDK 1.7 分段锁回顾(对比理解)
java
// 1.7 结构:Segment 数组,每个 Segment 继承 ReentrantLock
static final class Segment<K,V> extends ReentrantLock implements Serializable {
transient volatile HashEntry<K,V>[] table;
transient int count;
// ...
}
// put 流程:先定位 Segment,再获取锁
public V put(K key, V value) {
Segment<K,V> s;
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask; // 定位 Segment
if ((s = (Segment<K,V>)UNSAFE.getObject(segments, (j << SSHIFT) + SBASE)) == null)
s = ensureSegment(j); // CAS 初始化 Segment
return s.put(key, hash, value, false); // Segment 内部加锁 put
}
// Segment.put:tryLock → scanAndLockForPut(自旋)→ lock(阻塞)
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
// ... 链表操作
finally { unlock(); }
}
1.7 的局限:
- Segment 个数初始化后不可变,最大并发度受限
- 每个 Segment 内部是独立的小 HashMap,扩容时只扩容该 Segment
- 读操作需要两次 hash 计算,且可能读到过期数据(需重试)
六、关键设计总结
| 设计点 | 实现方式 | 目的 |
|---|---|---|
| 无锁读 | volatile 保证可见性 + ForwardingNode 转发 |
读操作完全并行,不加锁 |
| CAS 写 | sun.misc.Unsafe 的 CAS 操作 |
空桶插入无锁竞争 |
| 细粒度锁 | synchronized 锁住桶头节点 |
冲突时只锁一个桶,其他桶并发 |
| 分段计数 | baseCount + CounterCell[] |
避免所有线程竞争同一计数器 |
| 协作扩容 | transferIndex CAS 分配任务 + 多线程迁移 |
减少单线程扩容停顿 |
| 红黑树优化 | 链表长度 ≥ 8 且数组长度 ≥ 64 时树化 | 极端冲突下 O(n) → O(log n) |
七、使用注意事项
1. key/value 不能为 null:设计如此,避免 get(key) 返回 null 时无法区分"key 不存在"和"value 为 null"
java
// 以下代码会抛出 NullPointerException
map.put(null, "value"); // ❌
map.put("key", null); // ❌
设计原因:
- get(key) 返回 null 时,无法区分"key 不存在"和"value 为 null"
- ConcurrentHashMap 作者 Doug Lea 认为多线程下 null 语义模糊,干脆禁止
对比:HashMap 允许 null key/value,Hashtable 也禁止。
2. size() 是估计值:并发环境下返回的是近似值,非精确值
java
public int size() {
long n = sumCount();
return (n < 0L) ? 0 : (n > Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n;
}
问题:
- sumCount() 累加 baseCount + CounterCell\[\],但计数器更新有延迟
- 并发写入时,size() 返回的是近似值,不是精确值
- 如果需要精确计数,需外部加锁或使用 AtomicInteger 辅助
对比:Collections.synchronizedMap 的 size() 是精确的(因为全表锁),但代价是性能。
3. computeIfAbsent 原子性:保证"判断+计算+插入"整体原子,但计算逻辑不能太重
4. 迭代器弱一致性:不会抛出 ConcurrentModificationException,但可能读到已删除或读不到新插入的元素
可能读到过期/缺失数据
java
// 迭代期间,其他线程的修改可能反映也可能不反映
for (Map.Entry<K, V> entry : map.entrySet()) {
// 可能读到已被删除的元素
// 可能读不到刚插入的元素
}
问题:
- 不会抛出 ConcurrentModificationException(这是好处也是缺点)
- 如果需要强一致性快照,需自己加锁或使用 Collections.unmodifiableMap(new HashMap<>(map)) 拷贝
5. 避免热点 key:如果所有线程都竞争同一个桶,性能会退化到接近 Hashtable
java
// 极端场景:所有线程都 put/get 同一个 key
// 所有操作都竞争同一个桶的头节点锁
问题:
- 虽然锁粒度是桶级别,但如果所有操作都命中同一个桶,性能退化到接近 Hashtable
- 解决方案:key 设计分散(好的 hashCode),或使用 Striped 锁分散热点
6. 内存开销高于 HashMap
| 开销来源 | 说明 |
|---|---|
volatile 字段 |
每个 Node 的 val 和 next 都是 volatile,有内存屏障开销 |
CounterCell[] |
分段计数器数组,无竞争时也存在 |
| 红黑树节点 | TreeNode 比 Node 多 parent/left/right/red 等字段 |
| 扩容时的双表 | 扩容期间同时存在 table 和 nextTable |
估算:同等数据量下,ConcurrentHashMap 内存占用约为 HashMap 的 1.2~1.5 倍。
7. computeIfAbsent 的坑:计算逻辑可能阻塞其他线程
java
map.computeIfAbsent(key, k -> {
// 如果这里执行很慢(如网络请求、复杂计算)
// 其他线程对该桶的读写都会被阻塞!
return loadFromDatabase(k);
});
问题:
- computeIfAbsent 内部会 synchronized 锁住桶头节点
- 计算逻辑在锁内执行,重计算会阻塞该桶的所有并发操作
- 解决方案:先 get 判断,不存在时再用 putIfAbsent 或外部缓存
8. 扩容期间的写操作有一定开销
java
// 扩容期间,发现 ForwardingNode(hash = -1)
// 需要调用 helpTransfer() 协助迁移,增加额外操作
问题:
- 虽然渐进式扩容避免了全表停顿,但扩容期间的写入操作需要额外判断和协助迁移
- 如果写入频繁且容量设计不当,可能频繁触发扩容,影响吞吐量
9. 序列化/克隆开销
java
// ConcurrentHashMap 的序列化不是原子操作
// 序列化期间其他线程的修改可能部分反映
七、好处
1. 极高的并发性能
| 对比项 | Hashtable | Collections.synchronizedMap | ConcurrentHashMap |
|---|---|---|---|
| 锁粒度 | 整张表(synchronized 方法) | 整张表(synchronized 代码块) | 桶级别(CAS + synchronized 头节点) |
| 读操作 | 阻塞(所有读写互斥) | 阻塞 | 完全无锁,多线程并行读 |
| 写操作 | 串行 | 串行 | 不同桶并行写入,冲突桶才串行 |
| 并发度 | 1 | 1 | 理论上无上限(取决于桶数) |
核心原理 :JDK 1.8 中,空桶插入用 CAS(无锁),冲突桶用 synchronized 锁头节点,读操作全程依赖 volatile 保证可见性,不加任何锁。
2. 渐进式扩容(无停顿扩容)
java
// 扩容时,多个线程通过 transferIndex CAS 分配迁移任务
// 每个线程负责一段范围的桶迁移,逐步完成
好处:
- 不会像 HashMap 那样一次性全表迁移导致长时间停顿
- 扩容期间,读操作不受影响(ForwardingNode 转发到新表)
- 其他线程可以协助扩容,加快迁移速度
3. 读操作完全无锁
java
public V get(Object key) {
// 全程无 synchronized、无 Lock、无 CAS
// 仅靠 volatile 保证可见性
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// ... 直接读取
}
好处:
- 读操作零阻塞,性能接近 HashMap
- 高并发读场景(如配置缓存、热点数据)表现极佳
- 无锁读意味着无线程上下文切换开销
4. 分段计数器(低竞争计数)
java
// 类似 LongAdder 的设计
private transient volatile long baseCount;
private transient volatile CounterCell[] counterCells;
好处:
- 无竞争时 CAS 更新 baseCount
- 高竞争时分散到 CounterCell\[\] 数组,各线程写不同槽位
- 避免了所有线程竞争同一个计数器导致的 CAS 失败重试
5. 红黑树优化极端冲突
| 条件 | 行为 |
|---|---|
| 链表长度 < 8 | 保持链表,插入 O(1) |
| 链表长度 ≥ 8 且数组长度 ≥ 64 | 转为红黑树,查询 O(log n) |
| 树节点数 < 6 | 退化为链表,节省内存 |
好处:避免哈希冲突极端情况下(所有 key 哈希相同)链表退化为 O(n) 查询。
6. 丰富的原子复合操作
java
// 原子性:判断 + 计算 + 插入
V v = map.computeIfAbsent(key, k -> expensiveCompute(k));
// 原子性:判断 + 删除
map.remove(key, value);
// 原子性:判断 + 替换
map.replace(key, oldValue, newValue);
好处:避免了传统 if (!map.containsKey(k)) map.put(k, v) 的非原子竞态。
八、适用场景与选型建议
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 高并发读 + 少量写 | ConcurrentHashMap ✅ |
读无锁,性能最优 |
| 高并发读写均衡 | ConcurrentHashMap ✅ |
桶级别锁,读写并行 |
| 需要精确 size() | Collections.synchronizedMap + 外部锁 |
ConcurrentHashMap.size() 是近似值 |
| 需要有序 | ConcurrentSkipListMap |
ConcurrentHashMap 无序 |
| 单线程 / 低并发 | HashMap ✅ |
无并发开销,内存更省 |
| 需要 null key/value | HashMap 或 Collections.synchronizedMap |
ConcurrentHashMap 禁止 null |
| 需要事务性批量操作 | 外部锁 + HashMap |
ConcurrentHashMap 无事务支持 |
九、总结
bash
ConcurrentHashMap 的核心设计哲学:
"在绝大多数情况下保证线程安全,同时尽可能减少锁的范围"
好处:
✓ 读完全无锁,性能接近 HashMap
✓ 写锁粒度细(桶级别),并发度高
✓ 渐进式扩容,无全表停顿
✓ 分段计数,低竞争开销
✓ 红黑树优化极端冲突
缺点:
✗ size() 是近似值,非精确
✗ 迭代器弱一致性,无快照保证
✗ 不支持 null key/value
✗ 热点 key 导致性能退化
✗ 内存开销高于 HashMap
✗ computeIfAbsent 重计算会阻塞桶
✗ 扩容期间写入有额外开销
如果你的场景是高并发读写、不要求精确计数、不需要 null、key 分布均匀,ConcurrentHashMap 是最佳选择。如果需要精确语义或有序性,则需配合其他工具或改用其他实现。