• 基本概念与重要性:介绍ConcurrentHashMap的定义、作用及与类似容器的比较。
• JDK 1.7的实现原理:详细讲解分段锁机制、数据结构、读写操作流程及优缺点。
• JDK 1.8的改进:分析CAS+synchronized机制、数据结构优化、扩容策略等。
• 并发安全机制对比:对比JDK 1.7和1.8的锁粒度、性能及适用场景。
• 关键方法详解:说明put、get、size等方法的实现原理和特性。
• 高级特性与注意事项:介绍原子方法、迭代器行为、内存开销等。
• 代码示例与场景:提供基本用法和并发计数器的实现示例。
• 总结与最佳实践:对比版本差异,给出选型建议和适用场景。
接下来,我将开始详细讲解ConcurrentHashMap的原理。
🔍 ConcurrentHashMap 原理详解
1️⃣ 基本概念与重要性
ConcurrentHashMap 是 Java 并发包(java.util.concurrent)中提供的线程安全哈希表实现。它在多线程环境下替代传统的 Hashtable 和 Collections.synchronizedMap(),通过更精细的锁机制实现高并发读写,避免数据不一致和死循环等问题。
为什么需要 ConcurrentHashMap?
• HashMap 非线程安全,多线程写操作可能导致数据错乱或死循环。
• Hashtable 使用全表锁(synchronized 方法),并发性能差。
• ConcurrentHashMap 采用分段锁(JDK 1.7)或 CAS + synchronized(JDK 1.8),实现线程安全且高并发。
特点:
• 不允许 null 键或值(避免歧义)。
• 读写操作大部分无需全表锁,性能高。
• 迭代器是弱一致性(遍历时可能反映其他线程的修改,但不保证实时性)。
2️⃣ JDK 1.7 的实现原理:分段锁机制
📦 数据结构
ConcurrentHashMap 内部分为多个 Segment(默认 16 个),每个 Segment 是一个独立的哈希表(数组 + 链表),继承自 ReentrantLock。
java
// 简化代码结构
class ConcurrentHashMap<K, V> {
final Segment[] segments; // Segment 数组
static class Segment<K, V> extends ReentrantLock {
transient volatile HashEntry[] table; // 哈希桶数组
}
static class HashEntry<K, V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry next;
}
}
🔒 分段锁机制
• 写操作:根据 key 的哈希值定位到 Segment,只锁该 Segment(其他 Segment 可正常访问)。
• 读操作:无需加锁(依赖 volatile 保证可见性)。
• 优点:不同 Segment 的读写可并行,提升并发度。
• 缺点:Segment 数量固定(默认 16),并发度受限;跨段操作(如 size())需锁全表。
⚠️ 扩容机制
每个 Segment 独立扩容(阈值 = 容量 × 负载因子),扩容时只锁当前 Segment。
3️⃣ JDK 1.8 的改进:CAS + synchronized + 红黑树
📦 数据结构
改用与 HashMap 相同的数组 + 链表 + 红黑树结构:
• Node:普通链表节点(哈希值 ≥ 0)。
• TreeBin:红黑树的根节点(哈希值 = -2)。
• ForwardingNode:扩容临时节点(哈希值 = -1)。
树化条件:链表长度 ≥ 8 且数组长度 ≥ 64,否则仅扩容。
🔄 并发控制机制
-
CAS(无锁操作):
◦ 初始化数组。
◦ 插入头节点(桶为空时)。
-
synchronized(细粒度锁):
◦ 当桶非空时,锁住链表头节点(或红黑树根节点),再进行插入/更新。
📈 扩容机制(多线程协作)
• 触发条件:元素数量 > 容量 × 负载因子(默认 0.75)。
• 过程:
• 创建新数组(容量翻倍)。
• 线程按桶区间分段迁移数据(通过 ForwardingNode 标记已处理桶)。
• 迁移完成后替换旧数组。
❗️ size() 实现
使用分片计数(baseCount + CounterCell[]),避免 CAS 竞争,近似实时值。
4️⃣ JDK 1.7 vs JDK 1.8 对比
特性 JDK 1.7 JDK 1.8
数据结构 数组 + Segment + 链表 数组 + 链表 + 红黑树
锁机制 分段锁(Segment 级别) CAS + synchronized(桶级别)
并发度 受 Segment 数量限制 更高(锁粒度更细)
扩容 分段扩容 多线程协同扩容
哈希冲突优化 链表 链表 → 红黑树(O(n) → O(log n))
5️⃣ 关键方法原理
🔹 put(K key, V value)
-
计算 key 的哈希值(spread() 方法优化分布)。
-
若数组未初始化,则 CAS 初始化。
-
定位到桶:
◦ 桶为空:CAS 插入新节点。
◦ 桶非空:synchronized 锁头节点,遍历链表/树插入或更新。
-
判断是否需要扩容或树化。
🔹 get(Object key)
• 无锁操作:直接访问桶,依赖 volatile 可见性。
• 若遇到 ForwardingNode,则到新数组查找。
🔹 原子方法
• putIfAbsent():若 key 不存在则插入。
• compute():原子计算更新值。
• merge():合并值(如计数器)。
6️⃣ 高级特性与注意事项
✅ 优点
• 高并发读:读操作无锁。
• 写锁粒度细:JDK 1.8 锁单个桶。
• 原子方法丰富:支持复合操作。
❌ 缺点
• 弱一致性:迭代器不反映实时数据。
• 内存占用高:红黑树、计数器分片带来开销。
• 扩容成本:数据迁移可能暂时阻塞写操作。
⚠️ 注意事项
• 确保 key 的 hashCode() 和 equals() 正确实现。
• 合理设置初始容量和负载因子,减少扩容次数。
• 避免跨桶的复合操作导致死锁(尽管概率低)。
7️⃣ 代码示例与使用场景
🔧 基本用法
java
import java.util.concurrent.ConcurrentHashMap;
public class Example {
public static void main(String[] args) {
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// 插入
map.put("a", 1);
map.putIfAbsent("a", 2); // 不会覆盖
// 原子计算
map.compute("a", (k, v) -> v + 1); // a=2
// 合并计数器
map.merge("count", 1, Integer::sum);
// 遍历(弱一致性)
map.forEach((k, v) -> System.out.println(k + ": " + v));
}
}
🚀 高并发计数器
java
public class Counter {
private final ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
public void increment(String key) {
map.merge(key, 1, Integer::sum);
}
public int getCount(String key) {
return map.getOrDefault(key, 0);
}
}
适用场景:
• 缓存系统(如会话存储)。
• 实时计数器(API 调用统计)。
• 数据分片处理(日志分析)。
8️⃣ 小结
• JDK 1.7 通过分段锁提升并发度,但并发能力固定。
• JDK 1.8 改用 CAS + synchronized + 红黑树,锁粒度更细,性能更高,支持多线程扩容。
• 选型建议:读多写少的高并发场景首选 ConcurrentHashMap(JDK 1.8+)。
💡 提示:ConcurrentHashMap 是并发编程的核心工具,理解其机制有助于编写高效线程安全代码。
扩容机制
你可以把 ConcurrentHashMap 的扩容想象成一次 "高速公路的车道扩容施工"。
- 初始状态:一条有固定数量车道(比如 16 条)的高速公路,每条车道上跑着很多车(数据节点)。
- 交通拥堵:车越来越多(元素数量超过 容量 * 负载因子(默认0.75)),或者某条车道上的车排起了长队(单个桶的链表长度超过 8,但总车道数还小于 64),就需要扩建车道了。
- 扩建目标:目标是新建一条宽度翻倍(容量翻倍)的新高速公路(nextTable)。
- 施工原则:不能完全封闭高速进行施工(停止所有读写操作),必须保证在扩建过程中,其他车辆(读线程和写线程)还能尽可能正常地通行或协助施工。
下面我们看看这套"施工流程"是如何高效、并发地完成的。
🧭 一、扩容触发时机
扩容不会无缘无故发生,主要在以下三种情况触发:
- 常规触发:在 put 操作后,检测到元素总数超过了 当前容量 * 负载因子 (默认是 0.75)。这是最常见的情况。
- 链表转树前的检查:当某个桶中的链表长度达到了 8 (TREEIFY_THRESHOLD),但当前数组的容量却小于 64 (MIN_TREEIFY_CAPACITY) 时,会选择先尝试通过扩容(增加桶数)来分散该链表上的节点,而不是立即将其转换为红黑树。
- 协助扩容:当有线程进行 put 或其他操作时,发现当前要操作的桶已经被一个特殊的 ForwardingNode 节点占据(fwd),这表示地图正在扩容中。此时,该线程不会傻等,而是会积极参与到扩容工作中 [citation:3]。
🏗️ 二、扩容的准备工作:状态变量与初始化
一旦需要扩容,第一个发起扩容的线程(比如执行 put 的线程)会进行准备工作:
• 设置状态 (sizeCtl):通过一个名为 sizeCtl 的 volatile 变量来协调多线程。sizeCtl 就像一个全局状态信号灯。
◦ 初始时,sizeCtl 的值是扩容阈值(正数)。
◦ 发起扩容的线程会通过 CAS 操作将其设置为一个负值(如 (rs << RESIZE_STAMP_SHIFT) + 2),告知所有其他线程:"我正在准备扩容,别重复操作了" [citation:3]。
• 创建新数组 (nextTable):发起线程会新建一个容量是原数组(table) 两倍的新数组 nextTable。
👷 三、核心过程:多线程协同数据迁移 (Transfer)
这是最精妙的部分。扩容的核心方法是 transfer(),它允许多个线程并发地迁移数据。
- 任务分配机制 (分治策略)
• 任务队列:旧数组的所有桶可以被看作一个任务队列。
• 任务指针 (transferIndex):一个名为 transferIndex 的变量指向这个任务队列的末尾(初始值是旧数组的长度)。它记录了尚未被分配迁移任务的桶的起始索引。
• 领取任务:每个想参与扩容的线程(无论是发起者还是后续协助者)会通过 CAS 操作,尝试将 transferIndex 减少一个步长 (stride)(比如每次领 16 个桶的任务),从而"认领"一段连续的桶区间(例如从索引 31 到 16)来进行迁移。
初始状态:
旧数组索引: 0, 1, 2, ..., 15, 16, 17, ..., 30, 31
↑
transferIndex = 32
线程A领取任务 (stride=16):
CAS成功,transferIndex 变为 32-16 = 16。
线程A负责迁移索引 31 到 16 的桶。
此时状态:
旧数组索引: 0, 1, 2, ..., 15, | 16, 17, ..., 30, 31 |
↑ ↑
transferIndex=16 线程A负责的范围
线程B来帮忙:
它继续通过CAS领取 transferIndex (当前为16) 之前的16个桶(索引15到0)。
这种方式确保了多个线程不会重复处理同一个桶,实现了无锁化的任务分配。
- 数据迁移细节 (针对一个桶)
对于一个线程认领的每个桶,它会进行如下操作:
-
锁定桶头节点:使用 synchronized 关键字锁定当前桶的头节点。这是为了保证迁移这个桶的过程是线程安全的,同时锁粒度非常细,只影响当前这个桶的操作。
-
处理节点:
◦ 如果桶是空的,就直接放置一个 ForwardingNode 作为占位符。
◦ 如果是链表或树,则开始迁移。
-
巧妙的节点迁移:遍历这个桶上的所有节点(链表或树),根据每个节点 hash 值与原数组长度 n 进行一个按位与操作 (e.hash & n)。这个操作的结果只能是 0 或 n。
◦ 结果为 0:这个节点在新数组中的位置保持不变 (newIndex = i)。
◦ 结果不为 0:这个节点在新数组中的位置是原位置 + n (newIndex = i + n)。
为什么可以这样?
因为数组长度 n 永远是 2 的幂次方。扩容后新长度是 2n,所以计算索引的方式是 hash & (2n-1)。这与 hash & (n-1) 的区别就在于多了一个最高位。e.hash & n 实际上就是在检查这个多出来的最高位是 0 还是 1。
原数组长度 n = 16 (二进制 10000)
新数组长度 2n = 32 (二进制 100000)
假设一个节点的 hash = 25 (二进制...11001)
在原数组中的索引:25 & (16-1) = 25 & 15 = 9 (二进制 1001 & 1111 = 1001)
判断新位置:25 & 16 = 25 & 10000 = 10000 (不等于0 -> 非0)
在新数组中的索引:25 & (32-1) = 25 & 31 = 25 (二进制 11001 & 11111 = 11001)
而 9 + 16 = 25。结果正确。
通过这个巧妙的位运算,无需重新计算每个键的哈希值,就能快速确定节点在新表中的位置,极大提升了效率。
-
放置占位符:一个桶的所有节点都迁移完成后,会在旧数组的这个桶位置放置一个特殊的 ForwardingNode 节点。这个节点有两个重要作用:
◦ 作为标记:告知其他线程"此桶已迁移完毕,不用再处理了"。
◦ 重定向查询:如果在此期间有 get 操作访问到此桶,ForwardingNode 会将请求重定向到新数组 (nextTable) 上进行查找,保证扩容期间读操作的正确性。
🔄 四、扩容期间的并发访问
ConcurrentHashMap 的精妙之处在于扩容期间依然支持高并发访问:
• 读操作 (get):
◦ 访问到未迁移的桶:正常进行,毫无影响。
◦ 访问到已迁移的桶(即有 ForwardingNode 的桶):通过 ForwardingNode 的 find 方法直接去新数组 nextTable 上查找。因此读操作是完全无锁且不受扩容影响的。
• 写操作 (put, remove):
◦ 如果当前桶尚未被迁移,线程会尝试获取该桶的 synchronized 锁并进行操作。操作完成后,它可能会顺带协助完成当前桶的迁移。
◦ 如果发现桶已被 ForwardingNode 占据,表明扩容正在进行中,那么当前线程不会阻塞等待,而是会主动调用 helpTransfer() 方法参与协助扩容。这种"遇到扩容就帮忙"的设计,使得写线程成为了扩容的"生力军",极大地加速了整个扩容过程。
🎉 五、扩容完成
当所有桶都被迁移完毕(transferIndex 降到 0),最后一个完成任务的线程会执行收尾工作:
- 将 table 引用指向新的数组 (nextTable)。
- 计算并设置新的 sizeCtl 为 (新容量 * 负载因子),作为下一次扩容的阈值。
- 清空 nextTable 引用。
💎 总结与图解
核心思想:化整为零,协同工作。将庞大的扩容任务拆分成无数个小任务(桶),通过巧妙的变量设计 (sizeCtl, transferIndex) 协调多个线程并发完成。整个过程通过 CAS 进行协调,通过 synchronized 保证单个桶迁移的原子性,通过 ForwardingNode 保证读操作和扩容操作的并行性。