Java 集合——ConcurrentHashMap
导言
在前一篇文章中,已经讲解过了 HashMap 的实现原理,以及分析了 Java 8 新加入的优化细节。那么今天给大家讲解一下 ConcurrentHashMap 这个并发容器的实现原理,采用了怎么的机制使得它能够在并发环境中使用。
在看这篇文章前,先得了解 HashMap 的原理,可看我的这篇 HashMap 的原理介绍博文
介绍
ConcurrentHashMap 位于 Java.util.concurrent 包下,是可用于并发环境下的集合类,核心思想是分段锁,它通过分段锁来实现并发插入、删除等操作,并且能够无阻塞的进行读操作。
本文主要是从 Java 8 源码讲解其实现原理,但也会提及一下旧版本的 ConcurrentHashMap 的实现结构。
ConcurentHashMap 的结构
旧版本的结构
在旧版本中,ConcurrentHashMap 底层数据结构是采用 数组+数组+链表的形式,相当于一个把其分成一个个桶,每一个桶装着一个 HashMap 结构,具体结构如下图:
在内部有一个 segment 数组,每一个 segment 元素都是一个 HashEntry 数组(相当于一个 HashMap 结构),即划分出很多个桶,每一个桶又装了一个 HashMap,对于元素的操作,先计算在哪一个桶中,然后在桶中的结构进行操作。因此操作只需要对对应的桶进行加锁操作,通过这种分段锁机制实现了多个桶可以并发操作。
新版本的结构
新版本不再使用 segment 数组这样的结构,采用了与 HashMap 相似的结构,即数组+链表+红黑树这样的结构,分段锁的锁住对象变成数组的链表或者红黑树,首先先来看看它的结构定义:
源码实现:
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
implements ConcurrentMap<K,V>, Serializable {
transient volatile Node<K,V>[] table;
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;
}
}
}
上述是它的结构定义,从源码可以看出它与 HashMap 的结构很类似,但是唯一不同的是多处变量使用了 volatile 关键字定义,volatile 变量可以保证内存可见性以及禁止指令重排序,关于 volatile 关键字的原理可以看我的这边文章
基本操作
了解了 ConcurrentHashMap 的内部结构后,现在我们深入分析它的基本操作,看看它是如何实现并发插入元素和无阻塞读取的。
put
通过 put 操作可以添加一个键值对到集合中,前面我们已经提及到它使用的是分段锁的机制来实现并发插入,下面通过源码来说明整一个插入过程:
put 的工作流程:
1. 检查 key 和 value 是否为空,若是,则抛出 NullPointerException,结束;否则执行 2;
2. 计算 key 的 hash 值,计算方式与 HashMap 的实现一样;
3. 通过 hash 计算 index,判断当前 index 位置是否存在元素,若无元素,则采用无锁的 CAS 操作尝试插入新节点,若成功,则执行;失败,则重新执行 3;若当前位置有元素,则执行 4;
4. 判断当前是否正在扩容,若正在扩容,则加入协助扩容,扩容完毕后则回去重新执行 3;若不是正在扩容,则执行 5;
5. 锁住当前 index 位置的节点,遍历该位置的所有节点,判断是否存在相同的元素,存在则替换旧值;不存在则插入一个新的节点保存元素,然后执行 6;
6. 给容器的当前元素个数 count 加 1,若超出负载因子,则进行扩容操作 。
put 操作的源代码:
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 检查 key 和 value 是否为 null,为 null 则抛出异常
if (key == null || value == null)
throw new NullPointerException();
// 计算 key 的 hash 值,与 HashMap 计算方式一样
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f;
int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 根据 hash 计算应存放的位置,判断当前位置是否存在元素
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 使用 CAS 操作尝试更新插入节点,成功则跳出循环执行
// count + 1,否则代表已经有其他线程在该位置插入元素
// 则需要重新进入循环,以便插入到新元素的后面
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break;
}
// 若当前位置元素的 hash 值 为 MOVED(这是一个常量),标志着正在
// 进行扩容操作,则当前线程加入协助扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
// 锁住当前位置的头元素,这里使用的分段锁,它并没有锁住
// 整个 table,而是锁住了 table 中某一个位置的头元素
// 因此对于可以并发对不同位置进行插入操作
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);
if (oldVal != null)
return oldVal;
break;
}
}
}
// 执行 count + 1,这里可能会触发扩容操作
addCount(1L, binCount);
return null;
}
看完上述的源码,应该对整体的过程有一个大概的认识,现在我们逐个过程来分析。
CAS 插入头结点
从源码可知,当 table 的 index 位置无元素时,则会尝试使用 CAS 操作插入新节点作为头元素。
CAS 操作是 Java 提供的一种无锁更新操作,它有三个操作数,分别是被修改的变量地址、旧值、新值。当当前地址的值与旧值相同,则认为它没有被修改过,则使用新值替换旧值,修改成功;否则则代表已经有其他线程已经更改了变量,则修改失败。
它与传统的加锁不同,传统的加锁是一种悲观锁的实现,而 CAS 操作则是一种乐观锁的实现,它避免了加锁,从而使得线程不会因为等待锁而进入阻塞,在一定程度上提高了并发性能。
悲观锁:它认为每次修改都认为会存在并发问题,则在修改前都使用一个锁去保障此次修改过程不会出现并发问题。
乐观锁:它假定在执行过程不会存在并发问题,只有在最终提交结果的时候检查是否安全,不安全则失败重来。
分段锁
分段锁是 ConcurrentHashMap 的核心,在其内部结构中, hash 表是用数组保存,数组中每一个元素它相当于一个个小段或桶,桶内装了发生冲突的 (key,value) 对。
当进行插入动作时,ConcurrentHashMap 并不会锁住 hash 表,则是先通过 key 的 hash 计算应在的桶位置,再锁住该桶,再执行插入操作。该种方式允许并发地对不同的桶进行插入操作,但当插入的 (key,value) 对存放于同一个桶中时,还是会由于等待锁的获取而进入阻塞。所以从整体上看,当元素存取较分散时,性能还是会比 HashTable 要高得多。
扩容
扩容机制与 HashMap 的实现类似,将 table 的容量扩展为原来的两倍,再对旧表的元素重哈希到新表的位置,不过在这基础上,增加了并发扩容机制,允许多个线程同时参与扩容,使得扩容更快完成。
在看源代码前,先提几个重要变量
1. sizeCtl
- -1:表示 hash 表正在进行初始化或扩容操作
- -n:表示当前有 n-1 个线程执行扩容操作
- 0 or n:代表当前 hash 表未进行初始化或负载因子,当超过则需要扩容
2. transferIndex:表示要被重哈希的位置
3. stride:由于允许并发扩容,该数值记录一次扩容需重哈希多少长度的 hash 表,该值会根据你的 CPU 个数和表长而决定,最小为 16
工作流程:
1. 根据 CPU 个数和当前表的长度,计算一轮重哈希需要对多少长度的表进行重哈希;
2. 如果是第一个触发扩容的线程,创建新表,长度为原来的两倍;
3. 循环对旧表的元素进行重哈希,直到全部位置都重哈希完毕
3. 获取新一段需重哈希的初始 index,每一段的长度为 stride,获取后该线程就负责对 [index - stride , index] 的元素进行重哈希,由于存在并发扩容,因此获取并更新 transferIndex 需要循环使用 CAS 操作来获取
4. 对段中的每一个位置都进行重哈希,哈希过程中需要对头元素进行加锁,避免由于扩容和 put、delete 操作冲突,具体重哈希原理与 HashMap 类似。
下面来看看扩容的源代码:
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
// 此处是当 newHash 表未进行初始化,即第一个触发扩容,则执行初始化
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;
}
int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false;
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
// 计算 i 位置,i 用于记录当前线程可对那个桶的元素重哈希
// 由于存在并发扩容,因此可能存在某些桶已被重哈希
// 通过 CAS 操作来更新 transferIndex,从而保证获得当前
// 扩容进行到哪一个位置
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
// 这里是已经扩容完毕,执行最终提交
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
// 此处锁住旧表的 i 位置的头元素,对其上的元素进行重哈希
// 原理与 HashMap 类似,也会触发红黑树的退化
else {
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
if (fh >= 0) {
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
else if (f instanceof TreeBin) {
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
// 协助扩容方法
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
int rs = resizeStamp(tab.length);
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
来看两个问题,下面我们就这两个问题分析 ConcurrentHashMap 的具体实现:
1. 为什么扩容时要将 table 分成一段一段来处理?
2. 多个线程如何协调地一起进行扩容操作?
3. 其他线程如何可以知道当前正在执行扩容操作?
扩容时将 table 分成一段一段来处理
首先前面提及到 stride 用于保存重哈希时的段容量,它的数值由当前 CPU 个数和表长决定,计算公式如下:
int stride = NCPU > 1 ? (n >>> 3) / NCPU : n;
从公式可以看出,当系统的 CPU 个数为 1 个时,stride 的值为 n,即由单个线程完成整个 hash 表的重哈希过程。
有的人可能会想,为什么要这样做呢?多个线程一起扩容不是更快吗?注意,效率更高是要建立系统有多个 CPU 的情况下,在单个 CPU 的系统,一个时间点只能执行一个线程,如果让多个线程协助扩容,不仅不会提高效率,还增加了线程切换的开销,导致扩容更慢。因此只有当系统 CPU 个数多于一个时,才会将扩容过程分为多段来进行。注:段的长度最小规定为 16。
多个线程如何协调地一起进行扩容操作?
前面提过每个线程会各自负责一段的重哈希,当该段完成后再去负责下一段。
下面通过一个具体场景来分析运行过程:
当前有一个 concurrentHashMap 对象需要进行扩容,n 为 512,stride = 64,即一段为 64 长度,transferIndex 为 512。
- 线程 1 申请一段,申请成功,返回段的位置 transferIndex - 1 = 511,则此时线程 1 负责 [512-64,511] 段的重哈希,此时 transferIndex 为 448
- 线程 2 加入扩容,申请,此时线程 2 负责 [448-64, 448] 段的重哈希,此时 transferInex 为 384
- 线程 1 完成了 [448,511] 段的重哈希,想要继续再申请一段,当前 transferIndex 为 384;但此时线程 3 加入进来,它同时也申请一段,并且为它先分配了,transferIndex 变为 320。线程 1 通过 CAS 操作更新 transferIndex 失败,则重新获取 transferIndex 的最新值,再次申请。
- 3 个线程通过上述这样的协助操作,完成整个 hash 表的重哈希过程
其他线程如何可以知道当前正在执行扩容操作?
在扩容过程中,对每一个位置重哈希完毕后,会将旧表的当前位置元素使用一个 ForwardingNode 来代替该位置,其他线程当对 ConcurrentHashMap 执行操作时,探测到一个 ForwardingNode 时,则就可以知道当前集合正在进行扩容操作。是否加入扩容,则需要根据配置和当前的扩容线程数来决定,具体可参看 helpTransfer 方法。
get 操作
ConcurrentHashMap 的读操作是无阻塞的,不需要进行加锁,它与 HashMap 的实现原理类似,它仅仅利用 volatile 变量来保证内存可见性,它属于弱一致类型,即在一个时刻同时有线程调用 get(key) 和 put(key , value),读线程可能能获取到 value,也可能获取不了,这与 JMM 有关,可以通过 happen-before 关系来分析线程行为。
get 操作的源代码:
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
总结
ConcurrentHashMap 是一个 HasnMap 的并发容器版本,他实现了并发插入、并发扩容以及无阻塞读写。
插入操作它通过使用 CAS 操作和分段锁机制来实现并发插入,但当插入的元素会经常发生冲突,其效率仍然较低。
当插入元素个数超过负载因子,则会进行扩容,并且通过实现并发扩容来减少扩容的占用的时间,它将 hash 表分为多段,参与扩容的线程先申请一段,完成之后再申请另一端,通过多个线程写作来完成扩容,注意线程的个数与系统 CPU 个数有关。
读操作是无阻塞的,不需要进行加锁,它与 HashMap 的实现原理类似,它仅仅利用 volatile 变量来保证内存可见性,它属于弱一致类型,即在一个时刻同时有线程调用 get(key) 和 put(key , value),读线程可能能获取到 value,也可能获取不了,这与 JMM 有关,可以通过 happen-before 关系来分析线程行为。