ConcurrentHashMap的并发优化:Java 8中红黑树与CAS的精细化控制
各位朋友,大家好!今天我们来深入探讨Java 8中ConcurrentHashMap的并发优化策略,重点分析红黑树的应用以及CAS操作的精细化控制。ConcurrentHashMap作为高并发场景下的利器,其设计精妙之处值得我们细细品味。
1. ConcurrentHashMap的演进:从Segment到Node
在Java 7及之前,ConcurrentHashMap采用分段锁(Segment)机制。整个Map被分割成多个Segment,每个Segment相当于一个小的HashMap,拥有独立的锁。这样,并发访问不同Segment的数据时,线程之间不会产生锁竞争,从而提高了并发性能。
// Java 7 ConcurrentHashMap的结构示意
class ConcurrentHashMap<K, V> {
final Segment<K,V>[] segments;
static final class Segment<K, V> extends ReentrantLock implements Serializable {
transient volatile HashEntry<K,V>[] table;
// ...
}
static final class HashEntry<K, V> {
final K key;
volatile V value;
final HashEntry<K, V> next;
// ...
}
}
然而,分段锁机制也存在一些问题:
- 锁的粒度仍然较粗: 即使将Map分成多个Segment,每个Segment内部仍然使用一把锁,当多个线程同时访问同一个Segment时,仍然会发生锁竞争。
- Segment数量的限制: Segment的数量在初始化时就固定了,无法动态调整,这限制了其在高并发场景下的扩展性。
Java 8对ConcurrentHashMap进行了重大的改进,彻底抛弃了Segment的概念,采用了更加精细化的锁机制,以及红黑树来优化哈希冲突的处理。
2. Java 8 ConcurrentHashMap的核心结构:Node + CAS + 红黑树
Java 8的ConcurrentHashMap不再基于Segment,而是直接基于Node数组:
// Java 8 ConcurrentHashMap的核心结构示意
class ConcurrentHashMap<K, V> {
transient volatile Node<K,V>[] table;
private transient volatile int sizeCtl;
// ...
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
// ...
}
}
- Node: 是存储键值对的基本单元,类似于Java 7中的
HashEntry。 注意val和next都是volatile修饰的,保证了可见性。 - table: Node数组,是
ConcurrentHashMap存储数据的核心容器。 - sizeCtl: 控制table初始化和扩容的关键变量。
3. CAS操作的精细化控制
Java 8 ConcurrentHashMap 广泛使用了CAS (Compare and Swap) 操作来保证并发安全性,而不是粗暴地使用锁。 CAS是一种无锁算法,它尝试原子地更新一个变量,只有当变量的当前值与期望值相等时,才会将变量更新为新值。
ConcurrentHashMap中使用CAS操作的地方包括:
-
初始化table: 多个线程可能同时尝试初始化table,
sizeCtl变量用于控制初始化过程,只有当一个线程成功使用CAS将sizeCtl设置为-1时,它才能执行初始化操作。private final Node<K,V>[] initTable() { Node<K,V>[] tab; int sc; while ((tab = table) == null || tab.length == 0) { if ((sc = sizeCtl) < 0) Thread.yield(); // lost initialization race; just spin else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { if ((tab = table) == null || tab.length == 0) { int n = (sc > 0) ? sc : DEFAULT_CAPACITY; @SuppressWarnings("unchecked") Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n]; table = tab = nt; sc = n - (n >>> 2); } } finally { sizeCtl = sc; } break; } } return tab; } -
添加新的Node: 当需要在table的某个位置添加新的Node时,会首先尝试使用CAS操作将该位置设置为新的Node。如果CAS操作失败,说明有其他线程已经修改了该位置,当前线程需要重新尝试。
final V putVal(K key, V value, boolean onlyIfAbsent) { // ... for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; if (tab == null || (n = tab.length) == 0) tab = initTable(); else if ((f = tabAt(tab, i = (n - 1) & h)) == null) { if (casTabAt(tab, i, null, new Node<K,V>(h, key, value, null))) // 使用CAS插入新节点 break; // no lock when adding first node } // ... } // ... } static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v) { return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v); } -
扩容:
ConcurrentHashMap会动态扩容,以保证性能。扩容过程中,需要将旧table中的数据迁移到新的table中。 这个过程也是通过CAS操作来保证并发安全的。private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) { // ... while (advance) { int nextIndex, nextBound; if ((nextIndex = nextIndex()) >= nextBound || nextIndex < 0 || sizeCtl == RESIZE_STAMP) { advance = false; } else if ((f = tabAt(tab, i = nextIndex)) == null) advance = casTabAt(tab, i, null, new ForwardingNode<K,V>(nextTab)); // 使用CAS设置ForwardingNode // ... } // ... }
CAS的优势:
- 减少锁竞争: CAS操作避免了线程阻塞,减少了锁竞争,提高了并发性能。
- 更细粒度的并发控制: CAS操作允许对单个变量进行原子更新,提供了更细粒度的并发控制。
4. 红黑树的应用:解决哈希冲突
当多个key的hash值冲突时,会在table的同一个位置形成链表。 在链表长度较短时,可以通过遍历链表来查找key。 但是,当链表长度过长时,查找效率会大大降低,时间复杂度为O(n)。
为了解决这个问题,Java 8 ConcurrentHashMap引入了红黑树。 当链表长度超过一定阈值(TREEIFY_THRESHOLD = 8)时,会将链表转换为红黑树。 红黑树是一种自平衡的二叉搜索树,其查找、插入、删除操作的时间复杂度均为O(log n)。
// 链表转换为红黑树的过程
final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
tryPresize(n << 1);
else if ((b = tabAt(tab, index)) != null && lockTable(tab, index)) {
Node<K,V> hd = null, tl = null;
for (Node<K,V> p = b; p != null; p = p.next) {
TreeNode<K,V> t = new TreeNode<K,V>(p.hash, p.key, p.val, null, null);
if ((tl = t.prev = tl) == null)
hd = t;
else
t.prev.next = t;
}
setTabAt(tab, index, new TreeBin<K,V>(hd));
unlockTable(tab, index);
}
}
}
红黑树的优势:
- 提高查找效率: 将链表转换为红黑树,可以将查找的时间复杂度从O(n)降低到O(log n)。
- 平衡性能和空间: 红黑树是一种平衡树,可以有效地避免树的退化,保证查找效率。
5. sizeCtl变量的作用
sizeCtl 是 ConcurrentHashMap 中一个非常关键的控制变量,它在不同的状态下代表不同的含义:
- 小于0: 表示table正在初始化或者扩容。
-1: 表示table正在初始化。-(1 + nThreads): 表示有nThreads个线程正在进行扩容操作。
- 等于0: 表示table还没有被初始化,默认容量为
DEFAULT_CAPACITY。 - 正数: 表示下次扩容的阈值,即当table中的元素个数达到这个阈值时,就会触发扩容。 实际上是
table.length * loadFactor。
sizeCtl变量在ConcurrentHashMap的初始化、扩容等操作中起着至关重要的作用,通过CAS操作来保证这些操作的并发安全性。
6. put操作的流程详解
为了更好地理解ConcurrentHashMap的并发优化策略,我们来详细分析一下put操作的流程:
- 计算key的hash值: 根据key的
hashCode()方法计算hash值。 - 检查table是否初始化: 如果table为null,则调用
initTable()方法进行初始化。 - 定位到table中的位置: 根据hash值计算出key在table中的索引位置。
- 判断该位置是否为空:
- 如果为空,则使用CAS操作将新的Node添加到该位置。
- 如果不为空,则需要处理哈希冲突。
- 处理哈希冲突:
- 如果该位置的Node是一个
ForwardingNode,表示table正在扩容,则当前线程需要协助扩容。 - 如果该位置的Node是一个普通的Node,则需要遍历链表,查找key是否已经存在。
- 如果key已经存在,则根据
onlyIfAbsent参数决定是否更新value。 - 如果key不存在,则将新的Node添加到链表的末尾。
- 如果链表长度超过
TREEIFY_THRESHOLD,则将链表转换为红黑树。
- 如果key已经存在,则根据
- 如果该位置的Node是一个
- 增加size: 如果成功添加了新的Node,则需要增加size,并判断是否需要扩容。
可以用表格来总结put操作的流程
| 步骤 | 条件 | 操作 |
|---|---|---|
| 1 | 计算 Key 的 Hash 值 | |
| 2 | table == null | initTable() 初始化 table |
| 3 | 定位到的 table[i] == null | CAS 插入新节点 |
| 4 | table[i] instanceof ForwardingNode | 协助扩容 helpTransfer() |
| 5 | table[i] instanceof TreeNode | 红黑树操作 putTreeVal() |
| 6 | table[i] 是普通 Node 链表,并且 Key 存在 | 根据 onlyIfAbsent 参数决定是否更新 Value |
| 7 | table[i] 是普通 Node 链表,并且 Key 不存在 | 尾插法插入新节点 |
| 8 | 插入新节点后,链表长度超过 TREEIFY_THRESHOLD(8),且 table 长度大于 MIN_TREEIFY_CAPACITY(64) |
treeifyBin() 将链表转换为红黑树 |
| 9 | addCount() 增加 Map 的 Size,并检查是否需要扩容 |
7. get操作的流程详解
get操作相对简单,流程如下:
- 计算key的hash值: 根据key的
hashCode()方法计算hash值。 - 定位到table中的位置: 根据hash值计算出key在table中的索引位置。
- 判断该位置是否为空: 如果为空,则直接返回null。
- 处理哈希冲突:
- 如果该位置的Node是一个
ForwardingNode,表示table正在扩容,则到新的table中查找。 - 如果该位置的Node是一个普通的Node,则需要遍历链表,查找key。
- 如果该位置的Node是一个
TreeNode,则在红黑树中查找key。
- 如果该位置的Node是一个
总的来说 get 流程可以概括为:
- 计算 Key 的 Hash 值
- 定位到 table 数组的索引位置
- 如果 table[i] 为空,直接返回 null
- 如果 table[i] 是 ForwardingNode,则到新的 table 中查找
- 如果 table[i] 是 TreeNode,则在红黑树中查找
- 否则,遍历链表查找
由于Node的val属性是volatile修饰的,所以get操作可以保证读取到的value是最新的。
8. 总结:精细化控制带来的高性能
Java 8 ConcurrentHashMap通过以下策略实现了高性能的并发控制:
- Node + CAS: 使用Node作为基本存储单元,并使用CAS操作来保证并发安全性,减少了锁竞争。
- 红黑树: 使用红黑树来解决哈希冲突,提高了查找效率。
- sizeCtl: 使用sizeCtl变量来控制table的初始化和扩容,保证了并发安全性。
- volatile: 使用volatile 保证了线程间的可见性。
这些精细化的控制策略使得Java 8 ConcurrentHashMap在高并发场景下具有出色的性能。
9. 思考:优化的代价与权衡
ConcurrentHashMap的优化并非没有代价。红黑树的引入增加了空间复杂度,CAS操作虽然避免了阻塞,但在竞争激烈的情况下,可能会导致CPU空转。因此,在使用ConcurrentHashMap时,需要根据实际场景进行权衡,选择合适的初始化容量和负载因子,以达到最佳的性能。
10. 最后:并发编程的艺术
ConcurrentHashMap的设计充分体现了并发编程的艺术,它巧妙地利用了CAS、volatile、红黑树等技术,实现了高效、安全的并发访问。深入理解ConcurrentHashMap的实现原理,可以帮助我们更好地掌握并发编程的技巧,编写出更加健壮、高效的并发程序。 掌握这些知识,能帮助你更好的理解并发编程。