ConcurrentHashMap(重中之重)
一、基础概念
为什么需要 ConcurrentHashMap?
| 集合 | 问题 |
|---|---|
HashMap |
线程不安全,多线程 put 死循环(JDK 7 头插法)或丢失数据 |
Hashtable |
线程安全但性能差,所有方法全表加 synchronized |
Collections.synchronizedMap() |
同 Hashtable,全表加锁 |
ConcurrentHashMap |
✅ 线程安全 + 高性能,分段锁 / CAS 实现 |
JDK 7 vs JDK 8 核心区别(面试必问)
| 对比维度 | JDK 7 | JDK 8 |
|---|---|---|
| 数据结构 | Segment 分段锁 + HashEntry | Node + 数组 + 链表 + 红黑树 |
| 并发粒度 | Segment 级别(默认 16 个) | Node 桶级别(更细粒度) |
| 锁实现 | ReentrantLock(继承锁) | synchronized + CAS(优化后的锁) |
| 扩容机制 | 每个 Segment 内扩容 | 多线程协助扩容 |
| 查询时间复杂度 | O(log n) 链表遍历 | O(log n) → O(log n) 红黑树优化 |
| size() 统计 | 先无锁累加 3 次,失败再加锁 | baseCount + CounterCell 数组 |
二、JDK 7 实现(分段锁)
结构
ConcurrentHashMap
┌──────────────────────────────────────┐
│ Segment[] 数组(默认 16) │
│ ┌────┐ ┌────┐ ┌────┐ │
│ │ S0 │ │ S1 │ ... │ S15│ │
│ └────┘ └────┘ └────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ HashEntry[] 数组 │
│ ┌──┐ ┌──┐ ┌──┐ │
│ │H0│ │H1│ │Hn│ │
│ └──┘ └──┘ └──┘ │
└──────────────────────────────────────┘
Segment 继承 ReentrantLock,每次操作只锁对应 Segment,其他 Segment 不受影响。
并发度
- 默认
concurrencyLevel = 16,即最多 16 个线程同时写 - 参数在初始化时确定,后续不可变
JDK 7 的问题
- Segment 数量固定,扩容只能提高 HashEntry 数组长度
- 内存占用大(每个 Segment 都是一个锁对象)
- 查询效率低(先定位 Segment,再定位 HashEntry)
- 并发度有上限(最大 64K 个 Segment 受 hash 算法限制)
三、JDK 8 实现(CAS + synchronized)
结构
ConcurrentHashMap(JDK 8)
┌──────────────────────────────────────┐
│ Node[] table(数组) │
│ ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐ │
│ │ │N0│ │N1│ │ │N2│ │ │ │ │
│ └──┴┬─┴──┴┬─┴──┴──┴┬─┴──┴──┴──┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ 链表 红黑树 链表 红黑树 │
│ (≤8) (≥8) (≤8) (≥8) │
└──────────────────────────────────────┘
额外辅助结构:
┌──────────────────────────┐
│ baseCount (基础计数) │
│ CounterCell[](辅助计数)│ ← 减少 CAS 竞争
│ sizeCtl (控制标识) │ ← -1=正在初始化,>0=扩容阈值
│ transferIndex(扩容索引)│ ← 多线程扩容分配任务
│ TreeBin (红黑树根) │ ← 链表转红黑树的容器
└──────────────────────────┘
put() 流程(核心操作,面试必问)
java
final V putVal(K key, V value, boolean onlyIfAbsent) {
// key 和 value 都不能为 null(区别于 HashMap)
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode()); // 扰动计算
int binCount = 0;
// 自旋(CAS 失败就重试)
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// ① 数组未初始化 → 初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// ② 当前桶为空 → CAS 存放(无锁)
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break;
}
// ③ 正在扩容 → 协助扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// ④ 发生 hash 冲突 → synchronized 锁住当前桶的 head
else {
V oldVal = null;
synchronized (f) { // 只锁链表/红黑树的头节点
if (tabAt(tab, i) == f) { // 双重检查
if (fh >= 0) { // 链表
binCount = 1;
for (Node<K,V> e = f;; e = e.next) {
// 找到 key 覆盖 value
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);
break;
}
}
} else if (f instanceof TreeBin) { // 红黑树
// 向红黑树插入节点
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD) // >= 8 转红黑树
treeifyBin(tab, i);
if (oldVal != null) return oldVal;
break;
}
}
}
addCount(1L, binCount); // 增加计数
return null;
}
put 流程总结
put(key, value)
│
├── key/value 为 null?→ 抛异常
│
├── ① table 未初始化?→ initTable() 初始化(CAS + volatile)
│
├── ② 桶为空?→ CAS 直接放入(无锁,性能最高)
│
├── ③ 节点是 MOVED 状态?→ helpTransfer() 协助扩容
│
├── ④ 桶非空(冲突)→ synchronized(桶头节点)
│ ├── 链表 → 遍历,找到覆盖/尾插
│ └── 红黑树 → treeNode 插入
│
└── ⑤ addCount() 更新大小 → 检查是否需要扩容
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 为负 → 红黑树或正在扩容,用 find() 查找
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 保证可见性。
size() 统计机制
java
// 不加锁的累加方式
// sumCount() 累加 baseCount + CounterCell[]
public int size() {
long n = sumCount();
return (n < 0L) ? 0 : (n > Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n;
}
// CounterCell 是什么?
// 多个线程同时 addCount() 时,CAS 更新 baseCount。
// 如果 CAS 失败(竞争激烈),不重试而是用 CounterCell 数组分散写入。
// sumCount() 时累加 baseCount + 所有 CounterCell 的值。
四、扩容机制(多线程协助)
什么时候扩容?
- 元素数量 >
sizeCtl(扩容阈值 = 0.75 × 数组长度) - 链表长度 >= 8 但数组长度 < 64(先扩容而不是转红黑树)
多线程协助扩容
JDK 8 核心优化:扩容时多个线程可以一起帮忙搬数据!
① 一个线程发现需要扩容 → 创建新的数组(2 倍)
② 将任务按步长分配(默认 stride = 16 个桶)
③ 各个线程领取自己的区间,将 oldTab[i] 转移到 newTab
④ 搬运完后,旧桶设为 ForwardingNode(MOVED 标记)
⑤ 其他线程发现 MOVED 标记 → 协助扩容(helpTransfer)
扩容时的高低位搬运
java
// 扩容时,链表会拆成低位链和高位链
// 低位链:hash & oldCap == 0 → 放在 newTab[i]
// 高位链:hash & oldCap != 0 → 放在 newTab[i + oldCap]
// 例:oldCap = 16,扩容到 32
// 元素 hash 的 index = (n-1) & hash
// 扩容前:hash & 15 → 得到原索引
// 扩容后:hash & 31 → 要么原索引,要么原索引+16
// ↓
// 用 hash & 16(oldCap)判断:
// 为 0 → 新索引不变
// 为 1 → 新索引 = 原索引 + 16
五、JDK 7/8 对比总结
| 对比项 | JDK 7 | JDK 8 |
|---|---|---|
| 并发粒度 | Segment(多锁) | Node 桶级别(更细) |
| 加锁方式 | ReentrantLock | synchronized + CAS |
| 数据结构 | Segment + HashEntry | Node + 链表 + 红黑树 |
| 扩容 | 单线程扩容 | 多线程协助扩容 |
| 查询优化 | 无(链表查询) | 链表 > 8 转红黑树 |
| key/value null | 不允许 | 不允许 |
| 迭代器 | 弱一致性 | 弱一致性 |
为什么 JDK 8 用 synchronized 而不是 ReentrantLock?
- JDK 6+ 对 synchronized 做了重量级优化(锁升级、偏向锁、轻量级锁)
- synchronized 在 JVM 层面,能根据竞争情况自动优化
- 代码更简洁,减少了内存占用
注意事项
- ConcurrentHashMap 的 key 和 value 都不能为 null(HashMap 允许 key/value 为 null)
- 原因:无法判断是 value 为 null 还是不存在,多线程场景下有歧义
- 弱一致性——迭代器不保证获取到最新的数据,但不会抛 ConcurrentModificationException
- size() 是个近似值——不是精确的,因为统计过程没有加锁
- 批量操作(putAll/clear)没有原子性保证
- 合理设置初始容量——避免频繁扩容