@TOC
类前注释
- 此类包含了HashTable中所有的方法。
- 此哈希表是线程安全的,并且无需甚至是无法对整个表加锁。
- 在并发场景下,调用此类的
size
、isEmpty
、containsValue
等方法得到的结果只能反映一小段时间内的状态,因此请勿将这些方法作为程序控制的一部分。 - 当此哈希表出现了过多的哈希碰撞时,会动态扩展其容量。但是动态扩展和rehash操作总是非常耗时的,最好能在创建哈希表时就指定一个初始容量,这样能提高效率。
- 当键是可比较(Comparable)的时,此类可能会使用比较的顺序来打破性能瓶颈(help break ties)。
- 和HashTable类似,但是和HashMap不同,本哈希表不允许null值作为键或值。
类定义
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
implements ConcurrentMap<K,V>, Serializable
继承自AbstractMap
抽象类,并实现了ConcurrentMap
和Serializable
接口。看到类名很容易误会此类是由HashMap
派生而来,实际上ConcurrentMap
、HashMap
和EnumMap
等都是AbstractMap
派生而来。
overview
最初设计这个哈希表的目标是为了在并发竞争下维持可读性。第二个目标是为了在内存消耗上与HashMap保持一致甚至是优于它,并支持多线程写入。
每个哈希映射都保存在一个Node
节点中,但是,存在各种子类:TreeNode
被安排在平衡树中,而不是列表中。 TreeBins
拥有TreeNode
集合的根。 在调整大小期间,ForwardingNodes
放置在桶(bin)的顶部。
该表在第一次插入时被延迟初始化为2的幂。 表中的每个bin
通常包含一个Node列表(大多数情况下,该列表只有零个或一个Node
)。 表访问需要volatile/原子性 读,写和CASes
。使用sun.misc.Unsafe
实现。
我们将Node哈希字段的最高(符号)位用于控制目的,带有负哈希字段的节点在map方法中会经过特殊处理或忽略。
将第一个节点插入到一个空的桶中,使用CAS将其插入。其他更新操作则需要加锁。主要是对桶的第一个节点加锁,对这些锁的锁定支持依赖于内置的“synchronized
”监视器。
关键成员
static final int MOVED = -1; // hash for forwarding nodes
static final int TREEBIN = -2; // hash for roots of trees
static final int RESERVED = -3; // hash for transient reservations
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
- 当哈希值是
MOVED
时,说明当前节点是一个转发节点; - 当哈希值是
TREEBIN
时,说明这是一个红黑树的根; - 当哈希值是
RESERVED
时,说明这是一个暂时保留的节点?还是说这个字段是暂时保留的? - HASH_BITS用于正常哈希值的取模操作,防止哈希值溢出。
Unsafe机制
首先获取以下变量的内存地址,然后通过unsafe
包的native
方法实现CAS
操作,保证线程安全。
// Unsafe mechanics
private static final sun.misc.Unsafe U;
private static final long SIZECTL;
private static final long TRANSFERINDEX;
private static final long BASECOUNT;
private static final long CELLSBUSY;
private static final long CELLVALUE;
private static final long ABASE;
private static final int ASHIFT;
static {
try {
U = sun.misc.Unsafe.getUnsafe();
Class<?> k = ConcurrentHashMap.class;
SIZECTL = U.objectFieldOffset
(k.getDeclaredField("sizeCtl"));
TRANSFERINDEX = U.objectFieldOffset
(k.getDeclaredField("transferIndex"));
BASECOUNT = U.objectFieldOffset
(k.getDeclaredField("baseCount"));
CELLSBUSY = U.objectFieldOffset
(k.getDeclaredField("cellsBusy"));
Class<?> ck = CounterCell.class;
CELLVALUE = U.objectFieldOffset
(ck.getDeclaredField("value"));
Class<?> ak = Node[].class;
ABASE = U.arrayBaseOffset(ak);
int scale = U.arrayIndexScale(ak);
if ((scale & (scale - 1)) != 0)
throw new Error("data type scale not a power of two");
ASHIFT = 31 - Integer.numberOfLeadingZeros(scale);
} catch (Exception e) {
throw new Error(e);
}
}
3个核心tabAt方法
ConcurrentHashMap
在操作Node
数据中的节点时,为了保证线程安全,定义了以下三个原子操作,使用unsafe
机制实现。
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
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);
}
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
第一个方法从主内存中读数据而不是工作内存,第二个方法CAS
设置节点,第三个方法将节点的值直接更新到主内存。
put方法
public V put(K key, V value) {
return putVal(key, value, false);
}
在putVal
方法中,当键值有一者为空时,就会抛出空指针异常。
if (key == null || value == null) throw new NullPointerException();
随后用key的hashCode
计算哈希值:
int hash = spread(key.hashCode());
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;//HASH_BITS是2147483647
}
计算出哈希值后开启一个死循环:
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
...
}
首先判断哈希表是否为空,如果为空,就初始化表:
if (tab == null || (n = tab.length) == 0)
tab = initTable();
在initTable
方法中, 使用sizeCtl
中记录的大小来初始化哈希表,如果sizeCtl
小于0,就会调用yeild()
方法自旋等待。
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
如果sizeCtl
大于等于0,就CAS设置变量sizeCtl
的值,如果sizeCtl
的值和sc
的值相等(在CAS开始前并没有线程修改sizeCtl
的值,不考虑ABA问题),就将sizeCtl
设置为-1。
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
设置成功后执行try..finally
块,判断sc
是否大于0,如果大于0,说明其他线程已经设置过了sc
的值,那么就将现有的sc
赋值给n
;如果仍旧小于0,那说明sc
的值仍旧是-1,此时就将DEFAULT_CAPACITY
赋值给n
。
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
...
private static final int DEFAULT_CAPACITY = 16;
这个变量n
中存储着哈希表的初始化容量,随后创建node
数组,完成初始化。
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
有个关键点,就是sc = n - (n >>> 2)
这行代码。要解释为什么需要这行代码就需要知道sizeCtl
的详细作用:
sizeCtl
是用于控制初始化以及扩容的变量。- 当它为负值时,说明此哈希表正在经历初始化或者扩容。
-1
代表初始化,-N
代表有N-1
个线程正在进行扩容。 - 当它不为负值时,如果哈希表是空的,那么
sizeCtl
存储着初始化容量或者是一个默认值为0。在初始化完毕后,sizeCtl
中存储着下一个要调整表大小的元素计数值。
也就是说sc = n - (n >>> 2)
设置了下一个要调整表大小的元素计数值(可能为8),当表中元素计数大于等于8的时候,可能需要扩容,或者小于等于8的时候,需要缩容,具体还要看resize
的过程。
OK,initTable
方法就看到这里,回到put
方法。
如果哈希表不为空,但是哈希到的这个位置是一个空的bin
,就采用CAS设置,不加锁。
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
当哈希到的bin
位置仍旧是空的时候,就将新节点放入。
上述代码里,计算bin
在内存中的偏移量的语句是(long)i << ASHIFT) + ABASE
,其中i
是取模过后的哈希值,ABASE
是通过反射获取的数组第一个元素的偏移地址,ASHIFT
的计算比较复杂,如下所示:
int scale = U.arrayIndexScale(ak);
if ((scale & (scale - 1)) != 0)
throw new Error("data type scale not a power of two");
ASHIFT = 31 - Integer.numberOfLeadingZeros(scale);
首先通过arrayIndexScale
方法获取数组的转换因子,也就是数组中元素的增量地址(当前数组一个元素占用的字节数)。
再调用Integer.numberOfLeadingZeros
方法计算出scale
的二进制形式中,前导0的个数。
ASHIFT
就是31-前导0的个数。
最后通过(long)i << ASHIFT) + ABASE
计算出哈希值为i
的元素的内存地址。实际上通过ABASE + i * Scale
也能计算出内存地址,我看不太懂源码是如何算的,兴许只是想通过位运算来提高效率。
至此,当哈希表不为空且bin
桶为空的情况已经分析完毕。最后就是bin
桶不为空的情况了。
如果当前bin
的哈希值为MOVED
,说明当前正在发生resize
操作,当前线程需要协助完成resize
,即调用helpTransfer
方法。
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
先跳过helpTransfer
,这个方法放到后面说。
如果当前bin
的哈希值不为MOVED
,就开始对节点f
加锁(链表头节点),执行插入操作:
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
...
}
}
进入synchronized
代码块后,再次检查一下当前哈希值的节点是否变化,没变化才会继续执行操作。
如果哈希值大于等于0,说明当前节点是一个普通的链表头节点,可以执行链表的查询和插入操作。因此继续对链表遍历,找到key的位置,如果不存在,就插入到链表末尾。
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;
}
}
}
如果哈希值小于0,并且节点f
是一个树节点,那么就执行红黑树的插入操作。
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;
}
}
在上述遍历链表的过程中,会记录节点的总数,如果节点总数大于等于TREEIFY_THRESHOLD
(8),会将链表转化为红黑树。
最后调用addCount(1L, binCount);
方法,这个方法也放到后面讲。
至此,put
方法分析完毕。
addCount方法
此方法的一个作用是增加计数,如果发现哈希表太小了(占用率大),并且没有触发resize
操作,那么就调用transfer
进行扩容,如果已经在resize
了,就调用helpTransfer
帮助执行扩容。在扩容完毕后重新检查占用率,因为调整大小操作是滞后的,需要重新判断是否已经满足了扩容需求。
这个方法中用到了CounterCell
类,这个类是ConcurrentHashMap
专门用来记录元素个数的。在无线程安全的集合中,我们可以直接选择使用一个成员变量来记录元素个数,但是无法保证线程安全,例如在多线程环境下调用HashMap的size()方法不能准确得知元素个数。ConcurrentHashMap
为了保证线程安全设计了这个类。
以下内容能够帮助理解CounterCell
的作用,参考自这里:
为什么不直接for循环对当前map对象cas操作 baseCount 加 1,却要引入CounterCell数组?
因为for循环cas这种方式可以提高多线程并发效率,如果cas的是当前map对象,同一时刻还是只有一个线程能cas成功;当引入CounterCell数组,cas的是当前线程对应在数组中特定位置的元素,也就是说如果位置不冲突,n个长度的CounterCell数组是可以支持n个线程同时cas成功的。
这也是ConcurrentHashMap在JDK1.8后采用的并发思想。
ConcurrentHashMap
用一个volatile
修饰的CounterCell
数组采用分片的方法来保存元素个数。CounterCell数组的每个元素都存储一个元素计数,当需要获取元素数量时,调用sumCount
方法统计。volatile
则保证了内存可见性。
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
回到addCount方法,首先是进行并发计数。获取CounterCell
数组,如果不为空就进入if
代码块;如果为空就CAS设置BASECOUNT
的值(BASECOUNT
实际上是在未发生并发竞争的情况下元素的计数器),CAS失败了就进入if
代码块。
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
很好理解,如果CounterCell
不为空,那说明当前处于多线程环境,需要并发计数。如果CounterCell
为空,但是CAS失败了,说明当前正处于多线程环境不久,也需要并发计数。
在if
代码块中,首先将未冲突标志位设为true
。然后接连判断以下条件,任何一个条件成立,就进入fullAddCount
方法。
as == null
,说明是前面CAS失败了;(m = as.length - 1) < 0
,CounterCell
数组没有元素但不为空,说明早处于并发环境;(a = as[ThreadLocalRandom.getProbe() & m]) == null
,在as数组里用当前线程作为参数找的槽为空(如果不为空,可以直接CAS元素a
了);!(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x)))
,CAS向槽中填v
失败。
当出现以上并发问题时,就调用fullAddCount(x, uncontended)
方法,把x填入CounterCell
数组中。
这里不对fullAddCount
方法过度分析了,可以简单解释一下:
- 当出现情况1时,调用这个方法能够创建
CounterCell
数组来支持并发计数。 - 当出现情况2时,调用这个方法能够初始化
CounterCell
数组。 - 当出现情况3时,调用这个方法为当前线程创建
CounterCell
计数器,并放入CounterCell
数组中。 - 当出现情况4时,调用这个方法能够对
CounterCell
数组扩容,因为多个线程在CASCounterCell
元素时,产生了哈希碰撞,所以需要扩容。
回到addCount
方法:
if (check <= 1)
return;
s = sumCount();
当check
小于等于1时,直接返回。这个check
形参是由put
方法中的binCount
传递来的,所以当check
小于等于1时,说明put
时未发生哈希碰撞,也就无需再往下执行扩容了。
接下来进入了重点环节,当上一个if
条件未成立或者check>1
时会进入以下逻辑。
当以下三个条件均成立时,才进入while
循环:
- 元素总数大于等于阈值
sizeCtl
(前文已经分析过sizeCtl的作用了,不再赘述)。 - 当前哈希表不为空。
- 表长度仍旧小于
MAXIMUM_CAPACITY
(1 << 30)
while (s >= (long)(sc = sizeCtl) &&
(tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
满足扩容的条件,开始着手扩容。首先调用resizeStamp
方法:返回标记位,以调整大小为n的表的大小。 向左移动RESIZE_STAMP_SHIFT
时必须为负。
接着判断sc
是否小于0:
-
如果小于0,检测到其他线程正在初始化,就直接
break
。否则CAS设置SIZECTL
的值,成功了就执行transfer
方法。if (sc < 0) { if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0) break; if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) transfer(tab, nt); }
-
如果大于等于0,就CAS设置
SIZECTL
的值,成功了就执行transfer
方法。else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)) transfer(tab, null);
接着来看transfer
方法的逻辑。
transfer方法
这是ConcurrentHashMap
扩容操作的核心,这个方法相当的长而且非常复杂,因为它支持多线程并发进行扩容操作,并且没有用锁。
扩容操作分为两个步骤:
- 构建一个
nextTable
,它的容量是原来的两倍,这个操作是单线程完成的。这个单线程的保证是通过RESIZE_STAMP_SHIFT
这个常量经过一次运算来保证的。 - 将原来
table
中的元素复制到nextTable
中,这里允许多线程进行操作。
方法开始给stride
变量赋值,先判断CPU的核心数,如果是单核,直接将表长度n
赋值给stride
,表示单核需要承担rehash
所有桶的任务;如果是多核的,让表长度除8(为什么要除8?),然后再除核心数,让每个核心需要处理的桶一样多。如果平均分完后,每个核心分配的桶数量小于16,那么就默认为16。也就是说:
- 通过 计算CPU核心数和旧哈希表的长度得到每个核心需要负责迁移多少个桶,每个核心负责的桶数是平均的。
- 一个核心至少负责16个桶的任务。
- 许多博客将核心数和线程数弄混了,虽然这里一个核心对应一个线程,但是概念不能混淆,否则下面的多线程并发迁移就理解不了了。
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
紧接着判断新的哈希表是否已经创建,如果没有就自行创建:将新表的大小设置为原表的2倍,如果报了OOM,那么就将sizeCtl
设置为理论上的最大值Integer.MAX_VALUE
,作为下一次扩容尝试的大小(见前文对sizeCtl
的解释)。
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//报OOM了
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;
}
在继续之前,我们先来看看ConcurrenthashMap
中的一个静态内部类ForwardingNode
,这个类的实例仅存在于扩容过程。此内部类继承自Node
类,除此之外,有一个final修饰的Node
数组类型的成员变量nextTable
,表示扩容后的新的哈希表,由构造器传入参数赋值。
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
super(MOVED, null, null, null);
this.nextTable = tab;
}
这个类还有一个find
方法,这个方法的作用是:为读线程在扩容期间提供跳转到新表的入口,如果读线程在旧表中没有找到需要的节点,就到ForwardingNode
的nextTable
字段引用的新表中继续查找需要的节点。这个方法最外层是一个死循环,跳出这个循环的条件有:
- 该桶为
null
。 - 该桶不为
null
,并成功查找到了所需节点(包括以红黑树方式查找)。
否则会不断通过ForwardingNode
到新表查找节点。
首先保证传入的Object
参数k
非空,新哈希表也非空并且长度不为0,新哈希表索引h
的位置有元素存在,否则直接返回null
。前三个条件很好理解,第四个条件有这么几种情况:
- 旧表中的该桶索引位置本来就没有节点。
- 该位置扩容完成了,被迁移到了新节点中。这个情况也直接返回
null
,因为扩容完成了,不该由ForwadingNode
来参与查找。
if (k == null || tab == null || (n = tab.length) == 0 ||
(e = tabAt(tab, (n - 1) & h)) == null)
return null;
上述条件均不成立的情况下,说明可以通过ForwardingNode
查找节点。随后开启一个死循环,在这个循环体中,取出索引h
位置的节点,比较其哈希值和key
值,判断是否与传入的参数相同,如果相同就返回该节点;如果不相同,就判断这个节点的哈希值是否为负数,前面提到哈希值为负数有三种情况,分别代表该节点为正在扩容的节点(ForwardingNode
)、是红黑树节点(TreeBin
)以及保留字段。如果是红黑树,就在红黑树中查找返回,如果是ForwardingNode
,就将该节点指向的nextTable
取出,赋值给tab
变量,重新循环在nextTable
中查找节点。
for (;;) {
int eh; K ek;
if ((eh = e.hash) == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
if (eh < 0) {
if (e instanceof ForwardingNode) {
tab = ((ForwardingNode<K,V>)e).nextTable;
continue outer;//跳回外层循环
}
else
return e.find(h, k);
}
if ((e = e.next) == null)
return null;
}
回到transfer
方法,创建了一个ForwardingNode
节点后,这里有两个标志为:
advance
代表是否可以继续处理下一个桶,如果为true,表示当前桶已经处理完毕,可以处理下一个了。finishing
用于控制扩容何时结束,该标识的另一个用途:最后一个扩容线程会负责重新检查一遍数组查看是否有遗漏的桶。
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
...
}
随后开启了一个死循环用于处理一个 stride 长度的任务,其中,i
代表当前处理的槽位序号,bound
代表需要处理的槽位边界。循环体内通过不断地左移i
的值来遍历旧表,进行rehash
。
在循环体中:首先开启一个while
循环,使用CAS不断尝试为当前线程分配任务,直到分配成功或任务队列已经被全部分配完毕。如果当前线程已经被分配过bucket区域,那么会通过--i
指向下一个待处理bucket然后退出该循环(哈希表从后往前的顺序分配)。
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;
}
}
退出这个循环有3个机会:
--i >= bound || finishing
成立一个,如果--i>=bound
说明当前线程已经分配过bucket
区域了,如果finishing == true
,说明扩容已经完成了。nextIndex = transferIndex
<= 0,表示所有bucket已经被分配完毕了,因为transferIndex<=0
,transferIndex
代表着下一个被划分的索引。- CAS执行成功,说明成功为当前线程分配了
bucket
区域。
上面都失败了,就循环重来吧。
–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=
这里可以举个例子解释一下具体是怎么划分的,以单核CPU、首次扩容(旧表长度为8)为例。当前stride
等于旧哈希表长n
,transferIndex
首先被赋值为n
了。接下来代码进入了上面的循环体中,首先会CAS竞争修改transferIndex
值,如果当前的nextIndex
仍旧等于transferIndex
,那么CAS成功,将transferIndex
的值设置为nextBound
。nextBound
的计算方式如下:
nextBound = (nextIndex > stride ? nextIndex - stride : 0)
也就是说,当nextIndex
小于等于stride
时,将transferIndex
设置为0。那么什么情况下,nextIndex
(transferIndex
== nextIndex
)会大于stride
?回顾上面的代码,只有当核心数大于1时,stride
会比transferIndex
小。当核心数等于1时,stride
只可能大于等于transferIndex
。所以nextBound
在这个例子中必然等于0。
CAS成功后,设置区间为[0, n-1]
,即[0,15]
,将这个区间划分给当前核心的一条扩容线程。
–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=–=--=
接下来先跳过下面这个if
代码块看后面的代码。
调用tabAt
方法获得旧表的i
位置的节点。
- 如果为空,就在旧表的这个位置放入
forwardingNode
节点指向新节点,起占位标识和转发作用,这是触发并发扩容的关键。 - 如果不为空,并且哈希值为
MOVED
,说明当前节点已经是forwardingNode
了,说明该位置已经被其他线程迁移过了,将 advance 设置为 true ,以便继续往下一个桶检查并进行迁移操作。 - 否则开始对这个索引位置的链表进行
rehash
。
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {//开始rehash
开始rehash
前,先获得该链表头节点的监视器锁,并进行加锁后的检验:
synchronized (f) {
if (tabAt(tab, i) == f) {
首先判断节点f
哈希值是否大于等于0,根据不同的情况进行rehash
:
- 如果是,说明是普通的链表头节点。
- 如果不是,说明有可能是红黑树的根节点。
普通链表的迁移过程:
- 遍历整条链表,找出 lastRun 节点。
- 根据 lastRun 节点的哈希高位标识(0 或 1,为0说明是低位,为1说明是高位),首先将 lastRun设置为 ln (LowNode)或者 hn (HighNode)链的末尾部分节点,后续的节点使用头插法拼接。
- 使用高位和低位两条链表进行迁移,使用头插法拼接链表。
- 使用 volatile 的方式将 ln 链设置到新数组下标为 i 的位置上。
- 使用 volatile 的方式将 hn 链设置到新数组下标为 i + n(n为原数组长度) 的位置上
- 迁移完成后使用 volatile 的方式将占位对象
fwd
设置到旧 hash 桶上,该占位对象的用途是标识该hash桶已被处理过,以及查询请求的转发作用 - 设置advance为 true 表示当前 hash 桶已处理完,可以继续处理下一个 hash 桶
什么是lastRun节点?图出处
红黑树的迁移过程省略,本人没有对红黑树深入研究。
回到前面跳过的if
代码块:
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回到上层while循环
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
如果扩容结束了,就做收尾工作:将 nextTable
设置为 null
,表示扩容已结束,将 table
指向新数组,sizeCtl
设置为扩容阈值。
每一条线程扩容结束时,就会调用上述代码块中的CAS语句,将并发扩容线程数-1。除了修改结束标识之外,还得设置 i = n; 以便重新检查一遍数组,防止有遗漏未成功迁移的桶。
以上过程是多线程处理扩容的操作,如果在扩容期间,其他写线程访问到了ForwardingNode
,那么这些线程会加入到并发扩容的行列。前面介绍put方法的时候,提到了一个helptransfer
方法, 它就是来干这个的。
小结
为了彻底理解,我们在宏观上来看看并发迁移的过程:
- 首先某个线程在往哈希表中添加元素后会调用
addCount
方法计数; - 当计数结果发现当前元素总数超过了设定的阈值
sizeCtl
,于是触发了resize
操作,即调用了transfer
方法。 - 如果当前CPU核心数是1个的话,那么这条
rehash
线程只能自己默默地负责所有迁移工作。 - 如果当前CPU核心数是多个的话,那么这条线程很有可能有帮手了!假设核心1的线程正在执行
rehash
,核心2的线程这时对哈希表执行了写操作(读操作不会调用helptransfer
),发现当前哈希表中存在ForwardingNode
,于是它就立马调用helptransfer
方法帮助核心1的线程进行rehash
操作。核心3和核心n遵循核心2的操作。
我觉得理解并发迁移的关键,就是分清楚单核多线程和多核多线程的关系。
借用大佬的图:
提问
以下问题摘录自链接
哈希桶迁移中以及迁移后如何处理get和put方法?
- 还未被迁移到的
hash
桶正常进行get
和put
操作,因为没有遇到ForwardingNode
。 - 正在迁移的桶遇到了
get
请求, 通过ForwardingNode
的nextTable
字段转发到新的表上查询。 - 正在迁移的桶遇到了
put
请求,在rehash
时,链表头节点的哈希值会被设置为MOVED
,写入线程会去协助rehash
。
如果 lastRun 节点正好在一条全部都为高位或者全部都为低位的链表上,会不会形成死循环?
答:在数组长度为64之前会导致一直扩容,但是到了64或者以上后就会转换为红黑树,因此不会一直死循环 。
为什么 get 方法不需要加锁?
答:get操作全程不需要加锁是因为Node的成员val是用volatile修饰的 。