多线程下为什么用ConcurrentHashMap而不是HashMap
起因我在定时任务的Service用了ConcurrentHashMap作为缓存,大佬说为什么非要用ConcurrentHashMap。 我们的场景需要用到ConcurrentHashMap吗?(我们的场景是一个在读一个在写) 用HashMap会导致什么问题。
当场其实我闷了 :< 事实上, 最严重的问题是,可能快速失败!
“快速失败”机制的触发条件
HashMap
的“快速失败”机制是通过一个名为 modCount
的计数器实现的。任何会修改结构的操作(如 put
, remove
, clear
)都会增加这个计数器。
迭代器(例如通过 keySet().iterator()
, values().iterator()
, entrySet().iterator()
亦或者 stream流操作时 anyMatch()
, allMatch()
, noneMatch(),findFirst()
, findAny()
)在创建时会记录当前的 modCount
值。在迭代器遍历的每个下一个元素之前,它会检查当前的 modCount
是否与之前记录的值相等。如果不相等,就立即抛出 ConcurrentModificationException
。
HashMap在
多线程环境下的问题
Java 的 HashMap
不能安全地用于多线程环境,主要是因为它不是线程安全的(not thread-safe)。如果多个线程同时访问一个 HashMap
实例,且至少有一个线程修改了 HashMap
的结构(比如 put
、remove
等操作),就可能会导致以下问题:
1. 死循环(Dead Loop)或 CPU 100%
这是 HashMap
在多线程环境下最著名的“经典”问题,主要出现在 JDK 1.7 及更早版本。
- 原因:在多线程同时进行
put
操作时,如果触发了扩容(resize),HashMap
会将旧数组中的链表重新哈希到新数组。 - 在 JDK 1.7 中,扩容时采用的是头插法(将原链表的节点逐个插入到新链表的头部),这在多线程环境下可能导致链表形成环形结构。
- 一旦链表成环,当某个线程执行
get()
操作遍历链表时,就会陷入无限循环,导致 CPU 使用率飙升至 100%。
但是JDK 1.8 改用了尾插法(保持原有顺序插入链表尾部),解决了链表成环的问题,因此在扩容时不会形成环形链表,避免了死循环。
2. 数据丢失(Data Loss)
即使在 JDK 1.8 中解决了死循环问题,HashMap
仍然不是线程安全的,可能导致数据丢失。
- 多个线程同时执行
put
操作时,由于没有同步机制,一个线程的写入可能覆盖另一个线程的写入。 - 例如:线程 A 和线程 B 同时对同一个
key
进行put
,最终可能只有一个值被保留,另一个被覆盖,导致数据不一致。
3. 数据不一致(Inconsistent Data)
- 一个线程正在遍历
HashMap
(如使用iterator
),而另一个线程修改了HashMap
的结构(如put
或remove
),这时遍历线程可能会抛出ConcurrentModificationException
(fail-fast 机制)。 - 即使没有抛出异常,读取到的数据也可能是不一致的中间状态。
4. 竞态条件(Race Condition)
HashMap
的内部状态(如size
、table
数组等)在多线程并发修改时,由于缺乏同步控制,可能导致内部状态混乱。
为什么 ConcurrentHashMap
是线程安全的
ConcurrentHashMap
是 Java 中专门为高并发场景设计的线程安全的哈希表实现, CAS + synchronized(JDK 1.8+) 的机制,实现了细粒度的并发控制,既保证了线程安全,又大大提升了并发性能。
- 使用 Node 数组 + 链表 + 红黑树 的结构(类似
HashMap
) - 对每个 桶(bucket) 使用
synchronized
锁住第一个节点(头节点)来实现并发控制 - 大量使用 CAS(Compare and Swap) 操作来无锁更新关键字段(如
size
、modCount
等
ConcurrentHashMap
如何解决上述的四个问题?
1. 死循环 / CPU 100%(Dead Loop)
- ConcurrentHashMap 的解决方案:
- 使用 尾插法 进行扩容迁移,保证链表顺序不变。
- 扩容时对每个桶加锁(
synchronized
),同一时间只有一个线程能迁移某个桶的链表。 - 使用
forwardingNode
标记正在迁移的桶,其他线程看到后会协助迁移或等待,避免并发修改。
- ✅ 结论:彻底避免了链表成环,不会出现死循环。
2. 数据丢失(Data Loss)
- 问题根源:
HashMap
多线程put
时,一个线程的写入覆盖另一个线程的写入。 - ConcurrentHashMap 的解决方案:
- 每个
put
操作会先定位到具体的桶。 - 如果桶为空,使用 CAS 操作 原子性地插入第一个节点。
- 如果桶非空,使用
synchronized
锁住头节点,串行化该桶的所有写操作。 - 保证每个
put
操作要么成功插入,要么合并(如putIfAbsent
),不会被静默覆盖。
- 每个
- ✅ 结论:所有写操作都是原子的,不会丢失数据。
3. 数据不一致(Inconsistent Data)
- 问题根源:一个线程遍历时,另一个线程修改结构,导致
ConcurrentModificationException
或读到中间状态。 - ConcurrentHashMap 的解决方案:
- 不抛出
ConcurrentModificationException
:它的迭代器是 弱一致性(weakly consistent) 的。 - 迭代器基于创建时的快照,允许其他线程并发修改。
- 你可能读不到最新的修改,但不会崩溃或进入死循环。
- 提供了
compute
、merge
、putIfAbsent
等原子性复合操作,避免“读-改-写”不一致。
- 不抛出
- ✅ 结论:虽然不是强一致性,但保证了安全遍历和原子性更新,避免了不一致导致的崩溃。
4. 竞态条件(Race Condition)
- 问题根源:多个线程同时修改共享状态(如
size
、threshold
),导致状态混乱。 - ConcurrentHashMap 的解决方案:
- 使用 CAS 操作 更新
size
、modCount
等共享变量。 - 使用
volatile
关键字保证变量的可见性。 - 对桶的修改使用
synchronized
锁,保证临界区的互斥访问。 - 使用
Unsafe
类进行底层原子操作。
- 使用 CAS 操作 更新
- ✅ 结论:通过 CAS + synchronized + volatile 的组合,彻底解决了竞态条件。