深入理解 Java 中的 ConcurrentHashMap:为什么它是线程安全的?
在 Java 并发编程中,我们经常会遇到需要多个线程同时访问和修改共享数据的情况。普通的 HashMap 在并发场景下并不安全,可能导致数据丢失、死循环等问题。为了解决这个问题,JDK 提供了 ConcurrentHashMap。那么问题来了:为什么 ConcurrentHashMap 是线程安全的?它内部到底用了什么机制?
本文将详细解析 ConcurrentHashMap 的设计原理,并通过代码示例来说明它的线程安全性。
1. HashMap 为什么线程不安全?
我们先回顾一下 HashMap 的问题。
- 在多线程同时
put的时候,可能导致 数据覆盖; - 在扩容(resize)过程中,可能会因为链表迁移出现 环形链表,导致 CPU 占满;
- 没有任何同步措施,多个线程修改同一个桶时,数据状态完全不可控。
示例(线程不安全的场景):
import java.util.HashMap;
public class HashMapUnsafeDemo {
static HashMap<Integer, String> map = new HashMap<>();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
map.put(i, "t1-" + i);
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
map.put(i, "t2-" + i);
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("map.size=" + map.size()); // 可能小于 2000
}
}
运行结果:map.size 可能小于 2000,因为数据覆盖或丢失。
2. ConcurrentHashMap 为什么线程安全?
ConcurrentHashMap 的核心思路是 分而治之 + CAS + volatile。
JDK 7 的实现
- 使用 分段锁(Segment),每个 Segment 内部用
ReentrantLock控制; - 多个线程只要操作的 key 落在不同 Segment 上,就能并行执行,减少锁竞争。
JDK 8 的实现
- 放弃了 Segment,直接基于 数组 + 链表 + 红黑树;
- 使用 CAS(Compare-And-Swap) 和 synchronized 锁定桶(bin) 来实现更细粒度的并发控制;
volatile保证可见性。
关键机制
-
CAS + 自旋
插入新节点时,使用CAS方式设置,避免全表加锁。if (tabAt(tab, i) == null) { if (casTabAt(tab, i, null, new Node<>(hash, key, value, null))) break; // 插入成功 } -
桶级别 synchronized
如果一个桶里已经有节点(链表或红黑树),则用synchronized锁定桶头节点,保证同一桶的并发安全。 -
volatile 保证可见性
Node 的val和next被volatile修饰,确保修改对其他线程立即可见。
3.JDK 7:Segment 分段锁版(ReentrantLock)
1)整体结构与关键字段
- 结构:
Segment<K,V>[] segments+ 每个Segment里一张 独立的HashEntry<K,V>[] table。 Segment继承ReentrantLock,所以对单段的写可以用显式锁保护,跨段可并行。- 典型字段(精简):
volatile HashEntry<K,V>[] table:段内桶数组。volatile int count:段内键值对数量。int threshold, float loadFactor:控制段内扩容。volatile int modCount:结构性修改计数(用于 size 计算的重试)。
HashEntry(桶节点):final K key; final int hash; volatile V value; volatile HashEntry<K,V> next;value/next用volatile保证读写可见;读路径无需加锁(只读volatile)。
2)定位到 Segment 与桶
- 初始化时根据
concurrencyLevel选择段数(2 的幂,默认 16)。 - 根据 key 的
hash做高位扰动后:- 段下标:
(hash >>> segmentShift) & segmentMask - 桶下标:
indexFor(hash, table.length)
- 段下标:
3)get 流程(无锁快读)
- 计算段、取到段里的
table; - 在对应桶上顺着
next(volatile)查找相同 key; - 仅发生
volatile读,不加锁。
线程安全点:由于value/next是volatile,并发写入对读者立即可见;段内结构性修改在写锁保护下完成,发布时对读者可见。
4)put/replace/remove 流程(段锁保护)
- 进入段:先尝试 乐观读;若发现需要修改则
lock()加段锁。 put:- 拿锁→遍历桶→存在相同 key 则覆盖
value; - 不存在则头插新节点;
count++,若count > threshold触发段内扩容(rehash 整个段的table)。
- 拿锁→遍历桶→存在相同 key 则覆盖
remove/replace同理:拿锁→链表里删除/替换→count--/modCount++。
线程安全点:写入期间只有该段被独占,不影响其它段;段内读者受 volatile 与锁释放的 happens-before 保障。
5)扩容(段内 rehash)
- 仅扩容该段的 table,迁移链表到新桶数组;
- 其他段不受影响(扩展性的一部分),但段数固定(见缺点)。
6)size/isEmpty 的一致性策略
size()会遍历所有段累计count。为保证近似一致性:- 先尝试两次不加锁的累加,并检查各段的
modCount是否变化; - 若变化(说明有并发写),则退化为对每段加锁后再统计一次。
- 先尝试两次不加锁的累加,并检查各段的
- 这就是“弱一致”的体现:追求高性能,不保证强一致大小,但提供“足够准确或加锁重试”的策略。
7)优缺点
- 优点:锁分散(最多到段级),读无锁,写并行性好于全表锁。
- 缺点:段数在构造时固定,扩展性一般;段内仍是链表结构,高冲突时退化明显;扩容是段级串行。
4.JDK 8:无 Segment,CAS + synchronized + 红黑树
JDK 8 直接用一张表 Node<K,V>[] table,抛弃 Segment。并发控制更细,配合树化降低高冲突退化。
1)核心结构与关键类
Node<K,V>:final int hash; final K key; volatile V val; volatile Node<K,V> next;TreeNode<K,V>:红黑树节点(父/子/颜色等)。TreeBin:桶为红黑树时的包装,内部维护 root、读写控制位等。- 关键控制字段:
volatile Node<K,V>[] table:主数组。volatile int sizeCtl:表初始化/扩容控制:sizeCtl < 0:正在扩容(负值编码并发迁移状态)sizeCtl = 0:尚未初始化sizeCtl > 0:下一次触发扩容的阈值或初始容量提示
- 计数器:
baseCount + CounterCell[](分片计数,减少热点),mappingCount()/size()依赖它们求和。
2)并发控制三板斧(如何协同)
(1)CAS + 自旋(无锁开路)
- 表初始化:用 CAS 抢占
sizeCtl完成一次性初始化。 - 首个节点插入:目标桶为
null时,CAS 直接把新Node放进去,无锁完成写入。 - CAS 失败 → 说明有竞争 → 短自旋 或进入下一步。
(2)synchronized 桶级别锁(细粒度互斥)
- 桶非空(链表/树)时,用
synchronized(f)锁住桶头节点f:- 在该桶内完成插入、更新、删除、链表到树的转换等;
- 只互斥该桶,其它桶并发互不干扰。
- 选择
synchronized的原因:- JIT 对轻量级锁/偏向锁/锁消除优化成熟;
- 内存占用更低,避免每桶显式锁对象。
(3)volatile 可见性(读路径无锁)
Node.val/next、table等关键引用volatile;get()全程无锁:只读volatile即可看到最新发布的结构;- 对树桶,
TreeBin.find也能在读模式下无锁遍历(必要时会做短暂协助/退化)。
三者协作:“能 CAS 就 CAS;CAS 不成,锁到桶;所有结构发布靠 volatile”。
3)典型操作流程
A. get(完全无锁)
- 取表
table、算桶下标,读桶头f; f == null→ 返回null;f.hash < 0→ 特殊节点:- TreeBin:树查找;
- ForwardingNode:说明在扩容迁移,按新表继续找;
- 普通节点:链表遍历
next(volatile)匹配key返回val。
线程安全点:所有读只依赖 volatile 的可见性与单向发布,且迁移时采用 ForwardingNode 保证读者能“跟着读”。
B. put/putIfAbsent
- 表未初始化 → CAS 初始化(sizeCtl 控制);
- 定位桶:
- 桶为
null→ CAS 头插成功即结束; - 桶非空 →
synchronized(f):- 链表:遍历存在则覆盖,不存在尾插;长度达到阈值树化;
- 树桶(TreeBin):按红黑树规则插入/变色/旋转;
- 桶为
- 完成后 分片计数器 增加,用于
size()。
树化触发条件:
- 常量:
TREEIFY_THRESHOLD = 8,UNTREEIFY_THRESHOLD = 6,MIN_TREEIFY_CAPACITY = 64 - 当桶中节点数 ≥ 8 且表容量 ≥ 64 才树化,否则优先扩容来分摊冲突(比过早树化更划算)。
C. remove/replace
- 桶非空时
synchronized(f),从链表/树中删除或替换; - 可能触发 反树化(桶中元素回落到 6 以下)。
D. 扩容(并行迁移,性能关键)
- 触发:计数器超过阈值
sizeCtl; - 过程:
- 将
sizeCtl设为负值,编码“正在扩容”和并行参与者数量; - 设置
transferIndex,把桶区间切成步长(stride),允许多个线程并行迁移各自的区间; - 迁移一个桶后,用
ForwardingNode替换原槽:指向新表,告诉读者/后续写者“去新表找/写”; - 全部分段迁移完成后,将
table指向新表,恢复sizeCtl。
- 将
- 读/写在迁移期的表现:
get碰到ForwardingNode会跳到新表继续;put也会协助迁移或直接对新表写,无全表停顿。
线程安全点:通过 ForwardingNode + sizeCtl/transferIndex 的协同,保证“迁移可并行,读写不中断,结构发布有序”。
4)计算类方法的原子性
computeIfAbsent(key, fn):仅在“确实不存在”时在桶锁内调用fn并插入;多线程并发只会一个成功执行插入(但fn可能被多线程并发计算,只有持桶锁的能写入)。merge(key, val, remap):在桶锁内拿旧值、合并并回写,一次互斥完成。putIfAbsent:不存在才插入,存在不覆盖;读冲突时会在桶锁内二次确认,避免竞态。
5)计数与 size()
- 不是简单的
AtomicLong,而是baseCount + CounterCell[](分片累加,类似LongAdder):- 减少热点争用;
size()/mappingCount()对它们求和(弱一致,足够快)。
6)为什么 JDK 8 在读多写少更快?
- 读:全程无锁,只有
volatile成本; - 写:乐观 CAS 优先,失败才落到桶级锁,锁冲突面小;
- 高冲突桶树化:O(logN) 查找、写入;
- 扩容:并行迁移,无全表 Stop-the-world。
5.内存模型与 happens-before(两代共同的“看不见却最关键”的保障)
- 发布(Publish):把新节点放入
table[i]的动作(CAS/写)在 JMM 下对后续读者可见; - 可见性:
table、Node.val/next、ForwardingNode.nextTable等是volatile,读者看到最新结构; - 互斥区释放:JDK 7 的段锁释放、JDK 8 桶锁释放后,对修改建立 happens-before,保证后续读能见到结果;
- 有序性:Unsafe/VarHandle 的有序写与
volatile读写共同确保“先构造,再发布”。
6.实战:同一 Key 只加载一次(JDK 8)
// 典型:缓存缺失时加载,避免多线程重复打 DB
ConcurrentHashMap<String, Object> cache = new ConcurrentHashMap<>();
public Object getUser(String id) {
return cache.computeIfAbsent("user:" + id, k -> loadFromDb(id));
}
- 多线程同时请求同一 key 时,可能多个线程并发执行
loadFromDb,但只有持桶锁的那个线程能把结果放入 Map;其余线程返回已放入的值。 - 若你想严格只执行一次计算(避免多次函数执行),可以:
- 方案 A:
CompletableFuture协作(值是 Future,先占坑); - 方案 B:值用
LongAdder/AtomicReference等免重复开销的容器。
- 方案 A:
7.实战:并发计数器(JDK 8)
ConcurrentHashMap<String, LongAdder> counter = new ConcurrentHashMap<>();
public void inc(String key) {
counter.computeIfAbsent(key, k -> new LongAdder()).increment();
}
public long get(String key) {
LongAdder adder = counter.get(key);
return adder == null ? 0 : adder.sum();
}
- Map 保证 桶级互斥 的结构修改;
LongAdder在高并发下比AtomicLong更抗争用;- 读路径零锁化,吞吐高。
8.易踩的坑 & 选型建议
- 不支持 null key/value
- 语义模糊(是“缺失”还是“有值为 null”)在并发下会放大问题;
- 键需“稳定”
hashCode/equals依赖的字段不要在放入后再修改,否则定位/比较失真;
- 不要在外部再包一层全局锁
- 破坏了内部的细粒度并发优化;
- 高冲突优先扩容而非强行树化
- 初始化
initialCapacity略大些,避免频繁扩容与链表过长;
- 初始化
- JDK 7 与 8 的差异
- JDK 7:选择合适
concurrencyLevel,但段数一旦确定无法改变; - JDK 8:
concurrencyLevel仅作为提示意义不大,关注初始容量即可。
- JDK 7:选择合适
5.小结对照表
| 维度 | JDK 7(Segment) | JDK 8(CAS + sync + 树) |
|---|---|---|
| 并发读 | 无锁(volatile 读) | 无锁(volatile 读) |
| 并发写 | 段锁(ReentrantLock) | CAS 头插 + 桶锁(synchronized) |
| 冲突热点 | 段内链表,易退化 | 树化为红黑树,O(logN) |
| 扩容 | 段内串行迁移 | 并行迁移(ForwardingNode + transferIndex) |
| 计数 | 段级统计 + 重试 | 分片计数器(LongAdder 风格) |
| 扩展性 | 段数固定,扩展一般 | 细粒度,整体扩展性更好 |
| 适用场景 | 老项目/固定段并发 | 读多写少/热点键/大表(更优) |
6. 常用方法的线程安全性
put(K key, V value)
- 如果桶为空,CAS 插入;
- 如果桶不为空,对该桶加锁(synchronized)后插入或更新;
- 并发下不会出现数据丢失。
get(Object key)
- 无需加锁,直接通过 volatile 可见性 保证读取安全;
- 时间复杂度 O(1)。
remove(Object key)
- 找到桶并加锁,安全移除;
- 并发下不会出现“读到已经删除的数据”。
computeIfAbsent(K key, Function mappingFunction)
- 如果 key 不存在,原子地计算并插入;
- 避免了多线程下重复计算的问题。
7. 举例说明线程安全性
我们用一个例子来演示 ConcurrentHashMap 的线程安全。
import java.util.concurrent.ConcurrentHashMap;
public class ConcurrentHashMapDemo {
static ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
map.put(i, "t1-" + i);
}
});
Thread t2 = new Thread(() -> {
for (int i = 1000; i < 2000; i++) {
map.put(i, "t2-" + i);
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("map.size=" + map.size()); // 结果一定是2000
}
}
运行结果:map.size=2000,说明所有写操作都被安全地执行,没有丢失。
8. 实际应用场景
- 缓存:多个线程同时读写缓存,不会丢失数据;
- 统计计数器:可结合
compute或merge方法进行原子更新; - 消息系统:存储连接会话,支持高并发访问。
例如:并发计数器
ConcurrentHashMap<String, Integer> counter = new ConcurrentHashMap<>();
public void increment(String key) {
counter.merge(key, 1, Integer::sum);
}
merge 方法保证并发更新时不会丢失数据。
9. 总结
HashMap在并发场景下不安全,可能数据丢失或死循环;ConcurrentHashMap通过 CAS、自旋、synchronized(桶级别锁)、volatile 可见性 实现线程安全;- 常用方法如
put、get、remove都是线程安全的; - 在高并发场景下,
ConcurrentHashMap是Hashtable和Collections.synchronizedMap()的更优替代。
更多推荐

所有评论(0)