一、JDK 1.7 vs JDK 1.8 架构对比
1.1 JDK 1.7:分段锁架构
ConcurrentHashMap (JDK 1.7)
├── Segment[0] ──→ HashEntry[] ──→ 链表
├── Segment[1] ──→ HashEntry[] ──→ 链表
├── ...
└── Segment[15] ──→ HashEntry[] ──→ 链表
↑
ReentrantLock
结构本质:数组 + 数组 + 链表(多套了一层 Segment 外壳,16个带锁的小HashMap拼成一个大Map)
通俗理解:
- Segment 就是一个小型、带锁的 HashMap
- ConcurrentHashMap 就是把 16 个 HashMap 拼成一个大 Map
- 每个 Segment 独立持有
ReentrantLock,实现分段锁
核心特点:
- 并发度:最多支持 16 个分段并行写入,同一 Segment 还是串行
- 两次取模:定位数据需要两次哈希取模
两次取模过程:
- 第一次取模:哈希值 & (Segment 数组长度 - 1) 定位 Segment 下标
- 第二次取模:哈希值 & (HashEntry 数组长度 - 1) 定位桶位置
原理:不同 Segment 的操作互不抢锁,同一 Segment 内排队上锁
1.2 JDK 1.8:CAS + synchronized 架构
ConcurrentHashMap (JDK 1.8)
└── Node[] table(单层哈希数组)
├── 桶0:链表 / 红黑树
├── 桶1:链表 / 红黑树
└── ...
结构本质:数组 + 链表 / 红黑树(去掉 Segment,简化成一层,原生 HashMap 套了一层 CAS + 桶头 synchronized 锁)
通俗理解:
- 结构完全照搬 HashMap(数组 + 链表 / 红黑树)
- 只是加了并发控制(CAS + synchronized)
- 保证多线程不乱结构、不丢数据
核心特点:
- 锁粒度更细:锁住当前哈希桶的第一个头节点,不是锁数组、不是锁整个 map,别的桶完全不影响,并发度极高
- CAS 无锁操作:尝试无锁插入,失败才加锁
- 红黑树优化:链表长度 ≥ 8 并且 数组长度 ≥ 64 才树化;节点 ≤ 6 退化成链表(7 是缓冲区间,避免频繁转换)
原理:CAS 快速路径 + synchronized 兜底,读操作几乎无锁
为什么放弃 ReentrantLock 改用 synchronized?
JDK 1.6 后 synchronized 做了锁升级优化:
┌─────────────────────────────────────────────────────┐
│ 偏向锁 → 轻量级锁 → 重量级锁 │
│ ↓ ↓ ↓ │
│ 无竞争 少量竞争 重度竞争 │
└─────────────────────────────────────────────────────┘
| 对比维度 | ReentrantLock | synchronized |
|---|---|---|
| 锁升级 | 无 | 支持(偏向→轻量→重量) |
| 优化空间 | API层,难优化 | JVM原生,可逃逸分析、锁消除 |
| 代码复杂度 | 需手动加解锁 | 自动加解锁,更简洁 |
| 性能 | 较好 | JDK1.6+ 性能相当甚至更优 |
结论:锁粒度降到桶头节点后,用 synchronized 更轻量、更简洁,JVM 还能做更多优化
1.3 架构差异对比表
| 维度 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 结构层次 | 双层(Segment + HashEntry) | 单层(Node[]) |
| 锁机制 | ReentrantLock 分段锁 | CAS + synchronized 桶头锁 |
| 并发度 | 固定16 | 理论上等于桶数 |
| 扩容方式 | 各 Segment 独立扩容 | 全局渐进式扩容 |
| 数据结构 | 链表 | 链表 + 红黑树 |
| 内存开销 | 较高(Segment 开销) | 较低 |
二、核心区别:同桶不同 Key 的并发写入
2.1 HashMap 的问题(JDK 1.8)
// 多线程往同一个桶写入不同 key
// 问题:无锁争抢,后线程覆盖前线程的节点
for (int i = 0; i < 10; i++) {
new Thread(() -> {
map.put(Thread.currentThread().getId(), "value");
}).start();
}
// 结果:部分 key 丢失,数据错乱
问题本质:
- 尾插法虽解决了链表成环,但未解决数据覆盖
- 无锁操作导致节点引用被覆盖
- 扩容时可能出现数据丢失
2.2 ConcurrentHashMap 的保障
// 多线程往同一个桶写入不同 key
for (int i = 0; i < 10; i++) {
new Thread(() -> {
chm.put(Thread.currentThread().getId(), "value");
}).start();
}
// 结果:所有 key 都保留,结构完整
保障机制:
// 关键代码:synchronized 锁住桶头节点
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;
}
}
}
// 红黑树处理...
}
}
核心逻辑:
- 同一桶内的操作串行化
- 线程 A 先入队,线程 B 等待
- 线程 B 获得锁后,在链表尾部插入
- 不同 key 全部保留,不会互相覆盖
三、关键误区澄清
3.1 关于同一个 Key 的覆盖
| 场景 | HashMap | ConcurrentHashMap |
|---|---|---|
| 同 key 并发 put | 后写入覆盖前写入 | 后写入覆盖前写入 |
说明:同 key 覆盖是正常的业务行为,两个线程写同一个 key,后写入的值会覆盖前一个。若业务需要避免覆盖或实现原子更新,可使用以下原子操作方法:
// 原子操作方法
chm.putIfAbsent("key", "value"); // 不存在才插入
chm.merge("key", "value", (oldVal, newVal) -> oldVal + newVal); // 合并
chm.compute("key", (k, v) -> v == null ? 1 : v + 1); // 计算更新
四、硬性特性必记
4.1 核心特性
| 特性 | 说明 |
|---|---|
| null 值 | key、value 都不能为 null |
| 读操作 | 几乎无锁(volatile 保证可见性) |
| 写操作 | 细粒度锁(只锁当前桶) |
| 扩容 | 渐进式扩容,多线程协助迁移 |
| 结构安全 | 保证底层数据结构完整性 |
| 原子性 | 不保证业务复合操作的原子性 |
为什么 key/value 不能为 null?
HashMap 能存 null,为什么 ConcurrentHashMap 不能?
核心原因:并发环境下 get(key) 返回 null 会产生歧义,无法区分两种情况:
| 情况 | 场景 |
|---|---|
| 情况1 | key 根本不存在于 map 中 |
| 情况2 | key 存在,但对应的 value 就是 null |
示例:
// 线程 A
chm.put("key", null); // 假设允许存 null
// 线程 B
Object val = chm.get("key"); // 返回 null
// 此时无法判断:是 key 不存在?还是 value 本身就是 null?
结论:并发场景下无法通过返回值判断真实情况,容易引发业务 bug,因此 ConcurrentHashMap 直接禁止存 null
4.2 扩容机制对比
JDK 1.7:Segment独立扩容
扩容过程:
1. 单个Segment触发扩容阈值
2. 对该Segment加锁(ReentrantLock)
3. 该Segment内的数据迁移到新数组
4. 扩容期间,该Segment的读写都阻塞
特点:每个Segment独立扩容,锁粒度大,会阻塞
JDK 1.8:渐进式扩容
扩容过程:
1. 检测容量阈值 → 初始化新数组
2. 多线程分段迁移(每个线程负责一部分桶)
3. 使用 ForwardingNode 标记正在迁移的桶
4. 迁移期间:读操作可正常进行,写操作协助扩容
特点:全局统一扩容,渐进式迁移,读写不阻塞
扩容机制对比表
| 维度 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 扩容范围 | 单个Segment独立扩容 | 全局统一扩容 |
| 锁粒度 | 整个Segment锁 | 无锁 + CAS + 桶头锁 |
| 并发影响 | 扩容Segment阻塞 | 读写都不阻塞 |
| 迁移方式 | 单线程一次性迁移 | 多线程渐进式迁移 |
| 迁移期间 | Segment不可用 | 可正常读写,写协助扩容 |
优势:JDK 1.8的渐进式扩容避免了单线程扩容导致的长时间阻塞
五、与 HashMap 的核心区别
| 维度 | HashMap | ConcurrentHashMap |
|---|---|---|
| 线程安全 | ❌ 不安全 | ✅ 安全 |
| 同桶不同 key | 会覆盖丢失 | 排队写入,全部保留 |
| 锁机制 | 无锁 | CAS + synchronized |
| 扩容影响 | 单线程,阻塞 | 多线程协助,非阻塞 |
| null 值 | 支持 | 不支持 |
六、使用场景与最佳实践
6.1 适用场景
| 场景 | 是否适用 | 说明 |
|---|---|---|
| 高并发读写 | ✅ | 核心适用场景 |
| 缓存实现 | ✅ | 线程安全的缓存容器 |
| 共享状态管理 | ✅ | 多线程共享配置 |
| 计数器 | ✅ | 使用 compute/merge |
6.2 代码示例
ConcurrentHashMap<String, Integer> chm = new ConcurrentHashMap<>();
// 基本操作
chm.put("apple", 1);
Integer value = chm.get("apple");
// 原子操作(推荐)
chm.putIfAbsent("orange", 3); // 不存在才插入
chm.computeIfAbsent("grape", k -> 4); // 不存在时计算
chm.computeIfPresent("apple", (k, v) -> v + 1); // 存在时更新
chm.merge("banana", 1, Integer::sum); // 合并值
// 批量操作(弱一致性)
chm.forEach((k, v) -> System.out.println(k + ": " + v));
6.3 注意事项
-
避免 null 值:会抛出
NullPointerException - 迭代器弱一致性:迭代过程中看到的是某个时刻的快照
-
size() 非精确:高并发下返回近似值,精确计数用
mappingCount() -
复合操作需原子化:如
get + put需用compute系列方法
七、总结
核心要点
- JDK 1.8 架构革新:舍弃 Segment,采用单层哈希数组 + CAS + synchronized
- 锁粒度优化:从分段锁(最多16并发)到桶头锁(理论无限并发)
- 结构安全保障:同桶不同 key 并发写入不丢数据、不乱结构
版本对比与核心区别
| 版本 | 架构 | 并发度 | 核心特点 |
|---|---|---|---|
| JDK 1.7 | 双层结构(Segment + HashEntry) | 固定16 | 分段锁,层级笨重 |
| JDK 1.8 | 单层结构(Node[]) | 理论无限 | CAS + 桶头锁,粒度更细 |
与 HashMap 最大区别:同桶不同 key 并发写入,CHM 不丢数据不乱结构,HashMap 会丢数据
核心问题解答
ConcurrentHashMap 在 JDK 8 中如何实现线程安全?
- CAS 尝试无锁插入,失败则 synchronized 锁桶头节点
- 读操作无锁,靠 volatile 保证可见性
- 渐进式扩容,多线程协助不阻塞读写