ConcurrentHashMap的极致优化:Java 8中红黑树与CAS机制的并发控制
大家好,今天我们来深入探讨Java 8中ConcurrentHashMap的实现细节,特别是它如何利用红黑树和CAS(Compare-and-Swap)机制实现高效的并发控制。ConcurrentHashMap是Java并发包中最重要的类之一,理解其内部原理对于编写高性能的并发程序至关重要。
1. ConcurrentHashMap的设计目标与演进
ConcurrentHashMap的设计目标是在高并发环境下提供线程安全的哈希表操作,同时尽量减少锁的竞争,提高整体吞吐量。 在Java 5引入之前,Hashtable是Java中唯一线程安全的哈希表实现,但它的同步机制简单粗暴:直接对整个哈希表进行加锁。这意味着任何时候只能有一个线程访问Hashtable,并发性能非常差。
Java 5引入了ConcurrentHashMap,它采用分段锁(Segment Locking)的方式,将整个哈希表分成多个段(Segment),每个段拥有独立的锁。这样,不同段的数据可以被不同的线程并发访问,大大提高了并发性能。但是,这种分段锁的方式仍然存在一些问题,例如,当所有线程都访问同一个段时,仍然会发生锁竞争。
Java 8对ConcurrentHashMap进行了彻底的改进,摒弃了分段锁的设计,采用了更细粒度的并发控制机制,主要包括:
- CAS(Compare-and-Swap)机制: 用于对哈希表中的节点进行原子更新,避免使用锁。
- 红黑树: 当哈希冲突严重时,将链表转换为红黑树,提高查找效率。
- synchronized 关键字: 用于对哈希表中的桶(Bucket)进行同步,但锁的粒度更小,且使用了锁消除和锁粗化等优化技术。
2. ConcurrentHashMap的数据结构
ConcurrentHashMap 的核心数据结构是一个Node数组,称为table。 每个数组元素都是一个桶(Bucket),可以存储一个或多个Node对象。
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(int hash, K key, V val, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.val = val;
this.next = next;
}
// ... 省略其他方法
}
hash: key的哈希值。key: 键。val: 值,使用volatile修饰,保证可见性。next: 指向下一个Node的引用,用于解决哈希冲突,构成链表。
当链表长度超过一定阈值(TREEIFY_THRESHOLD = 8)时,链表会被转换为红黑树。 红黑树节点定义如下:
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next,
TreeNode<K,V> parent) {
super(hash, key, val, next);
this.parent = parent;
}
// ... 省略其他方法
}
table数组的大小可以在构造函数中指定,默认为16。 ConcurrentHashMap会根据负载因子(默认为0.75)动态扩容。
3. CAS机制的应用
CAS是一种原子操作,它包含三个操作数:内存地址V、预期值A和新值B。 CAS操作会将内存地址V中的值与预期值A进行比较,如果相等,则将内存地址V中的值更新为新值B;否则,不做任何操作。 整个比较和更新操作是一个原子操作,不会被中断。
ConcurrentHashMap大量使用了CAS操作来保证线程安全,例如:
-
初始化
table数组: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) { 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; }这段代码使用CAS操作来确保只有一个线程可以初始化
table数组。sizeCtl是一个控制字段,用于控制table数组的初始化和扩容。 如果sizeCtl小于0,表示table数组正在被初始化或扩容。U.compareAndSwapInt(this, SIZECTL, sc, -1)尝试将sizeCtl的值从sc更新为-1,如果更新成功,则表示当前线程获得了初始化table数组的权利。 -
添加新节点到链表/红黑树:
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) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; // no lock when adding to empty bin } // ... } // ... } 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); }这段代码使用CAS操作来将新节点添加到
table数组的指定位置。tabAt(tab, i)用于获取table数组中索引为i的节点。 如果该位置为空,则casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))尝试将该位置的值从null更新为新节点,如果更新成功,则表示添加新节点成功。 -
移除节点:
类似于添加节点,移除节点也需要使用CAS操作来确保原子性。
通过使用CAS操作,ConcurrentHashMap可以避免使用锁,减少锁竞争,提高并发性能。
4. 红黑树的引入
当哈希冲突严重时,链表的查找效率会退化到O(n),其中n是链表的长度。 为了解决这个问题,ConcurrentHashMap在Java 8中引入了红黑树。 当链表长度超过TREEIFY_THRESHOLD = 8时,链表会被转换为红黑树。
红黑树是一种自平衡的二叉查找树,它的查找效率为O(log n),其中n是树的节点数。 红黑树的引入可以有效地提高ConcurrentHashMap在高并发和高哈希冲突情况下的性能。
链表转红黑树的过程:
treeifyBin(Node<K,V>[] tab, int index): 这个方法负责将指定桶(bin)中的链表转换为红黑树。- 检查是否需要扩容: 在转换之前,会先检查当前的
table数组大小是否小于MIN_TREEIFY_CAPACITY = 64。 如果小于,则优先进行扩容,而不是直接转换成红黑树。 这是因为扩容可以减少哈希冲突,可能是更有效的解决性能问题的方法。 - 转换为红黑树: 如果数组大小足够大,则将链表转换为
TreeNode链表,然后调用Untils.treeify(TreeNode<K,V> head)将其转换为红黑树。这个方法会将链表中的节点转换为TreeNode类型,并维护TreeNode之间的parent、left、right等关系。 - 替换桶中的链表头: 使用
setTabAt(基于Unsafe的操作) 将桶中的链表头替换为红黑树的根节点。
红黑树退化为链表:
当红黑树中的节点数量减少到一定阈值(UNTREEIFY_THRESHOLD = 6)时,红黑树会退化为链表。 这是因为红黑树的维护成本比链表高,当节点数量较少时,链表的性能可能更好。
为什么不直接使用红黑树?
考虑到以下几点:
- 空间占用: 红黑树节点需要存储额外的parent, left, right, red等信息,空间占用比链表节点大。
- 维护成本: 红黑树的插入和删除操作需要进行平衡调整,维护成本比链表高。
- 哈希分布: 理想情况下,哈希函数可以将元素均匀地分布到不同的桶中,此时链表的长度很短,查找效率很高。
因此,只有当哈希冲突严重,链表长度超过一定阈值时,才将链表转换为红黑树。
5. synchronized关键字的使用
尽管ConcurrentHashMap大量使用了CAS操作,但仍然需要使用synchronized关键字来保证线程安全。 synchronized关键字主要用于对哈希表中的桶(Bucket)进行同步。
例如,在添加新节点到链表或红黑树时,需要先获取桶的锁,然后再进行添加操作。
final V putVal(K key, V value, boolean onlyIfAbsent) {
// ...
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key || (ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key, value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
// ...
}
// ...
}
这段代码首先获取桶f的锁,然后判断tabAt(tab, i)是否仍然等于f,如果相等,则表示当前线程获取了锁,可以安全地进行添加操作。
为什么需要synchronized?
虽然CAS操作可以保证单个操作的原子性,但多个操作组合在一起就无法保证原子性。 例如,在添加新节点到链表时,需要先判断该位置是否为空,然后再添加新节点。 如果使用CAS操作来判断该位置是否为空,可能会出现ABA问题。
ABA问题是指,在CAS操作执行期间,内存地址V的值从A变成了B,然后又变回了A。 虽然CAS操作会认为内存地址V的值没有发生变化,但实际上值已经发生了变化。
使用synchronized关键字可以避免ABA问题,保证多个操作的原子性。
synchronized的优化:
Java 6及以后的版本对synchronized关键字进行了大量的优化,例如:
- 偏向锁: 当一个线程访问一个同步块时,会在对象头中记录该线程的ID。 如果下次该线程再次访问该同步块时,不需要进行锁竞争,可以直接获得锁。
- 轻量级锁: 当多个线程竞争同一个锁时,会使用轻量级锁。 轻量级锁使用CAS操作来尝试获取锁,如果获取锁失败,则会升级为重量级锁。
- 锁消除: 如果编译器可以确定一个同步块不会被多个线程访问,则会消除该同步块。
- 锁粗化: 如果多个相邻的同步块使用同一个锁,则会将这些同步块合并为一个同步块。
通过这些优化,synchronized关键字的性能得到了很大的提高,已经可以满足大多数并发场景的需求。
6. ConcurrentHashMap的扩容机制
当ConcurrentHashMap中的元素数量超过负载因子(默认为0.75)乘以table数组的长度时,ConcurrentHashMap会进行扩容。 扩容的目的是为了减少哈希冲突,提高查找效率。
扩容过程:
- 创建一个新的
table数组,其长度是原来的两倍。 - 将原来的
table数组中的元素复制到新的table数组中。 - 将
table指向新的table数组。
扩容的并发性:
ConcurrentHashMap的扩容操作是并发进行的,多个线程可以同时参与扩容。 这是通过一个特殊的节点ForwardingNode来实现的。
当一个线程发现需要进行扩容时,会创建一个ForwardingNode节点,并将其放置在table数组的第一个位置。 ForwardingNode节点包含一个指向新table数组的引用。
当其他线程访问table数组的第一个位置时,会发现该位置是一个ForwardingNode节点,然后会将自己的操作转发到新的table数组中。
迁移元素:
每个线程负责迁移一部分元素,迁移完成后,会将table数组中对应的位置设置为ForwardingNode节点。 当所有元素都迁移完成后,table数组指向新的table数组,扩容完成。
sizeCtl的作用:
sizeCtl字段用于控制table数组的初始化和扩容。 当sizeCtl小于0时,表示table数组正在被初始化或扩容。 当sizeCtl大于0时,表示table数组的下一次扩容的阈值。
7. 总结
ConcurrentHashMap在Java 8中进行了重大改进,通过引入CAS机制、红黑树和优化的synchronized关键字,实现了高效的并发控制。它采用分段锁的方式,将整个哈希表分成多个段,每个段拥有独立的锁。这种设计使得多个线程可以并发访问不同的段,从而提高了并发性能。理解这些细节对于我们编写高性能的并发程序至关重要。
8. 一些需要注意的点
- 哈希函数的选择: 好的哈希函数可以将元素均匀地分布到不同的桶中,减少哈希冲突,提高性能。
- 负载因子的设置: 负载因子越大,
table数组的利用率越高,但哈希冲突的概率也越大。 负载因子越小,哈希冲突的概率越小,但table数组的利用率也越低。 需要根据实际情况选择合适的负载因子。 - 并发级别的设置: 并发级别是指可以同时访问
ConcurrentHashMap的线程数量。 并发级别越高,并发性能越高,但内存占用也越大。 需要根据实际情况选择合适的并发级别。
9. 进一步学习的建议
- 阅读
ConcurrentHashMap的源代码,深入理解其实现细节。 - 学习Java并发包中的其他类,例如
ReentrantLock、CountDownLatch、CyclicBarrier等。 - 学习并发编程的理论知识,例如锁、原子性、可见性、happens-before关系等。
- 实践并发编程,编写高性能的并发程序。