在 JDK 1.8 的 ConcurrentHashMap
中,之所以对“容器为空”和“计算位置为空”采取不同的处理方式,主要是因为 并发场景下的性能优化和并发安全保证。我们可以分开来看这两种情况:
1. 容器为空时,使用 volatile
+ CAS
初始化
-
原因:
ConcurrentHashMap
采用 懒加载,并不会在构造时就初始化所有桶(Node<K, V>[] table
)。 -
实现:当第一次插入元素时,会先判断
table
是否为空:
if (tab == null || (n = tab.length) == 0) tab = initTable();
-
initTable()
方法使用 CAS(Compare-And-Swap) 操作来保证线程安全的初始化。 -
为什么用 CAS 而不是
synchronized
?- 目的是减少不必要的锁竞争,提高并发性能。
- 由于初始化操作通常只需要执行一次(典型的 双重检查锁 模式),CAS 在多数情况下不会失败,所以开销较小。
2. 计算出的位置为空时,使用 CAS 插入
- 原因:如果某个桶(即
table[index]
)位置为空,说明没有哈希冲突,我们可以直接尝试插入数据。 - 实现:使用
CAS
方式直接插入: -
if (casTabAt(tab, i, null, new Node<K, V>(hash, key, value, null))) {break; // 插入成功,退出循环 }
为什么用 CAS 而不是
synchronized
? - 因为这个位置是
null
,没有竞争,所以可以直接尝试用 无锁的 CAS 操作 插入,避免加锁的开销,提高性能。
3. 计算出的位置不为空时,使用 synchronized
-
原因:如果
table[index]
位置已经有元素了,可能会遇到 哈希冲突,需要遍历该链表或红黑树进行替换或追加。 -
实现:
- 先通过
synchronized
锁住该桶(synchronized (f)
)。 - 然后遍历这个桶:
- 如果 key 已存在,则更新 value。
- 如果 key 不存在,则添加新的节点(链表 or 红黑树)。
- 插入完成后,判断链表长度是否达到阈值(8),如果达到就转换为红黑树。
- 先通过
-
为什么用
synchronized
而不是 CAS?- CAS 只能保证单个变量的原子性,而不能保证整个链表或树结构的原子性。
- 当多个线程同时修改一个桶时,直接用
synchronized
保护整个桶的操作,避免复杂的 CAS 失败重试,提高效率。
JDK 1.8 在 ConcurrentHashMap
中通过 分阶段使用 CAS 和 synchronized,既保证了 高并发性能,又保证了 线程安全,这就是它不同情况下采用不同方式的原因。