一. 前言
上节课我跟大家说过,本次课程会开始将我们JAVA中常用的并发集合类。本节课,我会跟大家一起详解ConcurrentHashMap的源码。ConcurrentHashMap其实就是一个线程安全的HashMap,在我们JDK1.8中是以CAS+synchronized实现的线程安全,其底层数据结构为数组+链表+红黑树。话不多说,我们一起进入正题。
二. ConcurrentHashMap存储操作源码解析
2.1 put方法简易流程图
首先,先不急着马上阅读源码,我这里画了一个简单的put()方法的流程图,我们先搞明白这个基本的流程,然后再对着流程一步步阅读下去,如下图2.1-1 put方法流程图所示。

图2.1-1 put方法流程图
put(K key, V value)是我们开发过程中使用频次最高的一个方法,那么这个方法底层又是怎么实现的呢,我们一步步往下看。
// 插入一条(key, value)键值对数据
public V put(K key, V value) {
// 我们可以看到其实,该方法底层调用了putVal,且第三个参数为false;
// 这里解释一下,第三个参数为false代表如果当前的map已经存在关键字为key
// 的映射,就会进行value的覆盖操作。
// 如果第三个参数为true,代表当前map已经存在关键字为key的映射时,什么
// 都不做,只有当key不存在时,才会添加一条(key,value的数据) ,
// 如在调用putIfAbsent时,会调用putVal方法,第三个参数传递的为true
return putVal(key, value, false);
}
2.2 putVal方法-二次哈希算法(也叫散列算法)
接下来,我们再进入putVal()方法,看看它又帮我们做了什么,为了方便理解,我们先不把putVal()方法的所有代码都一次性解析完,而是分层次,一步步的去读,先来重点研究一下,ConcurrentHashMap是怎么计算key的hashcode的。
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 可以看到,ConcurrentHashMap是不允许key或者value为空的,这点和HashMap不同。
if (key == null || value == null) throw new NullPointerException();
// 该方法是面试高频点,务必注意,这个方法其实是对key关键字进行二次hash,
// 目的是为了得到一个分配更加均匀的映射集合。
int hash = spread(key.hashCode());
int binCount = 0;
// 后续逻辑我会在后面一步步详解,此处我们先不要管后面的逻辑。
}
// 转换为二进制:011111111 11111111 11111111 11111111
static final int HASH_BITS = 0x7fffffff;
static final int spread(int h) {
// 这个方法是将hashcode与其无符号右移十六位的值进行一次异或运算,
// 然后再与HASH_BITS做一次与操作,注意这么做的目的如下:
// 是为了让h的高十六位也参与到计算中,因为我们的
// ConcurrentHashMap的索引是通过hashcode运算出来的,我后面会举例说明原因,
// 而之所以又要& HASH_BITS,是为了让hashcode计算出来的值不要是负数。
// 负数的hash值在ConcurrentHashMap里有特殊的含义,我后面会详解。
// 接下来,我举一个具体的案例说明为什么要有h ^ (h >>> 16)的操作,
// 假如我们的h一开始为00001101 00001101 00101111 10001111,如果不进行上述操作,
// 而我们计算下标的算法是h & (n - 1),这样一来,在你小于2的16次方的时候,
// 你会发现h的高十六位完全没有参与到计算当中,所以我们计算下标的算法是不够均匀的,
// 因此为了得到一个更加均匀的下标分配,我们将h ^ (h >>> 16),这样一来不就等于
// 把h的高十六位也参与到下标计算了,对吧
return (h ^ (h >>> 16)) & HASH_BITS;
}
上述我对于spread()这个二次hash的方法做了一个详解,这也是一个面试高频问题,我们常常在面试时,会碰到面试官问ConcurrentHashMap/HashMap的下标是怎么计算的,那么通过上面代码的理解,我想我们可以很清楚的用自己的语言表达出来。
2.3 putVal()方法添加数据和初始化数组
接下来,我们再来看看ConcurrentHashMap是如何添加数据的,请看下方源码详解,还是老样子,我们依然是拆解分析。
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
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)
// ConcurrentHashMap插入数据时,如果发现当前节点数组为空,
// 会先进行一次初始化的操作,初始化完毕后,又会通过外层这个死循环
// 进来,再将数据添加进去。
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 根据hash计算数组下标存放位置,然后用cas将数据塞入这个位置,
// 如果成功则跳出循环,如果失败则会接着循环,直到把数据放进去为止。
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
}
到这里,可以说我们已经将按照2.1-1的流程图算是读完了,大致流程非常简单,但是还有非常多的细节我们还没有去详解,接下来,我们还是自顶向下的方法,再来看看这个初始化方法initTable又是怎么做的呢?
private final Node<K,V>[] initTable() {
// tab:其实就是当前ConcurrentHashMap的节点数组的局部变量
// sc:是数组在初始化和扩容操作时的一个控制变量,在此处是sizeCtl的局部变量
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
// 注意,sc如果小于0,代表已经有别的线程在对其进行初始化或者扩容操作了。
// 此时,就会先让出线程的方法
Thread.yield();
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
// 采用cas的方式将sizeCtl修改为-1,成功代表当前线程来做数组初始化
try {
if ((tab = table) == null || tab.length == 0) {
// 这里sc大于0,就取sc的值,否则就取默认值16。
// 此处也是为什么我们常常说ConcurrentHashMap无参构造就是16
// 的长度的原因
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
// 这里可以理解为取n的3/4,理解为将sc设置为
// 下次ConcurrentHashMap进行扩容的阈值即可。
sc = n - (n >>> 2);
}
} finally {
// sizeCtl设置为sc
sizeCtl = sc;
}
break;
}
}
return tab;
}
上面的流程都轻轻松松,接下来将进入本章的一个重难点,就是添加数据到链表以及链表升级为红黑树的流程。老规矩,为了方便大家的理解,我这里针对添加数据的部分画出一张简单的流程图如下图2.3-1 添加数据流程示意图所示。

