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 流程:

  1. 计算 hash,定位桶;桶为空时用 CAS 写入新头节点(无锁快路径)
  2. 桶非空时 synchronized 锁住头节点,在桶内链表/红黑树中插入(锁范围仅一个桶)
  3. 正在扩容(头节点为 ForwardingNode,hash = -1)则当前线程加入协助迁移
  4. 树化条件与 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 的演进主线只有一条:不断缩小同步范围,把串行部分变成并行。理解这条主线,比记住任何一个版本的字段布局都重要。