简单聊聊 ConcurrentHashMap 源码
和 简单聊聊线程池源码 那篇一个套路:与其背"ConcurrentHashMap 是线程安全的",不如按 JDK 1.8 的核心执行流程
手写一个迷你版
MiniConcurrentHashMap,一行一行对照源码,聊明白
put到底是怎么并发安全的。开头先抛个坑,迷你版第一行就验证:
javaConcurrentHashMap<Object, Object> map = new ConcurrentHashMap<>(); map.put(null, null); // 直接抛 NullPointerException同样是 Map,HashMap 允许 null,它连 null 都不让进------这是并发设计的第一道防线。
一、它到底在解决什么问题
一句话:既要并发安全,又不想像 Hashtable 那样全表加锁。
| 并发安全性 | 并发粒度 | |
|---|---|---|
HashMap |
❌ 不安全(1.7 链表头插会死循环;1.8 并发 put 覆盖丢数据) | 无锁 |
Hashtable / synchronizedMap |
✅ 安全 | 整张表一把锁,任何操作串行 |
ConcurrentHashMap |
✅ 安全 | 锁桶头 + CAS,不同桶互不干扰 |
1.8 的设计核心八个字:"读不加锁,写锁桶头" 。迷你版 MiniConcurrentHashMap 完整复刻了这条主路径。
二、前置:为什么 key/value 都不能为 null
putVal 第一行就判空:
java
if (key == null || value == null) throw new NullPointerException();
两个原因:
- 歧义 :
get(key) == null既可能表示"没有这个 key",也可能表示"值是 null";单线程 HashMap 能容忍,但 CHM 是给putIfAbsent这类复合操作用的,null 会摧毁"是否存在"的语义; - 并发传递歧义 :并发下很难说清
put(k, null)这次写入到底有没有发生,索性设计层面直接禁止,从源头消灭不确定性。
面试点:这是 CHM 和 HashMap 最常被问到的表面区别,要能说出"为什么"。
三、三个支撑结构(先认识零件)
java
// ① 桶头节点:hash/key 不可变,value/next 是 volatile → 读不需要锁
static class Node<K, V> {
final int hash;
final K key;
volatile V value;
volatile Node<K, V> next;
}
// ② 桶数组:元素级 volatile + CAS
private volatile AtomicReferenceArray<Node<K, V>> table;
// ③ 扩容阈值 = 容量 * 0.75;元素个数
private volatile int sizeCtl;
private final AtomicInteger baseCount = new AtomicInteger();
三个设计点:
- Node 的 value/next 是 volatile ,这是"读不加锁"的物理基础------读到的值一定是某个线程完整写入后的值,不会读"写了一半"的脏状态;
- table 是 volatile + 元素级 CAS :JDK 用
Unsafe.getObjectVolatile / compareAndSwapObject操作原生数组;迷你版用AtomicReferenceArray提供同样能力,不依赖jdk.unsupported; sizeCtl一个变量身兼两职:平时是扩容阈值(容量 × 0.75),初始化/扩容时是竞争标志。
四、put 执行流程:死循环 + CAS 的四大防线
迷你版 putVal 的核心骨架(对应 JDK 的 putVal):
java
private V putVal(K key, V value) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode()); // 扰动:高位参与定位,减少碰撞
for (;;) { // 死循环 + CAS
AtomicReferenceArray<Node<K, V>> tab = table;
int n = tab.length();
int i = (n - 1) & hash; // 定位桶
Node<K, V> f = tab.get(i); // volatile 读桶头
if (f == null) {
// 分支B: 桶是空的 → CAS 直接写,无锁,最快路径
Node<K, V> node = new Node<>(hash, key, value);
if (tab.compareAndSet(i, null, node)) {
addCount(1);
return null; // 写入成功,收工
}
continue; // CAS 失败:被别人抢先,重试
}
// (JDK 分支C: f.hash == MOVED → 扩容中,helpTransfer 帮忙搬,迷你版省略)
synchronized (f) { // 分支D: 桶不空 → 锁桶头
if (tab.get(i) == f) { // 双重检查:桶头还是不是我锁的那个
V old = null;
for (Node<K, V> e = f; ; e = e.next) {
if (e.hash == hash && Objects.equals(e.key, key)) {
old = e.value; // key 已存在 → 覆盖
e.value = value;
break;
}
if (e.next == null) {
e.next = new Node<>(hash, key, value); // 尾插
break;
}
}
if (old == null) addCount(1); // 只有"新增"才计数
return old;
}
}
// 桶头被换(只有扩容会换) → 重试
}
}
画成流程图:
put(key, value)
│
key/value 判空 ──null──▶ 抛 NPE
│
hash = spread(key.hashCode())
│
┌───────▼────────死循环 + CAS────────┐
│ B. 桶是空的? ──是──▶ CAS 直接写入 ══▶ 收工(最快路径,无锁)
│ │
│ CAS 失败(别人抢先)→ 重试
│ D. 桶不空 ──▶ synchronized 锁桶头
│ └─ 双重检查 tab.get(i)==f
│ ├─ key 已存在 → 覆盖
│ └─ 不存在 → 尾插新节点
└─────────────────────────────────────┘
│
addCount(1) → 超过 sizeCtl 触发扩容
为什么"空桶用 CAS,非空桶才加锁"
- 空桶是 put 的最快路径 :两个线程同时看见空桶,
compareAndSet(i, null, node)保证只有一个成功 ,另一个 CAS 失败回到循环头重试(可能走到分支 D)。没有锁,一次 CAS 结束; - 非空桶锁"桶头节点"而不是整张表 :只有落在同一个桶的线程才竞争这把锁,不同桶的 put 完全并行------这就是 1.8 比 Hashtable 并发度高的本质;
- 双重检查
tab.get(i) == f:锁定时桶头可能被其他线程换掉(扩容迁移),必须确认还是原来那个节点,防止锁错对象、写错桶。
一个容易漏的细节:为什么是"尾插"
JDK 1.7 的 HashMap/CHM 扩容用头插 ,并发下会形成环形链表死循环;1.8 全部改尾插,新节点挂在链尾,链表顺序稳定,死循环问题从根上消失。
五、扩容 resize(为什么演示里要开关)
java
private void addCount(int delta) {
int c = baseCount.addAndGet(delta);
if (resizeEnabled && c >= sizeCtl) resize(); // 超过阈值 → 扩容
}
private synchronized void resize() {
// 容量翻倍,重哈希,尾插
...
table = newTab;
sizeCtl = (int) (newCap * LOAD_FACTOR);
}
三个要点:
- 触发条件 :元素个数 ≥
容量 × 0.75,容量翻倍(迷你版场景 4 实测:8 -> 16、16 -> 32,阈值 6、12 各触发一次); - 双重检查防重复扩容 :
resize()是synchronized,进入后先看baseCount <= sizeCtl------可能别的线程已经扩过了,直接返回; resizeEnabled开关 :迷你版的扩容是单线程 synchronized,和 JDK 的多线程搬桶不一样,所以并发插入演示(场景 3)把扩容关掉,避免简化版扩容的竞态污染"不丢数据"的结论。
六、get 无锁,size 近似
java
public V get(Object key) {
int hash = spread(key.hashCode());
Node<K, V> e = table.get((table.length() - 1) & hash);
for (; e != null; e = e.next) {
if (e.hash == hash && Objects.equals(e.key, key)) return e.value;
}
return null;
}
- get 完全不加锁 ,靠的就是 Node 的
volatile value+volatile next:读到的要么是完整旧值、要么是完整新值; size()返回baseCount.get()。JDK 1.8 更进一步:争用高时把计数分散到一组CounterCell[](LongAdder 思路),size()求和------所以 CHM 的size()是个近似值,并发语义下完全可接受。迷你版简化为一个AtomicInteger,但"读时求和、不是遍历全表"的思路一致。
七、迷你版和 JDK 的差距(面试要能诚实说出来)
| 迷你版 | JDK 1.8 | 影响 |
|---|---|---|
| 构造器直接建好 table | 首次 put 才 initTable(),CAS 抢初始化权 |
懒加载优化,行为等价 |
| 没有分支 C | ForwardingNode(hash=MOVED) + helpTransfer 多线程搬桶 |
put 遇到扩容会主动帮忙搬 |
resize() 单线程 synchronized |
多线程分区间并发迁移 + 高低位拆分 | 并发扩容性能差,但逻辑正确 |
| 没有红黑树 | 链表 ≥ 8 且容量 ≥ 64 → treeifyBin 转红黑树 |
极端碰撞下查询 O(n)→O(log n) |
AtomicReferenceArray |
Unsafe.getObjectVolatile + CAS 操作原生数组 |
能力一致,JDK 更底层 |
单个 AtomicInteger 计数 |
baseCount + CounterCell[] 分散计数 |
高并发计数争用更小 |
核心执行路径------判空、spread 扰动、死循环 + CAS、空桶 CAS、锁桶头 + 双重检查、尾插、addCount 阈值扩容------与 JDK 完全一致。 把这几个点讲清楚再去啃 JDK 源码,剩下的都是增量。
八、演示怎么跑:四个场景对应四个面试点
MiniConcurrentHashMap 演示代码
java
package com.interview.thread;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReferenceArray;
/**
* 迷你版 ConcurrentHashMap ------ 复刻 1.8 的 put 执行流程,去掉细节,保留骨架。
*
* 与 JDK 的对应关系(详解见 md/简单聊聊ConcurrentHashMap源码.md):
* - 分支B:空桶 CAS 直接写(最快路径,无锁)
* - 分支D:非空桶 synchronized 锁桶头,链表尾插,双重检查桶头没变
* - addCount:baseCount 计数,超过 sizeCtl 触发扩容
*
* 有意识的简化(面试要能诚实说出来):
* - 分支A initTable:JDK 并发 CAS 抢初始化,这里构造器里直接建好;
* - 分支C 扩容中:JDK 用 ForwardingNode(hash=MOVED)+ helpTransfer 多线程搬桶,
* 这里扩容是 synchronized 单线程,也没有 ForwardingNode;
* - 红黑树:JDK 链表≥8 且容量≥64 才转树,这里省略(TreeBin 太复杂);
* - JDK 用 Unsafe 操作原生 volatile 数组,这里用 AtomicReferenceArray 提供同样的
* volatile 读 + CAS 写,不依赖 jdk.unsupported。
*/
public class MiniConcurrentHashMap<K, V> {
private static final int DEFAULT_CAPACITY = 16;
private static final float LOAD_FACTOR = 0.75f;
private static final int MAXIMUM_CAPACITY = 1 << 30;
/** 桶头节点:hash/key 不可变,value/next 是 volatile → 读不需要锁 */
static class Node<K, V> {
final int hash;
final K key;
volatile V value;
volatile Node<K, V> next;
Node(int hash, K key, V value) {
this.hash = hash;
this.key = key;
this.value = value;
}
}
/** 桶数组:元素级 volatile + CAS(演示版替代 JDK 的 Unsafe) */
private volatile AtomicReferenceArray<Node<K, V>> table;
/** 扩容阈值 = 容量 * 0.75 */
private volatile int sizeCtl;
/** 元素个数 */
private final AtomicInteger baseCount = new AtomicInteger();
/** 演示开关:并发场景关掉扩容,避免简化版单线程扩容的竞态 */
volatile boolean resizeEnabled = true;
public MiniConcurrentHashMap() {
this(DEFAULT_CAPACITY);
}
public MiniConcurrentHashMap(int initialCapacity) {
int cap = 1;
while (cap < initialCapacity) cap <<= 1;
this.table = new AtomicReferenceArray<>(cap);
this.sizeCtl = (int) (cap * LOAD_FACTOR);
}
/** 扰动:高位异或低位参与定位,减少碰撞(JDK 的 spread) */
private static int spread(int h) {
return (h ^ (h >>> 16)) & 0x7fffffff;
}
public V put(K key, V value) {
return putVal(key, value);
}
private V putVal(K key, V value) {
// 第一道防线:key/value 都不允许 null。HashMap 允许,这里直接抛 NPE。
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
for (;;) { // 死循环 + CAS
AtomicReferenceArray<Node<K, V>> tab = table;
int n = tab.length();
int i = (n - 1) & hash; // 定位桶
Node<K, V> f = tab.get(i); // volatile 读桶头
if (f == null) {
// 分支B: 桶是空的 → CAS 直接写,无锁,最快路径
Node<K, V> node = new Node<>(hash, key, value);
if (tab.compareAndSet(i, null, node)) {
addCount(1);
return null; // 写入成功,收工
}
continue; // CAS 失败:被别人抢先,重试
}
// (JDK 分支C: f.hash == MOVED → helpTransfer 帮忙扩容。这里没有并发扩容,省略)
// 分支D: 桶不空 → 锁桶头,只锁这一个桶,不同桶互不干扰
synchronized (f) {
if (tab.get(i) == f) { // 双重检查:桶头还是不是我锁的那个
V old = null;
for (Node<K, V> e = f; ; e = e.next) {
if (e.hash == hash && Objects.equals(e.key, key)) {
old = e.value; // key 已存在 → 覆盖
e.value = value;
break;
}
if (e.next == null) {
e.next = new Node<>(hash, key, value); // 尾插
break;
}
}
// (JDK: 这里会判断链表长度 ≥8 → treeifyBin 转红黑树,省略)
if (old == null) addCount(1); // 只有"新增"才计数
return old;
}
}
// 桶头被换(本演示只有扩容会换) → 重试
}
}
/** 无锁读:靠 Node.value/next 的 volatile 保证读到的是某个线程完整写入的值 */
public V get(Object key) {
int hash = spread(key.hashCode());
AtomicReferenceArray<Node<K, V>> tab = table;
Node<K, V> e = tab.get((tab.length() - 1) & hash);
for (; e != null; e = e.next) {
if (e.hash == hash && Objects.equals(e.key, key)) return e.value;
}
return null;
}
public int size() {
return baseCount.get();
}
/** 计数 + 扩容检查(JDK 是 baseCount + CounterCell 分散计数,这里简化为一个 AtomicInteger) */
private void addCount(int delta) {
int c = baseCount.addAndGet(delta);
if (resizeEnabled && c >= sizeCtl) resize();
}
/** 扩容:容量翻倍,重哈希尾插(JDK 是多线程搬桶 + 高低位拆分 + ForwardingNode) */
private synchronized void resize() {
AtomicReferenceArray<Node<K, V>> oldTab = table;
int oldCap = oldTab.length();
if (oldCap >= MAXIMUM_CAPACITY) { sizeCtl = Integer.MAX_VALUE; return; }
// 双重检查:可能别的线程已经扩过了(配合 synchronized 防重复扩容)
if (baseCount.get() <= sizeCtl) return;
int newCap = oldCap << 1;
AtomicReferenceArray<Node<K, V>> newTab = new AtomicReferenceArray<>(newCap);
for (int i = 0; i < oldCap; i++) {
Node<K, V> e = oldTab.get(i);
while (e != null) {
Node<K, V> next = e.next;
e.next = null; // 断开旧链,防止把整条链带过去
insertTail(newTab, (newCap - 1) & e.hash, e);
e = next;
}
}
table = newTab;
sizeCtl = (int) (newCap * LOAD_FACTOR);
System.out.println("[MiniCHM] 触发扩容:" + oldCap + " -> " + newCap + ", 新阈值=" + sizeCtl);
}
private void insertTail(AtomicReferenceArray<Node<K, V>> tab, int idx, Node<K, V> node) {
Node<K, V> head = tab.get(idx);
if (head == null) {
tab.set(idx, node);
} else {
Node<K, V> cur = head;
while (cur.next != null) cur = cur.next;
cur.next = node;
}
}
// ==================== 演示 ====================
public static void main(String[] args) throws Exception {
// 场景1: 正常 put/get
MiniConcurrentHashMap<String, Integer> map = new MiniConcurrentHashMap<>();
map.put("A", 1);
map.put("B", 2);
System.out.println("场景1 get(A)=" + map.get("A") + ", get(B)=" + map.get("B") + ", size=" + map.size());
// 场景2: 不允许 null → NPE(和 HashMap 最大的表面区别)
try {
map.put(null, 1);
} catch (NullPointerException e) {
System.out.println("场景2 put(null, 1) 抛 NPE ✓");
}
try {
map.put("A", null);
} catch (NullPointerException e) {
System.out.println("场景2 put(\"A\", null) 抛 NPE ✓");
}
// 场景3: 8 线程并发 put,关闭扩容(避免简化版单线程扩容的竞态),验证不丢数据
MiniConcurrentHashMap<Integer, Integer> cmap = new MiniConcurrentHashMap<>();
cmap.resizeEnabled = false;
int threads = 8, perThread = 1000;
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch start = new CountDownLatch(1);
List<Future<?>> futures = new ArrayList<>();
for (int t = 0; t < threads; t++) {
final int base = t * perThread;
futures.add(pool.submit(() -> {
try {
start.await(); // 等所有线程就绪,一起开闸
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
for (int j = 0; j < perThread; j++) cmap.put(base + j, j);
}));
}
start.countDown();
for (Future<?> f : futures) f.get();
pool.shutdown();
int expected = threads * perThread;
System.out.println("场景3 8 线程并发 put " + expected + " 个, size=" + cmap.size()
+ (cmap.size() == expected ? " ✓ 不丢数据" : " ✗ 丢数据了!"));
System.out.println("场景3 get(123)=" + cmap.get(123));
// 场景4: 单线程触发扩容(阈值=8*0.75=6,插满触发 8->16,再到 16->32)
MiniConcurrentHashMap<Integer, Integer> rmap = new MiniConcurrentHashMap<>(8);
for (int i = 0; i < 20; i++) rmap.put(i, i);
System.out.println("场景4 插 20 个后 size=" + rmap.size() + ", get(15)=" + rmap.get(15));
}
}
场景1 get(A)=1, get(B)=2, size=2
场景2 put(null, 1) 抛 NPE ✓
场景2 put("A", null) 抛 NPE ✓
场景3 8 线程并发 put 8000 个, size=8000 ✓ 不丢数据
场景3 get(123)=123
[MiniCHM] 触发扩容:8 -> 16, 新阈值=12
[MiniCHM] 触发扩容:16 -> 32, 新阈值=24
场景4 插 20 个后 size=20, get(15)=15
| 场景 | 演示内容 | 对应面试问题 |
|---|---|---|
| 1 | 正常 put/get | 基本使用 |
| 2 | put(null, ...) 抛 NPE |
CHM 为什么不允许 null? |
| 3 | 8 线程并发 8000 个不丢数据 | 并发 put 会丢数据吗? |
| 4 | 阈值触发两次扩容 | 什么时候扩容?怎么避免重复扩容? |
九、结语
ConcurrentHashMap 1.8 的 put,本质是一条"尽可能无锁、不得已才加锁"的降级路线:
空桶 → CAS 一把梭(无锁)
非空桶 → synchronized 只锁桶头(细粒度)
扩容中 → 帮忙搬(协作,JDK)
再加三件配套:spread 扰动 降低碰撞、尾插 根除死循环、baseCount + CounterCell 分散计数。把这五块讲清楚,CHM 的源码面试基本就过关了------完整代码见 MiniConcurrentHashMap.java,也可以对照 JDK 的 ConcurrentHashMap 逐行读,体会 Doug Lea 的 CAS 与 volatile 功力。