ConcurrentHashMap:线程安全的HashMap

ConcurrentHashMap:线程安全的HashMap

目录

  • [HashMap 是非线程安全的](#HashMap 是非线程安全的)
  • [ConcurrentHashMap 是什么](#ConcurrentHashMap 是什么)
  • [Java 7 的实现:分段锁](#Java 7 的实现:分段锁)
  • [Java 8 的实现:CAS + synchronized](#Java 8 的实现:CAS + synchronized)
  • [put 流程拆解](#put 流程拆解)
  • [和 Hashtable、Collections.synchronizedMap 的区别](#和 Hashtable、Collections.synchronizedMap 的区别)
  • 小结

HashMap 是非线程安全的

在聊 ConcurrentHashMap 之前,我们先搞清楚 HashMap 到底哪里不安全。

问题一:数据丢失。 put 操作不是原子性的。它先计算 hash、再找到桶位置、判断 key 是否存在然后写入。如果两个线程同时 put 到同一个桶,就可能会互相覆盖掉对方的值。

问题二:死循环(Java 7)。 Java 7 的 HashMap 在扩容时采用头插法。并发扩容时,链表的指针可能形成环形链表,之后调用 get 时就会陷入死循环。Java 8 改成了尾插法,修复了环形链表的问题,但并发写入仍然不安全。

问题三:size 不准确。 size() 返回的值可能不是当前实际的元素数量,因为并发修改过程中计数可能不一致。

这三个问题的根源是同一个:HashMap 没有对并发操作做任何保护。 要解决这个问题,最暴力的方案是给整个 Map 加一把大锁,同一时刻只允许一个线程操作。这样确实是线程安全了,但完全不能并发处理,性能堪比一根烂香蕉。由此,我们的主角ConcurrentHashMap闪亮登场。

ConcurrentHashMap 是什么

ConcurrentHashMap 是 Java 并发包(java.util.concurrent)中的线程安全 Map 实现。它的核心设计思想是:把锁的粒度缩小,不锁整个 Map,只锁正在操作的那部分数据。

ConcurrentHashMap内部把数据分成若干段(或桶),对不同段的操作互不干扰,只有对同一段的操作才需要竞争锁。这样多个线程可以同时写入不同的段,并发度取决于分段的数量

Java 7 的实现:分段锁

Java 7 的 ConcurrentHashMap 采用了 Segment 分段锁 的设计。

它的内部结构是两层哈希:第一层把数据分成 16 个 Segment(默认值),每个 Segment 本质上就是一个小的 HashMap。第二层才是真正的 key-value 存储。

写入时,先定位到具体的 Segment,然后只锁这个 Segment,其他 Segment 的操作完全不受影响。理论上最多支持 16 个线程同时写入(并发度 = Segment 数量)。

Segment 继承了 ReentrantLock,每个 Segment 就是一把独立的可重入锁。put 操作只需要锁定目标 Segment,不需要影响其他 Segment。

分段锁的方案在大多数场景下工作得很好,但它有一个问题:Segment 数量在初始化时就确定了,不能动态调整。 如果某个 Segment 的数据特别多(哈希不均匀),这个 Segment 就会成为热点,其他线程都在等这一把锁,其他 Segment 反而闲着。

Java 8 的实现:CAS + synchronized

Java 8 对 ConcurrentHashMap 做了大幅重构,废弃了 Segment 分段锁 ,改用 CAS + synchronized + Node 数组 的方案。

结构变得更简单了:直接一个 Node 数组,每个 Node 存一个 key-value 对。链表长度超过阈值(默认 8)时转成红黑树。这和 HashMap 的结构几乎一样,区别在于并发控制的方式。

为什么要抛弃分段锁?因为分段锁的粒度还是太粗。一个 Segment 内部有多个桶,锁住一个 Segment 就锁住了它下面所有的桶。Java 8 直接把锁的粒度缩小到单个桶(加锁在链表的头节点或红黑树的根节点),并发度从 16 提升到了数组长度(默认 16,可动态扩容)。

并发控制用了两种机制:

CAS(Compare And Swap): 用于无竞争的情况。线程先尝试用 CAS 直接写入,如果成功就结束了,不需要加锁。CAS 是 CPU 级别的原子指令,没有系统调用的开销,比加锁快得多。

synchronized: CAS 失败时(说明有竞争),退化为对桶头节点加 synchronized 锁。synchronized 在 Java 6 之后做了大量优化(偏向锁、轻量级锁、自旋锁),性能已经非常好了。

put 流程拆解

Java 8 ConcurrentHashMap 的 put 操作整个流程可以分成三种情况:

情况一:桶为空。 说明没有竞争,直接用 CAS 写入新节点。不加锁,开销最小。

情况二:桶正在扩容。 当前线程帮助一起扩容,加快扩容速度。

情况三:桶不为空。 说明有竞争(其他线程已经在这个桶写入了数据),退化为对桶头节点加 synchronized 锁,然后在锁内做链表插入或红黑树插入。

绝大多数情况是情况一或情况二,CAS 一把就搞定了,根本不需要加锁。 只有在真正有竞争时才退化为 synchronized。这就是 Java 8 方案的精妙之处:乐观锁优先,悲观锁兜底。

和 Hashtable、Collections.synchronizedMap 的区别

面试常问:ConcurrentHashMap 和 Hashtable 有什么区别?

维度 Hashtable Collections.synchronizedMap ConcurrentHashMap
锁的粒度 整个表一把锁 整个表一把锁 桶级别锁(Java 8)
并发度 1(串行) 1(串行) 等于桶数量
null key/value 不允许 允许 不允许
迭代器一致性 强一致 强一致 弱一致
性能

Hashtable 的问题在于锁太粗。它的 putgetsize 等方法全部加了 synchronized,同一时刻只有一个线程能操作 Map。线程 A 在 put 的时候,线程 B 的 get 也得排队等着。在读多写少的场景下,这种全表锁性能太低。

Collections.synchronizedMap 本质和 Hashtable 一样,只是换了一种写法。它用一个装饰器包装了传入的 Map,所有方法都加了 synchronized,锁的是同一个互斥对象。并发度同样为 1。

ConcurrentHashMap 的优势在于细粒度锁。读操作完全无锁,写操作只锁目标桶,不同桶的写入互不干扰。在 16 个桶的默认配置下,理论上最多支持 16 个线程同时写入。

小结

ConcurrentHashMap 的核心设计思想是缩小锁的粒度,让无竞争的操作不加锁 。Java 7 用分段锁把数据分成 16 段,并发度从 1 提升到 16。Java 8 更进一步,废弃分段锁,改用 CAS + synchronized 的组合策略:无竞争时 CAS 一把搞定,有竞争时只锁单个桶,并发度提升到桶数组的长度。读操作通过 volatile 保证可见性,完全无锁;每一个设计决策都在回答同一个问题:在保证线程安全的前提下,怎么让并发性能尽可能接近无锁。