# ConcurrentHashMap
HashMap虽然好用,但是它却不是线程安全的,而在并发度较高的现在,在有些情况下它可能就不是那么合适了,所以需要一个线程安全键值对结构。
Hashtable是线程安全的,但是它却过于笨重了,相比于HashMap而言,它仿佛只是在HashMap的每一个方法上加了一个synchronized,效率可想而知。所以就有了我们今天这篇问文章的主角ConcurrentHashMap,毕竟讲HashMap不讲ConcurrentHashMap那就是耍流氓,想要了解HashMap的同学可以去[HashMap源码及其常见问题详解](https://blog.csdn.net/weixin_42737868/article/details/113256318)看看。
注意,我们这里讲解的ConcurrentHashMap同样是Java8的。
## 关键属性
ConcurrentHashMap为什么能保证线程安全的呢,简单来说是**通过CAS+synchronized来实现的**,而且他最大的特点是能够多个线程协助扩容,至于是怎么实现的且听我慢慢道来。
```java
private transient volatile int sizeCtl;
```
ConcurrentHashMap相比于HashMap多了一个关键的属性`sizeCtl`,它有这几种情况:
1. -1:代表正在初始化;
2. -N:代表有N-1个线程正在协助扩容;
3. 0或N:当table为null的时候代表初始容量,0代表默认容量,否则代表扩容阈值;
可以看出`sizeCtl`身兼数职,它不但代替了HashMap的`threshold`属性,还在数组初始化和扩容的时候有着至关重要的作用。我们先对他有个大致的了解,具体的应用我们下文再说。
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;
}
}
另一个巨大的改变是多了一个特殊的节点`ForwardingNode`,他只有在扩容的时候用的到,ConcurrentHashMap**在扩容的时候将这个节点插入桶的头部,来告诉其他线程这个桶正在被迁移。**`nextTable`是扩容的时候的**新数组**。`ForwardingNode`节点的**key和value都是null**,所以ConcurrentHashMap**不允许节点的key或value为空**。`ForwardingNode`节点的hash值默认是MOVEN,也就是-1,同样我们可以看到红黑树根节点的hash值是-2。
static final int MOVED = -1; // hash for forwarding nodes
static final int TREEBIN = -2; // hash for roots of trees
他跟HashMap的另一个属性的不同是多了一个`nextTable`,他只在扩容的时候有用,其他时候都是null。
/**
* The next table to use; non-null only while resizing.
*/
private transient volatile Node<K,V>[] nextTable;
## put()
ConcurrentHashMap的构造函数跟HashMap大差不差,都是对一些关键属性的赋值,如果没有指定就全都是默认,所以我们这里就不细讲了,有兴趣的可以看我上一篇文章,当然我也推荐你看完上一篇文章之后再来学习这一篇文章。
`put()`可以说是HashMap最核心的方法了,把这个方法完全搞懂基本上就把HashMap全部学会了,所以我们这次依旧来深入`put()`方法。
//key value都不能为null
public V put(K key, V value) {
return putVal(key, value, false);
}
`put()`还是一样,实际逻辑都在`putVal()`里。
final V putVal(K key, V value, boolean onlyIfAbsent) {
//不允许key或value为空
if (key == null || value == null) throw new NullPointerException();
//算出hash值
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();
//如果找到的这个桶中没有元素直接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; // 添加的时候没有加锁
}
//hsah值是-1的是ForwardingNode节点,说明正在扩容
else if ((fh = f.hash) == MOVED) //static final int MOVED = -1;
//协助扩容
tab = helpTransfer(tab, f);
else {
V oldVal = null;
//存在链表且没在扩容,就将头结点锁住
synchronized (f) {
//再次效验节点有没有发生改变
if (tabAt(tab, i) == f) {
//hash值>=0代表是链表
if (fh >= 0) {
binCount = 1;
//遍历链表
for (Node<K,V> e = f;; ++binCount) {
K ek;
//存在这个key就覆盖(如果可以覆盖的话)
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;
}
}
}
}
//binCount!=0代表桶中有元素
if (binCount != 0) {
//看是否需要转化为红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
ConcurrentHashMap对于table数组的初始化依然是延迟到了第一次添加元素的时候。而因为key值不能为null,所以对于hash的计算也忽略了为null的这种情况,其他的还是一样。
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
其中有一个`binCount`,这个是用来计算桶中从头结点到该key节点的元素个数。这个在后面的`addCount()`方法中用得上。
而在根据hash值计算出下标后,如果当前数组下标中没有节点,也就是桶中没有元素,那就直接CAS添加节点,如果成功了就直接返回,失败了当然就再来一次循环了,**这也是为什么最外层是个死循环的原因,想要跳出这个循环只能通过特定的几个出口break。**
如果存在元素会出现table数组中的节点hash值为-1的情况,这时候前面我们说过的`ForwardingNode`节点第一次登场了,因为只有这个特殊节点的hash值才会为-1。当看到这个节点的时候,就说明当前桶正在被其他线程扩容迁移,这时候当前线程就可以协助其他线程扩容。是不是感觉很神奇,扩容怎么还能协助呢?别急,我们一点一点来讲。
如果一切正常的话,就需要先锁住链表的头结点,这也是`put()`方法里唯一需要上锁的地方,它只会影响到这一个链表,粒度非常的小,对性能的影响微乎其微。而下面就是很普通的查询了,这里就不说了。
## initTable()
`initTable()`方法只会有一个线程执行,而且只会执行一次。
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(); // 失去初始化的资格,让出时间片
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { //CAS修改sizeCtl的值
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;
}
ConcurrentHashMap通过CAS修改`sizeCtl`来保证只有一个线程能够初始化,当`sizeCtl<0`的时候要么是初始化,要么是扩容,但是即使他是在扩容这个线程也不会参与,而是让出CPU时间片重新判断一下。而如果成功CAS修改sizeCtl的值就代表获得了初始化的资格,这里要注意哦,`sizeCtl`是`volatile`修饰的,保证了线程之间的可见性。
## addCount()
我们先不讲`helpTransfer()`方法,因为在`addCount()`也会调用到这个方法,所以我们先来看一下`addCount()`方法。
`addCount()`方法从字面意思上是添加一个元素个数,在HashMap中是直接`size++`来记录元素个数,但是这样显然是线程不安全的,所以ConcurrentHashMap新添了一个`addCount()`方法来完成这件事,里面同样包括了对是否扩容逻辑的判断。
/**
* x 要添加的数量
* check 如果<0,就不需要检查是否需要扩容,如果<=1,只需要检查不竞争的
*/
private final void addCount(long x, int check) {
//计算出map中的元素个数
CounterCell[] as; long b, s;
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
s = sumCount();
}
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
//取得table大小为n的标志位
int rs = resizeStamp(n);
//当前已有线程正在扩容
if (sc < 0) {
//排除以下这几种情况
//1.不是当前的这个容量的扩容
//2.sc超过最小值或者最大值
//3.table已经扩容完成
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
//CAS增加扩容线程数,协助扩容
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
//第一个进行扩容的线程
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
在上半部分是对元素个数进行添加x的处理,这一块代码也比较复杂,大家简单知道是干什么的就行。下面的就是关于是否扩容的效验了。
首先必须得达到扩容阈值,并且table不能为空,且为达到最大容量,在这之后有一个方法`resizeStamp(n)`大家可能比较懵,这个是干什么的,我们进入它的内部看看。
//sizeCtl中用于生成标记的位数
private static int RESIZE_STAMP_BITS = 16;
//可以帮助调整大小的最大线程数。
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
//在sizeCtl中记录容量标记的位移位。
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
`Integer.numberOfLeadingZeros(n)`方法是计算n的最高位之前的零位数,什么意思呢?就是将n转为二进制,假设这里的`n=16`,那么转为二进制就是`0001 0000`,最高位是第5位,那么`Integer.numberOfLeadingZeros(n)`返回的结果就是`32-5=27`。而`1 << (RESIZE_STAMP_BITS - 1)`就是将1左移15位,也就是`1000 0000 0000 0000`。28的二进制是`0001 1011`,这两个进行或运算得的结果就是`1000 0000 0001 1011`。
因为n一定是2的n次方,所以不同的容量n的前面0的个数一定不一样,这样就可以保证是对原容量为n情况下进行扩容,`resizeStamp(int n)`的返回值(简称rs)的高16位为0,第16位为1,低15位存放当前容量n的扩容标识。
回到上一步,我们先来讲解一下第一个进行扩容的线程。第一个扩容的线程会CAS修改`sizeCtl`,把rs左移16位,这样就将原本位于低16位的信息转移到高16位,高16位表示容量n的扩容标识,低16位代表当前参与扩容的线程数+1,所以扩容最大线程数`MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1`。
我们完整走一遍流程:
//容量n=16
0000 0000 0000 0000 0000 0000 0001 0000
//Integer.numberOfLeadingZeros(n)结果为27
0000 0000 0000 0000 0000 0000 0001 1011
//计算出来rs的值
0000 0000 0000 0000 1000 0000 0001 1011
//(rs << RESIZE_STAMP_SHIFT)+2
1000 0000 0001 1011 0000 0000 0000 0010
如果已经有其他线程在扩容了,当前线程需要判断几个条件。我们知道了扩容的时候`sizeCtl`的高16位是扩容标识,所以`sc >>> RESIZE_STAMP_SHIFT`就是第一个扩容线程算出来的rs的值,他与当前线程算出来的rs的值进行对比,就能知道这两个线程是不是对同一个容量进行的扩容。
而我们知道`nextTable`只有在扩容的时候才不为null,所以如果它为null说明扩容已经结束。
//扩容的时候下一个要分配的table索引
private transient volatile int transferIndex;
`transferIndex`是在扩容的时候用到的,用来记录下一个要分配给线程的table索引,我们先知道当它<=0的时候代表着table中的所有桶已经分配出去了,不需要其他协程协助扩容了。
## transfer()
`transfer()`方法就是真正的扩容逻辑了,这一块也是ConcurrentHashMap的一块核心代码了。
我先来讲解一下扩容的大致逻辑,他是怎么能多个线程协助扩容的,这样能方便你理解这段代码。
首先扩容实际上就是将旧数组里的数据迁移到新数组里,ConcurrentHashMap根据CPU的的数量计算出一个步长`stride`,然后为每一个进行扩容的线程分配连续的`stride`个桶,这几个桶就归这一个线程负责,这样就能同时有多个线程一起进行扩容了。具体的实现细节我们看源码。
//CPU的数量
static final int NCPU = Runtime.getRuntime().availableProcessors();
//每个线程扩容是分配的最小步长
private static final int MIN_TRANSFER_STRIDE = 16;
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//通过CPU核数计算每一个线程的步长,步长最低是16
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
//如果是第一个进行扩容的线程就对nextTable进行初始化
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; // to ensure sweep before committing nextTab
//i就是当前要迁移的桶的下标,bound是当前线程遍历table的范围
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
//用来计算分配桶的范围
int nextIndex, nextBound;
//当前这个桶还属于当前线程迁移的范围,或者已经结束扩容就直接跳出循环
if (--i >= bound || finishing)
advance = false;
//桶已经全部分配出去了
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
//CAS修改成功就代表成功获得[bound,nextIndex)范围内的桶的迁移权
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) {
//替换新的table,设置新的扩容阈值
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;
//当前没有线程参与扩容了,当前线程是最后一个线程就把扩容结束标志改为true
finishing = advance = true;
i = n; // recheck before commit
}
}
//桶为空直接算迁移过了
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
//如果i处是ForwardingNode节点,说明第i个桶已经有人迁移过了
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
//锁住链表头结点
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
//fh>=0说明是链表
if (fh >= 0) {
int runBit = fh & n;
Node<K,V> lastRun = f;
//找出最后一段完整的fh & n不变的链表
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;
}
//lastRun之前的结点因为fh&n不确定,所以全部需要重新迁移。
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);
}
//因为上面已经加锁这,这里就不需要CAS修改了
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
//迁移完成将ForwardingNode节点插入数数组
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;
}
}
}
}
}
}
`transfer()`方法里有几个关键的变量,`finishing`是用来判断是否扩容可以结束,用来确保扩容已经完成。for循环里是真正的逻辑,`i`就是当前要处理的桶的下标。
在while循环的第一个判断中有两个作用,一个是将`i`值进行减一,**从这我们可以看出来对于桶的处理是从右到左的(也可以说是从高到低的)**,二是判断下一个桶还是不是当前线程负责的了,如果不是说明当前线程的活已经干完了,这时候就要看还有没有未被分配的桶了,如果还有的话当前线程还能再次参与扩容。
我们已经知道`transferIndex`是当前数组未分配的桶的下标,而桶的分配又是从左到右分配的,所以当它<=0的时候说明桶已经全部分配出去了,如果线程走到这一步就跟这个扩容基本没啥关系了。
如果还有未分配的桶,那么就需要CAS修改`transferIndex`的值,我们可以看出是将原值减去步长,如果修改成功就代表成功分配到桶了,可以退出这个循环了,如果没成功那就需要再走一遍循环。
跳出循环后我们继续往下走,首先会判断一下`i`是否已经越界了,**如果越界了说明当前线程的工作已经完成,并且所有的桶也已经分配出去了**,例如上面`while`循环里的`i = -1`的那种情况。这时候就只需要判断一下是否所有的线程都已经完成工作了,**如果`finishing = true`,那当前线程一定是当前唯一一个参与扩容的线程**。如果`finishing = false`,就将参与扩容的线程数减一。如果当前线程不是最后一个线程直接返回,如果是最后一个线程,就会将`finishing`改为true,这就是为什么判断`finishing = true`一定是最后一个扩容线程。
> 你会发现在再将`finishing`改为true之后,又将`i`改为`n`,这样做的目的是为了再检查一遍所有的桶,他会从下标为`n-1`的桶开始,一个一个查看,如果发现没有迁移的桶就再把它迁移过去,当所有的桶都检查过一遍后整个扩容才会真正的结束。
如果通过下标`i`找到的桶为空,那么直接就算迁移完成,将`ForwardingNode`节点放入数组中。
如果`i`下标的节点是`ForwardingNode`节点,说明已经迁移完成,**我们上面说的最后一个扩容线程检查桶是否全部迁移完成就是通过这行代码来判断的**。
后面就是桶的真正迁移过程了,这个跟HashMap差不多,只是多了一点小优化,就是它首先会找出最后一段完整的`fh & n`不变的链表,`fh & n`相同的节点在新数组里必在同一下标,这样后面就不用再新建节点了。
> 回看一下put()方法,可以发现,即使当前正在扩容,但如果还未处理到当前桶,那么依然能够继续执行`put()`代码逻辑,这两个部分是可以同时进行的。
## helpTransfer()
讲完扩容之后我来讲一讲协助扩容,这时候你应该会很容易就理解了。
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) {
//依然是对sc和transferIndex的效验
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
//如果CAS修改sizeCtl成功就可以参与扩容
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
从`put()`方法中我们可以得知`helpTransfer()`方法的参数`f`是桶中链表的头结点,所有首先会效验`f`是不是`ForwardingNode`节点,且节点`nextTable`是否存在。接下来依然是跟`addCount()`里一样的效验,只不过这里只有协助扩容的部分。
## get()
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//算出hash值
int h = spread(key.hashCode());
//table初始化完成且该桶有值
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;
}
我们可以到整个`get()`方法里没有一个地方进行加锁,唯一一个比较特殊的地方就是通过`key`的`hash`值找到的下标处节点的`hash < 0`,hash值小于0的节点只有红黑树根节点和`ForwardingNode`,红黑树暂且不说,而不加锁的奥秘就是在`ForwardingNode`的`find()`方法
Node<K,V> find(int h, Object k) {
// loop to avoid arbitrarily deep recursion on forwarding nodes
//在新数组里进行查找
outer: for (Node<K,V>[] tab = nextTable;;) {
Node<K,V> e; int n;
if (k == null || tab == null || (n = tab.length) == 0 ||
(e = tabAt(tab, (n - 1) & h)) == null)
return null;
for (;;) {
int eh; K ek;
//hash值和key都相同
if ((eh = e.hash) == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
if (eh < 0) {
//如果又是ForwardingNode节点就跳到最外层循环
if (e instanceof ForwardingNode) {
tab = ((ForwardingNode<K,V>)e).nextTable;
continue outer;
}
//红黑树
else
return e.find(h, k);
}
//没有找到节点返回null
if ((e = e.next) == null)
return null;
}
}
}
对于节点是`ForwardingNode`的情况,说明当前桶里的元素已经迁移到新数组去了,`ForwardingNode`的`find()`方法就是直接去新数组里进行查找。如果遍历链表的时候发现了`ForwardingNode`节点,就会跳到最外层循环重新查找,避免发生深度递归。
> 会在链表里再次发现`ForwardingNode`的情况是,在这次查询中又经历了一次扩容,这些桶里的节点又迁移到`nextTable`里了
学无止境,你我共勉。