ConcurrentHashMap 的工作原理及代码实现
ConcurrentHashMap 采用了非常精妙的”分段锁”策略,ConcurrentHashMap 的主干是个 Segment 数组。Segment 继承了 ReentrantLock,所以它就是一种可重入锁(ReentrantLock)。在 ConcurrentHashMap,一个 Segment 就是一个子哈希表,Segment 里维护了一个 HashEntry 数组,并发环境下,对于不同 Segment 的数据进行操作是不用考虑锁竞争的。
这段描述对应的是 JDK7 的实现。JDK8 已经彻底重写了这套机制——抛弃 Segment,改为 CAS + synchronized 锁桶头节点,锁粒度从”段”细化到”单个桶”。两个版本都值得理解:7 看思想,8 看工程。
JDK7:Segment 分段锁
- 结构:
Segment[],每个 Segment 内含一个HashEntry[]并继承 ReentrantLock - 并发度:默认 16 个 Segment,最多 16 个写线程互不阻塞
- 局限:锁粒度固定为段,段内冲突依然串行;size() 需要重试或加锁统计
分段锁的意义在于证明了一件事:**哈希表的并发化不需要锁整表,只需要锁”互不相干的数据分区”**。JDK8 把这个思想推到了极致——分区粒度直接等于桶。
JDK8:CAS + synchronized 锁桶头
put 流程:
- 计算 hash,定位桶;桶为空时用 CAS 写入新头节点(无锁快路径)
- 桶非空时 synchronized 锁住头节点,在桶内链表/红黑树中插入(锁范围仅一个桶)
- 正在扩容(头节点为 ForwardingNode,hash = -1)则当前线程加入协助迁移
- 树化条件与 HashMap 相同(链表 ≥8 且数组 ≥64)
为什么敢用 synchronized?JDK6 之后偏向锁/轻量级锁优化让无竞争同步的成本接近 CAS,而 synchronized 还能在竞争加剧时自动升级为重量级锁,比固定 ReentrantLock 更自适应。
size() 怎么统计
遍历所有桶计数显然不行。JDK8 用 baseCount + CounterCell[]:无竞争时 CAS 累加 baseCount,竞争失败时把增量分散到 CounterCell 数组(思想同 LongAdder),size() 时求和。这是”热点变量分片”的经典手法。
扩容:多线程协助迁移
扩容时以 stride(步长)把桶区间分配给多个线程:每个线程认领一段区间,把旧桶节点按高位 bit 拆成 lo/hi 两链搬入新表,搬完的桶置为 ForwardingNode 标记。后来者遇到标记就帮忙干活而不是等待——把扩容从阻塞操作变成了并行协作操作,这是 JDK8 最漂亮的设计之一。
与 Hashtable / synchronizedMap 对比
| 方案 | 锁粒度 | 并发读 | 现状 |
|---|---|---|---|
| Hashtable | 整表 | 阻塞 | 遗留类,勿用 |
| synchronizedMap | 整表 | 阻塞 | 包装器,吞吐差 |
| ConcurrentHashMap | 桶(JDK8) | 无锁 | 唯一推荐 |
使用注意
computeIfAbsent的映射函数里不要再操作本 map,可能死锁或递归异常- 复合操作(check-then-act)要用
putIfAbsent/compute等原子方法,自己 if-put 仍有竞态 - key/value 不允许 null:并发下无法区分”值不存在”与”值为 null”的歧义(单线程 HashMap 可以 get 二次确认)
从 Segment 到桶级锁再到协作扩容,ConcurrentHashMap 的演进主线只有一条:不断缩小同步范围,把串行部分变成并行。理解这条主线,比记住任何一个版本的字段布局都重要。











