Appearance
ConcurrentHashMap 原理、Segment 分段锁与 CAS+synchronized 演进
回到总览:并发与运行时底座
相关模块:synchronized / Lock / volatile · Atomic* / CAS / LongAdder
一句话定义
ConcurrentHashMap 是 Java 并发包(JUC)中高性能的并发哈希表;从 JDK 7 的 Segment 分段锁演进到 JDK 8 的 Node 数组 + CAS + synchronized 细粒度锁,实现了极高并发读写吞吐与线程安全保证。
代码索引
为什么需要
- 为什么
Hashtable或Collections.synchronizedMap()在高并发场景下性能极差,必须替换为ConcurrentHashMap?- 一句话答:
Hashtable使用一把大锁锁住整个方法与整个数组,导致所有线程无论读写哪个 Bucket 都在竞争同一把锁;而ConcurrentHashMap只锁住当前插入的 Bucket 头节点(Node),不同 Bucket 之间的写入完全并行互不干扰。
- 一句话答:
- 为什么 10 年 Android 工程师必须了解 JDK 8 的 ConcurrentHashMap 架构演进?
- 一句话答:JDK 8 废弃了重量的
Segment继承体系,改用无锁CAS处理空槽插入,仅在发生哈希冲突时对链表/红黑树首节点加轻量级synchronized锁;这种“无锁 + 细粒度锁”思想是高并发优化的典范。
- 一句话答:JDK 8 废弃了重量的
底层机制
1. JDK 7 vs JDK 8 实现架构对比
text
JDK 7: Segment 分段锁 (ReentrantLock 数组)
[Segment 0 (Lock)] ───► [HashEntry 数组]
[Segment 1 (Lock)] ───► [HashEntry 数组]
JDK 8: Node 数组 + CAS + synchronized 桶锁
[Node 0] ───► null (CAS 尝试无锁插入!)
[Node 1] ───► Node ───► Node (synchronized 锁住 Node 1 节点)
[Node 2] ───► TreeNode (红黑树) (synchronized 锁住 TreeNode 根节点)两代设计对比表
| 维度 | JDK 7 ConcurrentHashMap | JDK 8 ConcurrentHashMap |
|---|---|---|
| 加锁粒度 | Segment 分段锁 (默认 16 个 Segment) | 桶首节点 (Node / TreeNode) |
| 锁类型 | ReentrantLock | CAS + synchronized |
| 数据结构 | Segment 数组 + HashEntry 数组 + 链表 | Node 数组 + 链表 + 红黑树 |
| 并发度 | 依赖 Segment 数量 (默认 16) | 取决于 Bucket 数组大小 (可达几千) |
2. 读操作 get() 完全无锁机制
ConcurrentHashMap 的 get() 操作全程不需要加锁:
Node节点的val与next指针均声明为了volatile,保证了多线程写入的可见性。- 读线程通过
volatile读可以直接拿到最新的节点与值,无需任何锁开销。
Android / Flutter / Web / Backend 对照
| 维度 | Java / Android (JUC) | Dart / Flutter | Go 语言 |
|---|---|---|---|
| 并发 Map 方案 | ConcurrentHashMap | 无需锁 (单线程 Isolate 天然安全) | sync.Map (读写分离/读无锁) |
| 读写锁粒度 | 桶头节点级锁 | 无锁 | read 读 map (原子) + dirty 写 map (Mutex) |
常见场景
1. 使用 putIfAbsent 或 computeIfAbsent 保证复合操作原子性
java
ConcurrentHashMap<String, AtomicInteger> map = new ConcurrentHashMap<>();
// 错误示范:get 和 put 组合并非原子操作!
// if (!map.containsKey(key)) map.put(key, new AtomicInteger(1));
// 正确示范:使用 computeIfAbsent 保证线程安全的单次初始化
map.computeIfAbsent(key, k -> new AtomicInteger(0)).incrementAndGet();常见误配、事故后果与排障
1. 事故:误以为 ConcurrentHashMap.get() + put() 组合也是线程安全的
- 误配原因:代码中写
if (map.get(key) == null) { map.put(key, val); }。 - 后果:虽然
get()和put()单个方法是线程安全的,但“先查后写”两步之间存在竞态条件(Race Condition),导致多线程同时通过get() == null校验造成重复覆写! - 排障与修法:使用
putIfAbsent()或compute()复合原子方法。
对应实验
各章节内已嵌入跳转链接;此处汇总全部 Lab:
| Lab | 说明 | 源码 |
|---|---|---|
| map-concurrency-compare | HashMap / Hashtable / ConcurrentHashMap 并发读写与扩容行为对照 | MapConcurrencyCompare.java |
复习检查题
JDK 8 的
ConcurrentHashMap是如何做到读操作get()完全不需要加锁的?答:因为
ConcurrentHashMap的Node节点的属性val和指向下一个节点的指针next均被volatile关键字修饰。volatile语义保障了多线程间内存的实时可见性与有序性。当写线程在某一节点上追加新节点或修改值时,读线程能立刻通过volatile读获取最新数据,因此get()全程无需加锁。为什么 JDK 8 放弃了分段锁 (Segment),改用
synchronized锁住桶首节点?答:因为 Segment 分段锁的并发度受限于 Segment 数量(默认 16),且 Segment 继承自
ReentrantLock带来了较重的内存开销。而锁住单个 Bucket 的首节点将锁粒度缩小到了最小,且随着 JDK 6 之后synchronized获得了偏向锁、轻量级锁和自旋锁等一系列优化,其性能在低中度竞争下极其优异,搭配 CAS 操作能最大化并发吞吐。
速记
- JDK 8 演进:放弃 Segment 分段锁,改用 Node 数组 + CAS + synchronized 桶首锁。
- 读无锁写细粒:
get()靠 volatile 读实现完全无锁,写只锁冲突桶的首节点。 - 防竞态条件:单独方法安全不等于组合安全,复合操作必用
computeIfAbsent。