ConcurrentHashMap博客
ConcurrentHashMap 深度解析:从源码到底层,再到实战
面向:已经会用
HashMap、想真正搞懂并发容器底层、准备面试或正在做高并发开发的 Java 工程师。
本文以 JDK 1.8 为主(也是现在的主流版本),并对比 JDK 1.7 的设计演进。
目录
- 为什么需要 ConcurrentHashMap
- 底层数据结构演进(1.7 vs 1.8)
- 核心字段:读懂这些变量才算入门
- put 方法源码逐行解析
- get 为什么不需要加锁
- size / mappingCount 如何高并发计数
- 扩容与数据迁移(transfer)
- 底层知识讲解(进阶)
- 实际开发中的需求与最佳实践
- 面试高频问题速答
- 总结
一、为什么需要 ConcurrentHashMap
直接用 HashMap 在多线程下会出事:
- JDK 1.7
HashMap:扩容时采用头插法,并发resize可能形成环形链表,导致get时 CPU 100%。 - JDK 1.8
HashMap:改成尾插法避免了环,但并发put仍会出现数据覆盖(++size、桶指针赋值都不是原子的)。 Hashtable/Collections.synchronizedMap:对整个put/get加synchronized,一把锁串行化所有线程,并发性能很差。
ConcurrentHashMap 的目标就是:既要线程安全,又要高并发,同时提供弱一致性(weakly consistent)的迭代器,避免为了强一致付出巨大性能代价。
直觉对比:
| 方案 | 线程安全 | 并发度 | 迭代器一致性 |
|---|---|---|---|
HashMap |
❌ | — | — |
Hashtable |
✅ | 全局 1 把锁(串行) | 强一致(会抛 ConcurrentModificationException) |
Collections.synchronizedMap |
✅ | 全局 1 把锁 | 强一致 |
ConcurrentHashMap (1.8) |
✅ | 桶级别(可视为 N 把锁 + CAS) | 弱一致(不抛异常) |
二、底层数据结构演进(1.7 vs 1.8)
2.1 JDK 1.7:分段锁(Segment + ReentrantLock)
- 内部是一个
Segment[],默认 16 个Segment。 - 每个
Segment继承ReentrantLock,自身又是一张小的HashMap(HashEntry[]+ 链表)。 - 写操作:先定位 key 属于哪个
Segment,再lock()该段,段与段之间可以并发,段内是串行的。 - 并发度 = Segment 数量(创建后不可动态扩容)。
缺点:锁粒度仍然偏粗。如果大量 key 哈希到同一个 Segment,这个段就成了性能瓶颈,退化成“单锁串行”。
2.2 JDK 1.8:取消 Segment,采用 CAS + synchronized
- 结构与
HashMap1.8 一致:Node<K,V>[] table+ 链表 + 红黑树。 - 取消了 Segment,锁的粒度从“段”降到“桶(bin)”。
- 关键设计:
- 桶为空:用 CAS 无锁写入新节点。
- 桶非空:用
synchronized(f)锁住桶的头节点(只锁一个桶,其他桶仍可并发)。 - 链表长度 ≥ 8 且表长 ≥ 64 时树化为红黑树;长度 ≤ 6 时退树化回链表。
- 扩容时通过
ForwardingNode(hash =MOVED = -1)标记正在迁移的桶,引导线程去新表。
为什么 1.8 用
synchronized而不是ReentrantLock?
- 锁的是单个桶头节点,锁竞争极低,JVM 对
synchronized有锁升级(偏向→轻量→重量)和锁消除/锁粗化的持续优化;synchronized是 JVM 内建,不需要像ReentrantLock那样为每个节点额外维护一个AQS队列对象,内存占用更小。
三、核心字段:读懂这些变量才算入门
// 底层数组,长度总是 2 的幂;volatile 保证扩容时对其他线程可见
transient volatile Node<K,V>[] table;
// 扩容时的新数组(仅扩容期间非空)
private transient volatile Node<K,V>[] nextTable;
// 控制表初始化与扩容的核心信号量,含义随值变化:
// = 0 未初始化
// > 0 下一次扩容的阈值(容量 * 负载因子)
// = -1 正在初始化
// 其他负数 高 16 位是扩容 stamp,低 16 位是参与扩容的线程数
private transient volatile int sizeCtl;
// 扩容时,多个线程认领桶区间的游标(CAS 递减分配任务)
private transient volatile int transferIndex;
// 计数基础值,无竞争时直接累加
private transient volatile long baseCount;
// 计数分片数组(竞争激烈时分摊到各分片,思想同 LongAdder)
private transient volatile CounterCell[] counterCells;
// 初始化 counterCells 或扩容时的自旋锁标记(0 无锁 / 1 加锁)
private transient volatile int cellsBusy;
辅助常量:
static final int TREEIFY_THRESHOLD = 8; // 链表转红黑树阈值
static final int UNTREEIFY_THRESHOLD = 6; // 红黑树转链表阈值
static final int MIN_TREEIFY_CAPACITY = 64; // 树化还要求表容量至少 64
static final int MOVED = -1; // 表示该桶正在扩容(ForwardingNode)
static final int TREEBIN = -2; // 表示该桶是红黑树(TreeBin)
static final int HASH_BITS = 0x7fffffff; // 正常节点哈希的上限掩码
哈希扰动(让高位也参与运算,减少碰撞):
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
四、put 方法源码逐行解析
putVal 是核心写入方法(已省略部分分支,保留主干):
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException(); // ① 不允许 null
int hash = spread(key.hashCode()); // ② 扰动哈希
int binCount = 0;
for (Node<K,V>[] tab = table;;) { // ③ 自旋重试
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable(); // ④ 表为空先初始化
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;
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f); // ⑥ 正在扩容,帮忙搬
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; // 找到相同 key
if (!onlyIfAbsent) e.val = value; // 覆盖(value 是 volatile)
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) // ⑪ 达到阈值树化
treeifyBin(tab, i);
if (oldVal != null) return oldVal;
break;
}
}
}
addCount(1L, binCount); // ⑫ 计数 +1 并可能触发扩容
return null;
}
要点拆解:
- 不允许 key/value 为 null:并发场景下无法区分“key 不存在”和“value 就是 null”,故直接抛
NullPointerException(这也是它和HashMap的一大区别)。 spread扰动哈希:高 16 位异或低 16 位,再用& HASH_BITS去掉符号位,保证(n-1) & hash分布更均匀。- 空桶 CAS:竞争极小,无锁成功率高。
- 非空桶
synchronized(f):只锁桶头,冲突域极小。 - 双重检查
tabAt(tab,i) == f:防止在获取锁期间该桶被扩容迁移走。 addCount:累加计数,并判断是否需要扩容(sizeCtl阈值)。
initTable(用 CAS 把 sizeCtl 从 0 置为 -1 抢到初始化资格):
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 抢到初始化权
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY; // 默认 16
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2); // 阈值 = n * 0.75
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
五、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
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;
}
不加锁的底气来自 volatile:
table、Node.val、Node.next都是volatile,保证可见性和禁止重排序。- 读线程总能看到已完成的写(happens-before),不需要加锁。
- 若读到正在迁移的桶(
hash < 0的ForwardingNode),会调用find去新表查找。
代价是弱一致性:迭代器在遍历过程中,其他线程的增删改可能“部分可见”,但不会抛
ConcurrentModificationException,也不会死循环。这对缓存、计数等大多数业务场景完全够用。
六、size / mappingCount 如何高并发计数
高并发下若用单个 AtomicLong 计数,多核 CAS 会剧烈自旋竞争。JDK 1.8 采用 baseCount + CounterCell[] 分片计数(与 LongAdder 同源):
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;
}
public int size() { return (int)sumCount(); } // 返回 int,可能溢出截断
public long mappingCount() { return sumCount(); } // 推荐用这个,返回 long
CounterCell 用 @Contended 做缓存行填充,避免相邻分片伪共享:
@sun.misc.Contended
static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
注意:
size()是弱一致的估计值,不保证某一瞬间的精确值。它适合做监控/统计,不适合用来判断“集合是否刚好为空”这种强一致逻辑(见第九节坑点)。
七、扩容与数据迁移(transfer)
触发时机:addCount 发现元素数量达到阈值(sizeCtl)。扩容是容量翻倍(2n)。
核心亮点:多线程协助迁移。
- 每个线程认领一段桶区间:
stride = (n >>> 3) / NCPU(至少 16),通过transferIndexCAS 递减分配任务。 - 已经搬完的桶被替换为
ForwardingNode(hash = MOVED),后来的读写线程看到它就会先帮忙迁移,然后去新表操作。 - 旧桶
i的元素按(e.hash & n)拆分为两批:低位留在新表i,高位去新表i + n。 - 所有线程搬完后,把
table指针指向nextTable,旧表被 GC。
扩容期间能否正常读写?能。
get:碰到ForwardingNode直接去新表查。put:碰到MOVED会helpTransfer一起搬,搬完再写。- 这就是“并发迁移”比 1.7 高效很多的原因。
八、底层知识讲解(进阶)
8.1 CAS(Compare-And-Swap)
无锁原子操作:比较内存值与预期值,相等才更新,返回成功/失败。JDK 1.8 用 Unsafe 的 compareAndSwapXxx 实现 casTabAt 等操作。
- ABA 问题:值从 A→B→A,CAS 误以为没变。解决方案是加版本号(
AtomicStampedReference),但ConcurrentHashMap的桶迁移场景下 ABA 不会影响正确性,故未引入。 - 自旋开销:CAS 失败会重试,高竞争下可能空转,因此才引入
CounterCell分片降低竞争。
8.2 synchronized 的锁升级
synchronized(f) 锁的是桶头节点,竞争极低。JVM 会:偏向锁 → 轻量级锁(CAS 自旋)→ 重量级锁(操作系统互斥)。低竞争时几乎无开销。
8.3 volatile 的内存语义
table、val、next 的 volatile 保证了:写操作对后续读可见;写操作不会与后续操作重排序。这是“读不加锁”的基石。
8.4 伪共享(False Sharing)与 @Contended
CPU 缓存以缓存行(通常 64 字节)为单位。若多个 CounterCell 落在同一缓存行,一个线程改其中一个会让其他核心的整行失效,导致额外同步开销。@Contended 通过填充让它们独占缓存行。
8.5 哈希扰动与高位参与
(h ^ (h>>>16)) & HASH_BITS 让高位也参与取模定位,避免只用低位导致的桶分布不均、链表过长。
8.6 为什么 key/value 不允许 null
并发下 get(key) 返回 null 无法区分“key 不存在”还是“value 为 null”,所以干脆禁止 null,从 API 层面消除歧义。
8.7 弱一致性迭代器
迭代器创建后,不要求反映后续所有修改,也不抛 CME。设计哲学:高并发下“看到大部分、不崩溃”比“某一刻精确”更有价值。
九、实际开发中的需求与最佳实践
场景 1:高并发本地缓存 / 配置映射
// 多实例共享的配置缓存,读多写少
private static final ConcurrentHashMap<String, AppConfig> CONFIG_CACHE =
new ConcurrentHashMap<>(64);
public AppConfig getConfig(String key) {
return CONFIG_CACHE.computeIfAbsent(key, k -> loadFromDb(k)); // 注意第 6 点的坑
}
避免在 computeIfAbsent 的 mappingFunction 里再调本 map 的其他写方法(JDK 8/9 有死循环/死锁 bug,JDK 10+ 已修;保险起见尽量别在映射函数内递归写同一 map)。
场景 2:并发计数(别用 size())
// 错误:用 size 做强一致判断
if (map.size() == 0) doSomething(); // 不可靠
// 正确:用 LongAdder / AtomicLong 做精确计数
private final LongAdder hitCounter = new LongAdder();
hitCounter.increment();
long hits = hitCounter.sum();
ConcurrentHashMap.size() 只是估计值,仅用于监控面板,不要用它做业务流程的分支判断。
场景 3:批量初始容量,避免频繁扩容
// 预计放入约 1000 个元素,负载因子 0.75
int expectedSize = 1000;
int capacity = (int) (expectedSize / 0.75f) + 1; // ≈ 1334,向上取 2 的幂
ConcurrentHashMap<String, Object> map = new ConcurrentHashMap<>(capacity);
不指定容量时默认 16,频繁 put 会触发多次扩容(transfer 有成本)。预估容量能显著减少扩容次数。
场景 4:别用大对象 / 可变对象当 key
- key 的
hashCode()应稳定且分布均匀。若 key 是自定义类,必须正确重写equals和hashCode,且不要让它可变(改了 key 的字段会导致再也查不到)。 - 避免在
hashCode()里做昂贵计算(如序列化整个对象)。
场景 5:批量写入用 putAll,但注意它不是原子的
map.putAll(anotherMap); // 整个过程并发安全,但其他线程可能在中间看到部分数据
putAll 是逐条 put,不保证“要么全有要么全无”。如果业务需要“原子替换整个映射”,请用 replaceAll + 临时 map 切换引用,或加外部锁。
场景 6:用 putIfAbsent / replace 做原子操作
map.putIfAbsent(key, defaultValue); // 不存在才放
map.replace(key, oldVal, newVal); // CAS 式替换,成功返回 true
这些原子方法比“先 get 再 put”安全得多,是并发环境下避免覆盖的标准写法。
场景 7:遍历用 forEach / entrySet,别用 keys() 后逐个 get
map.forEach((k, v) -> System.out.println(k + "=" + v)); // 弱一致迭代,安全高效
弱一致迭代器不会抛异常,遍历过程中其他线程的修改可能不全部可见,但程序不会崩溃。
常见坑速查
| 坑 | 说明 |
|---|---|
size() 当强一致用 |
只是估计值,业务判断用专用计数器 |
get(null) / put(null,..) |
直接 NPE |
在 computeIfAbsent 里写同一 map |
旧 JDK 死循环/死锁 |
| 用可变对象当 key | 改字段后查不到 |
| 不设初始容量 | 频繁扩容影响性能 |
putAll 当原子批量替换 |
实际是逐条 put |
十、面试高频问题速答
-
JDK 1.7 和 1.8 的区别?
1.7 用 Segment 分段锁(继承 ReentrantLock),1.8 取消 Segment,改Node[]+ 链表/红黑树,CAS +synchronized锁桶头,粒度更细、并发更高。 -
如何保证线程安全?
初始化/空桶用 CAS;非空桶用synchronized(f)锁头节点;计数用baseCount + CounterCell分片;读操作靠volatile可见性不加锁。 -
为什么 1.8 用 synchronized 而不是 ReentrantLock?
锁的是单个桶,竞争低,JVM 对 synchronized 有持续优化(锁升级、消除/粗化),且无需额外 AQS 对象、内存更省。 -
get 为什么不加锁?
因为table、val、next都是volatile,写对读可见,且无重排序;遇到ForwardingNode会去新表查。 -
size 怎么统计?精确吗?
baseCount + Σ CounterCell。是弱一致的估计值,不精确;需要精确计数请用LongAdder。 -
扩容时还能读写吗?
能。扩容是多线程协助迁移,迁移中的桶插ForwardingNode,读写线程看到后先帮忙迁移/去新表查。 -
key/value 为什么不能为 null?
并发下无法区分“不存在”和“值为 null”,直接禁止以消歧义。
十一、总结
ConcurrentHashMap 是“并发容器设计”的教科书:
- 1.8 把锁粒度从段降到桶,配合 CAS 无锁写 和 synchronized 细锁,并发度大幅提升;
- 红黑树兜住哈希冲突的极端情况;
- 分片计数 + 伪共享填充让
size也不成为瓶颈; - 多线程协助扩容让扩容期间仍能正常读写。
理解它,不只要背八股,更要抓住一条主线:用更细的锁 + 无锁 + 内存可见性(volatile),把“安全”和“性能”同时拉满。
源码版本说明:本文代码片段基于 OpenJDK 8
java.util.concurrent.ConcurrentHashMap,不同 JDK 小版本在命名/常量上可能略有差异,但核心设计一致。

浙公网安备 33010602011771号