Java集合(3)
作者:没有四次元口袋的蓝胖
日期:2026-07-02
标签:Java, 并发集合, ConcurrentHashMap
一、ConcurrentHashMap 概述
ConcurrentHashMap 是线程安全的 HashMap,在多线程环境下替代 HashMap 和 Hashtable。
核心特点:
- 线程安全(高并发下性能好)
- 不允许 key 和 value 为 null
- JDK 1.7:分段锁(Segment)
- JDK 1.8:CAS + synchronized + 红黑树
面试地位: 和 HashMap 一样是必考题,经常和 HashMap、Hashtable 对比着问,必须能说出 1.7 和 1.8 的实现区别以及线程安全的原理。
二、底层结构
2.1 JDK 1.7 底层结构 ------ 分段锁
结构: Segment 数组 + HashEntry 数组 + 链表
ConcurrentHashMap
└── Segment[] (分段数组,默认16个Segment)
├── Segment[0]
│ └── HashEntry[](每个Segment内部自己的哈希表)
│ ├── [0] → Entry → Entry
│ ├── [1] → Entry
│ └── ...
├── Segment[1]
│ └── HashEntry[]
└── ...
核心思想:分段锁
- 把整个 Map 分成 16 个段(Segment)
- 每个 Segment 有自己的锁(继承自 ReentrantLock)
- 不同 Segment 之间可以并发操作
- 同一个 Segment 内仍然是串行的
Segment 的数量叫并发度(concurrencyLevel),默认 16,可以在构造时指定。
java
// JDK 1.7 核心结构
static class Segment<K,V> extends ReentrantLock implements Serializable {
transient volatile HashEntry<K,V>[] table;
transient int count; // 元素个数
transient int modCount; // 修改次数
transient int threshold; // 扩容阈值
final float loadFactor;
}
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
}
JDK 1.7 的缺点:
- 并发度固定(默认16),且是 Segment 级别的,同一 Segment 内还是串行
- 分段数组 + 哈希表数组,两层数组,结构复杂
- 链表长度大了查询效率还是 O(n)
2.2 JDK 1.8 底层结构 ------ CAS + synchronized
结构: Node 数组 + 链表 + 红黑树(和 HashMap 一样的数据结构)
ConcurrentHashMap
└── Node[] table(哈希桶数组)
├── [0] → Node → Node(链表,长度<8)
├── [1] → null
├── [2] → TreeNode(红黑树,链表>=8且数组>=64)
├── [3] → Node
└── ...
和 JDK 1.7 的区别:
- 放弃了分段锁,锁粒度更细(桶级别)
- 引入红黑树优化长链表查询
- 用 synchronized 代替 ReentrantLock
- 数据结构和 HashMap 对齐(Node 数组 + 链表 + 红黑树)
java
// JDK 1.8 核心结构
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 保证可见性
}
// 红黑树节点
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;
}
// 树的根节点(放在桶的位置)
static final class TreeBin<K,V> extends Node<K,V> {
TreeNode<K,V> root;
volatile TreeNode<K,V> first;
volatile Thread waiter;
volatile int lockState; // 锁状态
}
为什么用 synchronized 而不是 ReentrantLock?
- JDK 1.8 对 synchronized 做了大量优化(偏向锁、轻量级锁、自适应自旋等)
- 锁粒度变细了(只锁一个桶),锁的持有时间很短
- synchronized 是 JVM 层面的,优化空间更大,性能不比 ReentrantLock 差
三、线程安全原理
3.1 JDK 1.7 ------ 分段锁机制
操作1(修改 Segment[0]) ←→ 可以并发 ←→ 操作2(修改 Segment[5])
操作1(修改 Segment[0]) ←→ 串行 ←→ 操作2(也修改 Segment[0])
put 过程:
- 根据 key 的 hash 找到对应的 Segment
- 尝试获取该 Segment 的锁(ReentrantLock)
- 获取锁成功 → 执行 put 操作(和 HashMap 类似)
- 释放锁
size() 计算:
经典的不加锁统计方式:
- 先不加锁,遍历所有 Segment,累加 count,记录 modCount
- 再统计一次,比较两次的 modCount 是否相同
- 如果相同,说明期间没有修改,直接返回结果
- 如果不同(有并发修改),重试一次
- 还不行,就给所有 Segment 都加锁,再统计
3.2 JDK 1.8 ------ CAS + synchronized
JDK 1.8 的线程安全更精细,不同场景用不同的方式:
| 场景 | 实现方式 |
|---|---|
| 空桶插入 | CAS(无锁) |
| 链表/红黑树插入 | synchronized 锁桶头节点 |
| 元素计数 | CAS + baseCount + CounterCell |
| 扩容 | 多线程协助扩容(ForwardingNode) |
3.2.1 空桶插入:CAS 无锁
当插入的位置(桶)为空时,用 CAS 直接插入,不需要加锁:
java
// casTabAt 就是用 Unsafe 的 CAS 操作
if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // CAS 成功 → 插入完成
// CAS 失败 → 说明并发插入了,循环重试
}
原理: 比较当前位置是不是 null,是就设为新节点,不是就失败重试。
3.2.2 非空桶插入:synchronized 锁桶头
当桶里已经有元素时,锁住桶头节点(链表头或红黑树根),在锁内执行插入:
java
synchronized (f) { // f 是桶头节点
if (tabAt(tab, i) == f) { // 双重检查:确认桶头没变
if (fh >= 0) { // 链表节点(hash >= 0)
// 链表插入逻辑...
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
// 找到相同 key → 覆盖
// 遍历到尾 → 尾插新节点
}
}
else if (f instanceof TreeBin) { // 红黑树节点
// 红黑树插入逻辑...
}
}
}
// 插入后检查链表长度是否需要转红黑树
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i); // 转红黑树
...
}
锁粒度: 只锁当前桶的头节点,其他桶不受影响 → 并发度 = 桶的数量
3.2.3 元素计数:baseCount + CounterCell
HashMap 直接用 size 字段计数就行,因为单线程。
ConcurrentHashMap 不行,因为多线程同时改 size 会有并发问题。
JDK 1.8 的做法(和 LongAdder 思想一样):
java
// 基础计数
private transient volatile long baseCount;
// 计数单元格数组(竞争激烈时用)
private transient volatile CounterCell[] counterCells;
计数逻辑:
- 先尝试用 CAS 修改
baseCount,成功就 +1 - 如果 CAS 失败(有竞争),就使用
CounterCell数组,每个线程在自己的格子里 +1 size()的时候,baseCount + 所有 CounterCell 的和 = 总数
核心思想:分散竞争。把一个变量分散到多个单元格,减少 CAS 冲突,用空间换时间。
size() 返回的是近似值: 因为统计过程中可能有并发的增删,所以 size() 的结果不是精确值(弱一致性)。
3.2.4 扩容:多线程协助扩容
JDK 1.8 的 ConcurrentHashMap 有一个很牛的设计:多线程一起帮忙扩容。
大致过程:
- 某个线程触发扩容,创建新数组(容量×2),设置 sizeCtl 控制状态
- 其他线程如果也在操作数据,发现正在扩容,就帮忙一起搬数据
- 每个线程搬一段(默认16个桶),搬完了再拿下一段
- 全部搬完后,替换旧数组
ForwardingNode(转移节点):
正在迁移的桶会被替换成一个 ForwardingNode(hash = -1),它指向新数组。
如果一个线程操作时发现桶头是 ForwardingNode,说明这个桶已经被搬走了,就去新数组里操作,或者帮忙扩容。
四、Put 流程(JDK 1.8)
4.1 总流程图
put(key, value)
↓
key 或 value 为 null?
└── 是 → 抛 NullPointerException(不允许 null!)
↓
计算 hash(扰动函数,和 HashMap 类似)
↓
table 为空?
├── 是 → initTable() 初始化数组(CAS 保证只初始化一次)
└── 否 → 继续
↓
定位桶下标 i = (n-1) & hash
↓
桶为空?
├── 是 → CAS 插入新节点 → 成功则结束,失败则循环重试
└── 否 → 继续
↓
桶头是 ForwardingNode(正在扩容)?
└── 是 → 帮忙扩容 → 扩容完后回到循环重试
↓
synchronized 锁住桶头节点
↓
双重检查:桶头还是原来那个吗?
└── 不是 → 释放锁,重新循环
↓
是链表节点(hash >= 0)?
├── 是 → 遍历链表
│ ├── 找到相同 key → 覆盖 value
│ └── 没找到 → 尾插新节点
└── 否(红黑树 TreeBin)→ 红黑树插入
↓
释放 synchronized 锁
↓
链表长度 >= 8?
└── 是 → 尝试转红黑树(数组<64则优先扩容)
↓
元素计数 +1(CAS + CounterCell)
↓
需要扩容吗(size > 阈值)?
└── 是 → 触发扩容(多线程协助)
↓
返回旧 value(覆盖的情况)或 null
4.2 和 HashMap put 的核心区别
| 对比项 | HashMap | ConcurrentHashMap |
|---|---|---|
| key/value 为 null | 允许 | 不允许,直接抛异常 |
| 空桶插入 | 直接赋值 | CAS 插入 |
| 非空桶插入 | 直接操作 | synchronized 锁桶头 |
| 元素计数 | size 字段直接++ | CAS + CounterCell 分散计数 |
| 扩容 | 单线程扩容 | 多线程协助扩容 |
| 线程安全 | 不安全 | 安全 |
五、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()); // 计算 hash
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 1. 桶头就是目标
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 2. hash < 0 → 特殊节点(红黑树/ForwardingNode)
else if (eh < 0)
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 操作不需要加锁!
Node.val和Node.next都是volatile的,保证可见性- 数据结构的变化对读线程也是可见的
- 扩容时通过 ForwardingNode 去新数组找
弱一致性: get 只能保证读到的是某个时刻的快照,不保证读到最新的值。如果需要强一致性,得用
Hashtable或Collections.synchronizedMap(但这俩性能差)。
六、与 Hashtable 的对比 ⭐⭐⭐
6.1 Hashtable 的实现
Hashtable 也是线程安全的 Map,但实现方式非常粗暴------所有方法都加 synchronized,相当于锁整个哈希表。
java
public synchronized V put(K key, V value) { ... }
public synchronized V get(Object key) { ... }
public synchronized int size() { ... }
public synchronized V remove(Object key) { ... }
问题:
- 所有操作共用一把锁,并发度只有 1
- 读读也互斥(两个线程同时 get 也要排队)
- 性能差,基本被淘汰
6.2 对比表
| 对比项 | ConcurrentHashMap | Hashtable | HashMap |
|---|---|---|---|
| 线程安全 | ✅ 高效(细粒度锁) | ✅ 低效(全表锁) | ❌ |
| 锁方式 | JDK7分段锁 / JDK8 CAS+synchronized | synchronized 全方法 | 无锁 |
| 锁粒度 | 桶级别 | 整张表 | - |
| 并发度 | 高(=桶的数量) | 1 | - |
| null key/value | ❌ 都不允许 | ❌ 都不允许 | ✅ 都允许 |
| 扩容 | 多线程协助 | 单线程 | 单线程 |
| size() 结果 | 弱一致(近似值) | 强一致 | 强一致 |
| 出现版本 | JDK 1.5 | JDK 1.0 | JDK 1.2 |
| 性能 | 高 | 低 | 最高 |
| 推荐使用 | ✅ 多线程推荐 | ❌ 已废弃 | 单线程用 |
6.3 为什么 ConcurrentHashMap 不允许 null?
Doug Lea 的设计决策: 在并发场景下,get 返回 null 有歧义:
- 是 key 不存在?
- 还是 value 本身就是 null?
单线程的 HashMap 可以用 containsKey() 确认,但并发环境下,get() 返回 null 和 containsKey() 之间数据可能已经被改了,无法判断。干脆不允许 null,避免二义性。
七、JDK 1.7 vs JDK 1.8 对比
| 对比项 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 数据结构 | Segment 数组 + HashEntry 数组 + 链表 | Node 数组 + 链表 + 红黑树 |
| 锁实现 | ReentrantLock(分段锁) | CAS + synchronized |
| 锁粒度 | Segment 级别(默认16段) | 桶级别(更细) |
| 并发度 | Segment 数量(默认16) | 桶的数量(动态扩容) |
| 查询效率 | O(n)(链表) | O(log n)(红黑树优化) |
| 元素计数 | 两次统计 + 全部加锁 | CAS + CounterCell |
| 扩容 | 单线程扩容 | 多线程协助扩容 |
| 复杂度 | 两层数组,复杂 | 一层数组,简洁 |
八、思维导图速览
ConcurrentHashMap
├── 概述
│ ├── 线程安全的 HashMap
│ ├── 不允许 key/value 为 null
│ └── 高并发高性能
├── 底层结构
│ ├── JDK 1.7:分段锁
│ │ ├── Segment[](继承 ReentrantLock)
│ │ ├── 每个 Segment 内部有自己的 HashEntry[]
│ │ └── 并发度 = Segment 数量(默认16)
│ └── JDK 1.8:CAS + synchronized
│ ├── Node[] + 链表 + 红黑树
│ ├── 锁粒度细化到桶
│ └── 和 HashMap 结构对齐
├── 线程安全原理(JDK 1.8)
│ ├── 空桶插入 → CAS 无锁
│ ├── 非空桶插入 → synchronized 锁桶头
│ ├── 元素计数 → CAS + CounterCell(分散竞争)
│ └── 扩容 → 多线程协助 + ForwardingNode
├── Put 流程
│ ├── null 检查 → 抛异常
│ ├── 空数组 → initTable 初始化
│ ├── 空桶 → CAS 插入
│ ├── 正在扩容 → 帮忙扩容
│ ├── 非空桶 → synchronized 锁 + 链表/红黑树插入
│ ├── 计数 +1
│ └── 超阈值 → 扩容
├── Get 流程
│ ├── 不需要加锁(volatile 保证可见性)
│ └── 弱一致性
├── vs Hashtable
│ ├── 锁粒度:细 vs 粗
│ ├── 性能:高 vs 低
│ ├── 并发度:高 vs 1
│ └── 推荐:ConcurrentHashMap ✅
└── JDK 1.7 vs 1.8
├── 数据结构:分段锁 vs CAS+synchronized+红黑树
├── 锁粒度:Segment级 vs 桶级
├── 查询:O(n) vs O(log n)
└── 扩容:单线程 vs 多线程协助
九、写在最后
学习建议
- JDK 1.8 为主,1.7 了解思路:面试问得更多的是 1.8 的实现,但 1.7 的分段锁思想也要知道
- 和 HashMap 对比着学:数据结构很像,重点记不一样的地方(CAS、synchronized、CounterCell、协助扩容)
- 理解"分散竞争"思想:LongAdder 和 ConcurrentHashMap 的 size 计数都是这个思路,很经典
- ForwardingNode 是亮点:多线程协助扩容的设计很巧妙,了解一下能给面试加分
面试高频题(附答题思路)
Q1:ConcurrentHashMap 怎么实现线程安全的?
JDK 1.8 用 CAS + synchronized。空桶用 CAS 无锁插入;非空桶用 synchronized 锁桶头节点,锁粒度很细;元素计数用 CAS + CounterCell 分散竞争;扩容是多线程协助的。
Q2:ConcurrentHashMap 和 Hashtable 的区别?
Hashtable 是全表加 synchronized,性能差;ConcurrentHashMap 锁粒度细(桶级别),性能高。Hashtable 是老古董,不推荐用。
Q3:ConcurrentHashMap 为什么不允许 null?
并发场景下 get 返回 null 有歧义,不知道是 key 不存在还是 value 为 null,而且 containsKey 判断也不可靠(并发可能被修改),所以干脆禁止。
Q4:JDK 1.7 和 1.8 的 ConcurrentHashMap 区别?
1.7 是分段锁(Segment + ReentrantLock),并发度固定;1.8 是 CAS + synchronized,锁粒度到桶级别,还引入了红黑树、多线程协助扩容等优化,性能更好。
Q5:ConcurrentHashMap 的 get 为什么不用加锁?
因为 Node 的 val 和 next 都是 volatile 的,保证了可见性。扩容时通过 ForwardingNode 去新数组找,也能正确读到数据。但 get 是弱一致性的,不保证读到最新值。