图2.3-1 添加数据流程示意图
接下来,跟着这个流程图,我们思路不用乱,按照流程一步步来看它的详细源码实现。
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
// binCount是记录节点链表长度的标识。
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
// n:数组长度
// i:当前Node需要存放的索引位置
// f: 当前数组i索引位置的Node对象
// fh:当前数组i索引位置上数据的hash值
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
}
else if ((fh = f.hash) == MOVED) // 这段暂时先不用看,我文章后面单独有详解。
tab = helpTransfer(tab, f);
else { // 这部分代码是现在重点关注的。
V oldVal = null;
// 我们可以看到,在产生hash冲突的情况下,会给当前节点加锁,
// 所以这一块插入节点数据的时候一定是线程安全的。
synchronized (f) {
// 判断当前位置的数据还是之前的f么……(避免加锁前并发操作的安全问题)
if (tabAt(tab, i) == f) {
// 哈希值大于0,说明是正常节点,我前面有提过,小于0的是有特殊含义的,
// 文章后面我会详解hash值小于0的特殊含义。
if (fh >= 0) {
// 链表长度初始为1。
binCount = 1;
// 注意,这块也是死循环
for (Node<K,V> e = f;; ++binCount) {
// 声明标识ek
K ek;
// 当前i索引位置的数据,是否和当前put的key的hash值一致
if (e.hash == hash &&
// 如果hash值一样,再判断key是否是同一对象又或者是否相等
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
// hash值一样且key也相等,进到下面的逻辑。
// oldVal先存储key之前对应的value值。
oldVal = e.val;
// 还记得我前面讲的putVal()方法吗?onlyIfAbsent对应它的
// 它的第三个参数,在put方法里就是false,所以会覆盖。
if (!onlyIfAbsent)
// 覆盖掉原value。
e.val = value;
break;
}
// 到这一步,说明当前要插入的节点和e肯定不是一个key,先记录
// pred为当前节点e
Node<K,V> pred = e;
if ((e = e.next) == null) {
// 如果当前节点不存在next节点,正好了,把待插入的节点设
// 置为当前节点的next,否则就继续循环,往下循环链表直到
// 找到一个next为空的节点挂上去。
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
// 这里代表当前节点已经是一个红黑树结构了,并且单纯的链表结构,
// 那么就直接把当前节点插入到这颗红黑树下,后面文章会专门为这块
// 内容做详解,我们先不用急。
Node<K,V> p;
// 这是故意这么设置的,并非代表它的链表或者红黑树节点只有两个,
// 后面代码会根据binCount大小判断是否升级为红黑树,而这里已经
// 是红黑树结构。
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
// 如果链表长度大于等于8,代表当前节点要扩容或者升级为红黑树的数据结构。
if (binCount >= TREEIFY_THRESHOLD)
// 扩容或者升级红黑树结构的方法,文章后面有详解,现在先理解这块流程。
treeifyBin(tab, i);
if (oldVal != null)
// 返回节点的oldVal,即原值。
return oldVal;
break;
}
}
}
// 文章后面单独有详解,先理解上面这块代码的整体流程,对着图2.3-1一步步看下去。
addCount(1L, binCount);
return null;
}
2.4 treeifyBin()方法详解
刚才上面的流程还有一个方法我还没有给大家做详解,就是treeifyBin()方法,它会先判断数组长度是否大于等于64,否则会扩容并重新迁移数据,是则会将节点对应的链表升级为红黑树,这里我们再单独对treeifyBin()方法做一次详细分析。
private 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)
// 上面的if就是判断数组长度是否小于64,是则要进行扩容和重新迁移。
// 关于tryPresize文章后面也会做专门讲解
tryPresize(n << 1);
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
synchronized (b) {
// 为了避免加锁前并发操作修改了数据节点,这里又做了一次判断。
if (tabAt(tab, index) == b) {
TreeNode<K,V> hd = null, tl = null;
for (Node<K,V> e = b; e != null; e = e.next) {
TreeNode<K,V> p =
new TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
// hd赋值这个节点只有for循环的第一次才会走到,它就是整个单项
// 列表的头节点。
hd = p;
else
// 设置链表,注意这里是很多人的误区:链表升级为红黑树,并不
// 代表原来的链表就没有了,它依然存在。
tl.next = p;
tl = p;
}
// 注意:new TreeBin<K,V>(hd)方法其实就是将链表升级为红黑树的构造方
// 法, setTabAt()方法就是将index下标的节点改为升级为红黑树后的节点,
// TreeBin构造方法也是我文章后面要详解的。
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
2.5 tryPresize()方法详解
上面我们讲到tryPresize()方法是扩容的方法,它的作用就是将旧数组长度按指定容量进行扩容,保证数组扩容后的长度为2的n次方,并且扩容后,还要按照新数组重新计算节点的下标值并重新迁移旧数组的节点到新数组上。接下来,我们来看看它是怎么做的吧。
// size在这里其实就是将之前的数组长度 左移 1位得到的结果
private final void tryPresize(int size) {
// 如果扩容的长度达到了最大值,就使用最大值
// 否则需要保证数组的长度为2的n次幂
// 这块的操作,是为了初始化操作准备的,因为调用putAll方法时,也会触发tryPresize方法
// 如果刚刚new的ConcurrentHashMap直接调用了putAll方法的话,会通过tryPresize方法进行
// 初始化
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
tableSizeFor(size + (size >>> 1) + 1);
// 下面这段代码和initTable一模一样,很多部分不赘述
int sc;
while ((sc = sizeCtl) >= 0) {
Node<K,V>[] tab = table; int n;
if (tab == null || (n = tab.length) == 0) {
n = (sc > c) ? sc : c;
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if (table == tab) {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
}
}
else if (c <= sc || n >= MAXIMUM_CAPACITY)
break;
else if (tab == table) {
// 计算扩容表示戳,根据当前数组的长度计算一个16位的扩容戳
// 第一个作用是为了保证后面的sizeCtl赋值时,保证sizeCtl为小于-1的负数
// 第二个作用用来记录当前是从什么长度开始扩容的
int rs = resizeStamp(n);
// 务必注意:这是JDK1.8的BUG,sc < 0在这里是永远进不去的
// 如果sc小于0,代表有线程正在扩容。
if (sc < 0) {
Node<K,V>[] nt;
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);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
// CAS的方式将rs进行+2,代表有一个线程在进行扩容操作,
// 也代表是第一个来扩容的线程
// 基于CAS将sizeCtl修改为如:10000000 00011010 00000000 00000010
// 将扩容戳左移16位之后,符号位是1,就代码这个值为负数
// 低16位在表示当前正在扩容的线程有多少个,
// 为什么低位值为2时,代表有一个线程正在扩容
// 每一个线程扩容完毕后,会对低16位进行-1操作,当最后一个线程扩容完毕后,减
//1的结果还是-1,
// 当值为-1时,要对老数组进行一波扫描,查看是否有遗漏的数据没有迁移到新数组
transfer(tab, null);
}
}
}
2.6 transfer()方法详解
关于transfer()方法我们要记住三点,1. 它可以支持多线程迁移,因此每个线程需要去领取自己的迁移任务;2. 每个线程都有自己的迁移结束操作,各个线程之间迁移互不影响,不会等到所有线程都迁移完;3. 迁移数据到链表这一块存在一个lastRun优化机制,如下图2.6-1 lastRun优化机制示意图,这块算法对我们日后的编程也有帮助,可能比较难理解,务必结合流程图去看代码,会轻松很多;
此外,此处就要提一点了,hash值为-1的代表它是可协助迁移任务的节点,也代表其正在扩容或者已经扩容完毕,hash值为-2的是红黑树节点,后面会有详解,这里我们先知道这个概念。

图2.6-1 lastRun优化机制示意图
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
// n = 数组长度
// stride = 每个线程一次性迁移多少数据到新数组
int n = tab.length, stride;
// 基于CPU的内核数量来计算,每个线程一次性迁移多少长度的数据最合理
// NCPU = 4
// 举个栗子:数组长度为1024 - 512 - 256 - 128 / 4 = 32
// MIN_TRANSFER_STRIDE = 16,为每个线程迁移数据的最小长度
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
// 根据CPU计算每个线程一次迁移多长的数据到新数组,如果结果大于16,使用计算结果。
// 如果结果小于16,就使用最小长度16
stride = MIN_TRANSFER_STRIDE;
// 第一个进来扩容的线程需要把新数组构建出来
if (nextTab == null) {
try {
@SuppressWarnings("unchecked")
// 将原数组长度左移一位,构建新数组长度,等于是乘2;
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
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
// 第一次进来,这两个判断肯定进不去。
// 对i进行--,并且判断当前任务是否处理完毕!
if (--i >= bound || finishing)
advance = false;
// 判断transferIndex是否小于等于0,代表没有任务可领取,结束了。
// 在线程领取任务会,会对transferIndex进行修改,修改为transferIndex -
// stride
// 在任务都领取完之后,transferIndex肯定是小于等于0的,代表没有迁移数据的任
//务可以领取
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
// 已经没有迁移任务了,advance设置为false。
advance = false;
}
// 当前线程尝试领取任务
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
// bound可以理解为设置成了迁移完成的结束位置(注意:迁移是从数组后面往前
// 进行的)
bound = nextBound;
// i就是迁移的末尾位置,迁移任务是从后往前进行的,一路进行到bound
i = nextIndex - 1;
advance = false;
}
}
// 这里又有JDK1.8的bug,看看下方注释。
// 判断扩容是否已经结束!
// i < 0:当前线程没有接收到任务!
// i >= n: 迁移的索引位置,不可能大于数组的长度,不会成立
// i + n >= nextn:因为i最大值就是数组索引的最大值,不会成立
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// finishing为true,代表扩容结束
if (finishing) {
// 将nextTable新数组设置为null
nextTable = null;
// 将当前数组的引用指向了新数组~
table = nextTab;
// 按照新数组重新计算扩容阈值
sizeCtl = (n << 1) - (n >>> 1);
return;
}
// 走到这里说明当前线程没有领到迁移任务,让当前线程结束扩容操作。
// 采用CAS的方式,将sizeCtl - 1,代表当前并发扩容的线程数 - 1
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
// 如果是最后一个退出扩容的线程,将finishing和advance设置为true
// 将i设置为老数组长度,让最后一个线程再从尾到头再次检查一下,是否数据全部
// 迁移完毕。
finishing = advance = true;
i = n; // recheck before commit
}
}
else if ((f = tabAt(tab, i)) == null)
// 当前桶位置没有数据,无需迁移,直接将当前桶位置设置为fwd
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
// 一般是给最后扫描时,使用的判断,如果迁移完毕,直接跳过当前位置。
advance = true;
else {
// 当前桶位置有数据,先锁住当前桶位置。
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
// hash大于0,代表当前Node属于正常情况,不是红黑树,使用链表方式迁移
if (fh >= 0) {
// 这种运算结果只有两种,要么是0,要么是n
int runBit = fh & n;
// 将f赋值给lastRun
Node<K,V> lastRun = f;
// 循环目的就是为了得到链表下经过hash&n结算,结果一致的数据
// 在迁移数据时,值需要迁移到lastRun即可,剩下的指针不需要变换。
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
//只有在下一节点的runBit和上一节点不一样的时候,才触发
// 这么做是为了提升后面迁移节点的性能。
runBit = b;
lastRun = p;
}
}
// runBit == 0,赋值给ln
if (runBit == 0) {
ln = lastRun;
hn = null;
}
// runBit == n,赋值给hn
else {
hn = lastRun;
ln = null;
}
// 循环到lastRun指向的数据即可,后续不需要再遍历
for (Node<K,V> p = f; p != lastRun; p = p.next) {
// 获取当前Node的hash值,key值,value值。
int ph = p.hash; K pk = p.key; V pv = p.val;
// 如果hash&n为0,挂到lowNode上
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
// 如果hash&n为n,挂到highNode上
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
// 采用CAS的方式,将ln挂到新数组的原位置
setTabAt(nextTab, i, ln);
// 采用CAS的方式,将hn挂到新数组的原位置 + 老数组长度
setTabAt(nextTab, i + n, hn);
// 采用CAS的方式,将当前桶位置设置为fwd
setTabAt(tab, i, fwd);
// advance设置为true,保证可以进入到while循环,对i进行--操作
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;
}
}
}
}
}
}
2.7 helpTransfer()协助扩容方法详解
前面我们有提到hash值为负数的节点是存在特殊含义的,这里我给大家指出来,在我们设置某个节点为ForwardingNode时,它的构造方法中hash值写死了是-1,意味着它可以做协助扩容操作,这里我们先将协助扩容是什么理解一下,后面内容会对ForwardingNode再进行详解。
// 在添加数据时,如果插入节点的位置的数据,hash值为-1,代表当前索引位置数据已经被迁移到了新数组
// tab:老数组
// f:数组上的Node节点
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
// nextTab:新数组
// sc:给sizeCtl做临时变量
Node<K,V>[] nextTab; int sc;
// 第一个判断:老数组不为null
// 第二个判断:新数组不为null (将新数组赋值给nextTab)
if (tab != null &&
(f instanceof ForwardingNode) && (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
// ConcurrentHashMap正在扩容
// 基于老数组长度计算扩容戳
int rs = resizeStamp(tab.length);
// 第一个判断:fwd中的新数组,和当前正在扩容的新数组是否相等。 相等:可以协助扩容。不相等:要么扩容结束,要么开启了新的扩容
// 第二个判断:老数组是否改变了。 相等:可以协助扩容。不相等:扩容结束了
// 第三个判断:如果正在扩容,sizeCtl肯定为负数,并且给sc赋值
while (nextTab == nextTable && table == tab && (sc = sizeCtl) < 0) {
// 第一个判断:将sc右移16位,判断是否与扩容戳一致。 如果不一致,说明扩容长度不一样,退出协助扩容
// 第二个、三个判断是BUG:
/*
sc == rs << 16 + 1 || 如果+1和当前sc一致,说明扩容已经到了最后检查的阶段
sc == rs << 16 + MAX_RESIZERS || 判断协助扩容的线程是否已经达到了最大值
*/
// 第四个判断:transferIndex是从高索引位置到低索引位置领取数据的一个核心属性,如果满足 小于等于0,说明任务被领光了。
if ((sc >>> RESIZE_STAMP_SHIFT) != rs ||
sc == rs + 1 ||
sc == rs + MAX_RESIZERS ||
transferIndex <= 0)
// 不需要协助扩容
break;
// 将sizeCtl + 1,进来协助扩容
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
// 协助扩容
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
三. 红黑树操作
在前面我们讲了关于数据+链表的添加和扩容操作,现在开始讲解红黑树。因为红黑树的操作比较复杂,要求先对红黑树结构有一定了解。
3.1 红黑树简介
红黑树是一颗特殊的平衡二叉树,我们对数据结构有一点了解的应该清楚,平衡二叉树有一个特性是左右子树的高度差不能大于1,如果超出1,就会进行左旋和右旋的操作实现自平衡。而红黑树虽然没有左右子树高度差不能大于1的限制,但其也保留了左旋和右旋的操作,并且在这个基础上还会有变色的特点,这里我先来讲一下红黑树的五个平衡条件:
1. 所有节点都是黑色或者红色;
2. 根节点必须为黑色;
3. 所有叶子节点必须为黑色:这里的叶子节点指的是树为空时的NIL节点,或者实际叶子节点的子节点(在二叉查找树中,叶子节点没有子节点,但在红黑树的实现中,通常会用NIL节点来表示不存在的子节点)。
4. 红色节点的子节点必须为黑色;
5. 从任一节点出发到其每个叶子节点的所有路径都包含有相同数目的黑色节点;
此处,我不再深挖红黑树这个数据结构了,如果有对红黑树不了解的同学还请先去学习一下红黑树,否则本文章后面关于ConcurrentHashMap的红黑树操作的内容肯定是难以理解的,当然,我后面会有专题去将数据结构与算法,也会有红黑树的讲解。
3.2 treeifyBin方法构造红黑树
前面我们在讲解treeifyBin()方法时就已经提到了使用TreeBin构造方法构造节点红黑树,只是没有往下细讲,现在我们就一起来看看它是怎么做的吧。
// 将双向链表转为红黑树的操作。 b:双向链表的第一个节点
// TreeBin继承自Node,root:代表树的根节点,first:双向链表的头节点
TreeBin(TreeNode<K,V> b) {
// 构建Node,并且将hash值设置为-2
super(TREEBIN, null, null, null);
// 将双向链表的头节点赋值给first
this.first = b;
// 声明r的TreeNode,最后会被赋值为根节点
TreeNode<K,V> r = null;
// 遍历之前封装好的双向链表
for (TreeNode<K,V> x = b, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
// 先将左右子节点清空
x.left = x.right = null;
// 如果根节点为null,第一次循环
if (r == null) {
// 将第一个节点设置为当前红黑树的根节点
x.parent = null; // 根节点没父节点
x.red = false; // 不是红色,是黑色
r = x; // 将当前节点设置为r
}
// 已经有根节点,当前插入的节点要作为父节点的左子树或者右子树
else {
// 拿到了当前节点key和hash值。
K k = x.key;
int h = x.hash;
Class<?> kc = null;
// 循环?
for (TreeNode<K,V> p = r;;) {
// dir:如果为-1,代表要插入到父节点的左边,如果为1,代表要插入的父节点的右边
// ph:是父节点的hash值
int dir, ph;
// pk:是父节点的key
K pk = p.key;
// 父节点的hash值,大于当前节点的hash值,就设置为-1,代表要插入到父节点的左边
if ((ph = p.hash) > h)
dir = -1;
// 父节点的hash值,小于当前节点的hash值,就设置为1,代表要插入到父节点的右边
else if (ph < h)
dir = 1;
// 父节点的hash值和当前节点hash值一致,基于compare方式判断到底放在左子树还是右子树
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
// 拿到当前父节点。
TreeNode<K,V> xp = p;
// 将p指向p的left、right,并且判断是否为null
// 如果为null,代表可以插入到这位置。
if ((p = (dir <= 0) ? p.left : p.right) == null) {
// 进来就说明找到要存放当前节点的位置了
// 将当前节点的parent指向父节点
x.parent = xp;
// 根据dir的值,将父节点的left、right指向当前节点
if (dir <= 0)
xp.left = x;
else
xp.right = x;
// 插入一个节点后,做一波平衡操作
r = balanceInsertion(r, x);
break;
}
}
}
}
// 将根节点复制给root
this.root = r;
// 检查红黑树结构
assert checkInvariants(root);
}
3.3 balanceInsertion()方法
上面我们讲到双链表是如何被构造成一颗红黑树的,而红黑树里最麻烦的一个点,就是保持整棵树的平衡,如果仔细阅读了上面的源码注释,我们不难发现每个红黑树节点的插入都涉及到了balanceInsertion()方法,这个方法是保证整颗树平衡的关键,一起来看看它的实现吧。
// 红黑树的插入动画:https://www.cs.usfca.edu/~galles/visualization/RedBlack.html
// 红黑树做自平衡以及保证特性的操作。 root:根节点, x:当前节点
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root, TreeNode<K,V> x) {
// 先将节点置位红色
x.red = true;
// xp:父节点
// xpp:爷爷节点
// xppl:爷爷节点的左子树
// xxpr:爷爷节点的右子树
for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
// 拿到父节点,并且父节点为红
if ((xp = x.parent) == null) {
// 当前节点为根节点,置位黑色
x.red = false;
return x;
}
// 父节点不是红色,爷爷节点为null
else if (!xp.red || (xpp = xp.parent) == null)
// 什么都不做,直接返回
return root;
// =====================================
// 左子树的操作
if (xp == (xppl = xpp.left)) {
// 通过变色满足红黑树特性
if ((xppr = xpp.right) != null && xppr.red) {
// 叔叔节点和父节点变为黑色
xppr.red = false;
xp.red = false;
// 爷爷节点置位红色
xpp.red = true;
// 让爷爷节点作为当前节点,再走一次循环
x = xpp;
}
else {
// 如果当前节点是右子树,通过父节点的左旋,变为左子树的结构
if (x == xp.right) {、
// 父节点做左旋操作
root = rotateLeft(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {
// 父节点变为黑色
xp.red = false;
if (xpp != null) {
// 爷爷节点变为红色
xpp.red = true;
// 爷爷节点做右旋操作
root = rotateRight(root, xpp);
}
}
}
}
// 右子树(只讲左子树就足够了,因为业务都是一样的)
else {
if (xppl != null && xppl.red) {
xppl.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}
else {
if (x == xp.left) {
root = rotateRight(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateLeft(root, xpp);
}
}
}
}
}
}
除了treeifyBin()方法之外,还记得我们前面提到的putVal()方法吗,它也有涉及到红黑树的节点插入操作,并且调了putTreeVal()方法,之前没有细讲,现在我们来看看。
3.4 putTreeVal方法-添加红黑树节点
// 添加节点到红黑树内部
final TreeNode<K,V> putTreeVal(int h, K k, V v) {
// Class对象
Class<?> kc = null;
// 搜索节点
boolean searched = false;
// 死循环,p节点是根节点的临时引用
for (TreeNode<K,V> p = root;;) {
// dir:确定节点是插入到左子树还是右子数
// ph:父节点的hash值
// pk:父节点的key
int dir, ph; K pk;
// 根节点是否为诶null,把当前节点置位根节点
if (p == null) {
first = root = new TreeNode<K,V>(h, k, v, null, null);
break;
}
// 判断当前节点要放在左子树还是右子数
else if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
// 如果key一致,直接返回p,由putVal去修改数据
else if ((pk = p.key) == k || (pk != null && k.equals(pk)))
return p;
// hash值一致,但是key的==和equals都不一样,基于Compare去判断
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
// 基于compare判断也是一致,就进到if判断
(dir = compareComparables(kc, k, pk)) == 0) {
// 开启搜索,查看是否有相同的key,只有第一次循环会执行。
if (!searched) {
TreeNode<K,V> q, ch;
searched = true;
if (((ch = p.left) != null &&
(q = ch.findTreeNode(h, k, kc)) != null) ||
((ch = p.right) != null &&
(q = ch.findTreeNode(h, k, kc)) != null))
// 如果找到直接返回
return q;
}
// 再次判断hash大小,如果小于等于,返回-1
dir = tieBreakOrder(k, pk);
}
// xp是父节点的临时引用
TreeNode<K,V> xp = p;
// 基于dir判断是插入左子树还有右子数,并且给p重新赋值
if ((p = (dir <= 0) ? p.left : p.right) == null) {
// first引用拿到
TreeNode<K,V> x, f = first;
// 将当前节点构建出来
first = x = new TreeNode<K,V>(h, k, v, f, xp);
// 因为当前的TreeBin除了红黑树还维护这一个双向链表,维护双向链表的操作
if (f != null)
f.prev = x;
// 维护红黑树操作
if (dir <= 0)
xp.left = x;
else
xp.right = x;
// 如果如节点是黑色的,当前节点红色即可,说明现在插入的节点没有影响红黑树的平衡
if (!xp.red)
x.red = true;
else {
// 说明插入的节点是黑色的
// 加锁操作
lockRoot();
try {
// 自平衡一波。
root = balanceInsertion(root, x);
} finally {
// 释放锁操作
unlockRoot();
}
}
break;
}
}
// 检查一波红黑树结构
assert checkInvariants(root);
// 代表插入了新节点
return null;
}
3.5 TreeBin的锁操作
我们调用了putTreeVal()方法添加红黑树节点时,可以看到,我们在方面前调用了lockRoot()进行加锁,这里其实可以理解为写锁,在我们对红黑树节点进行数据插入时,如果此时进来了读操作,且读操作优先抢占了锁资源,那么写操作会被阻塞,在读操作完成后,再唤醒写操作线程,这里设计得非常精妙,可以一起来看看。
// TreeBin的锁操作
// 如果说有读线程在读取红黑树的数据,这时,写线程要阻塞(做平衡前)
// 如果有写线程正在操作红黑树(做平衡),读线程不会阻塞,会读取双向链表
// 读读不会阻塞!
static final class TreeBin<K,V> extends Node<K,V> {
// waiter:等待获取写锁的线程
volatile Thread waiter;
// lockState:当前TreeBin的锁状态
volatile int lockState;
// 对锁状态进行运算的值
// 有线程拿着写锁
static final int WRITER = 1;
// 有写线程,再等待获取写锁
static final int WAITER = 2;
// 读线程,在红黑树中检索时,需要先对lockState + READER
// 这个只会在读操作中遇到
static final int READER = 4;
// 加锁-写锁
private final void lockRoot() {
// 将lockState从0设置为1,代表拿到写锁成功
if (!U.compareAndSwapInt(this, LOCKSTATE, 0, WRITER))
// 如果写锁没拿到,执行contendedLock
contendedLock();
}
// 释放写锁
private final void unlockRoot() {
lockState = 0;
}
// 写线程没有拿到写锁,执行当前方法
private final void contendedLock() {
// 是否有线程正在等待
boolean waiting = false;
// 死循环,s是lockState的临时变量
for (int s;;) {
//
// lockState & 11111101 ,只要结果为0,说明当前写锁,和读锁都没线程获取
if (((s = lockState) & ~WAITER) == 0) {
// CAS一波,尝试将lockState再次修改为1,
if (U.compareAndSwapInt(this, LOCKSTATE, s, WRITER)) {
// 成功拿到锁资源,并判断是否在waiting
if (waiting)
// 如果当前线程挂起过,直接将之前等待的线程资源设置为null
waiter = null;
return;
}
}
// lockState & 00000010,代表当前没有写操作挂起等待。
else if ((s & WAITER) == 0) {
// 基于CAS,将LOCKSTATE的第二位设置为1
if (U.compareAndSwapInt(this, LOCKSTATE, s, s | WAITER)) {
// 如果成功,代表当前线程可以waiting等待了
waiting = true;
waiter = Thread.currentThread();
}
}
else if (waiting)
// 挂起当前线程!会由读操作唤醒
LockSupport.park(this);
}
}
}
3.6 transfer方法对于红黑树的迁移逻辑
前面2.6我们就有详解过transfer()方法的内容,并且将红黑树那块暂时给搁置了,到了这里,我们就可以对红黑树迁移内容再做一个详解,我们首先要记住这么几个要点:1.首先红黑结构的数据迁移是基于双向链表封装的数据; 2. 如果迁移后节点的链表长度小于等于6,封装为链表迁移到新数组; 3.如果迁移后节点的链表长度大于6,依然封装为红黑树节点迁移到新数组。
// 红黑树的迁移操作单独拿出来,TreeBin中不但有红黑树,还有双向链表,迁移的过程是基于双向链表迁移
TreeBin<K,V> t = (TreeBin<K,V>)f;
// lo,hi扩容后要放到新数组的高低位的链表
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
// lc,hc在记录高低位数据的长度
int lc = 0, hc = 0;
// 遍历TreeBin中的双向链表
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;
}
}
// 封装低位节点,如果低位节点的长度小于等于6,转回成链表。 如果长度大于6,需要重新封装红黑树
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);
// 当前位置数据迁移完毕,设置上fwd
setTabAt(tab, i, fwd);
// 开启前一个节点的数据迁移
advance = true;
四. 总结
由于篇幅限制,ConcurrentHashMap实际上还有很多要点还没有讲完,但是就光这些内容其实就够我们消化很久了。其实关于源码解析,我还是那句话,不求看懂每一行代码,主要是要读懂作者的设计理念和基本的执行流程即可。
478

被折叠的 条评论
为什么被折叠?



