本文的所有关于ConcurrentHashMap源码都基于JDK1.8.0_211,如有其他版本的代码,将会在引用处指出代码版本
前言
在翻过了HashMap的一座大山之后,还有一座更高的大山,那就是ConcurrentHashMap,这座大山集成了集合和线程安全为一体,成为了许多Java人眼中望而却步的天堑,笔者在经过大量源码阅读和许多大佬巨佬的博客熏陶之后,写下了这篇ConcurrentHashMap浅析,不敢说自己彻底融汇贯通,只能就着自己的理解略谈一二,如有疏漏,还烦请各路大佬指点一番。
整个ConcurrentHashMap在JDK1.8之后,做了一番巨大的调整,和原来JDK1.7时完全是两个模样,1.8之后的ConcurrentHashMap在看它的第一眼,和HashMap仿佛很类似,不管是数据结构,还是链表的插入都那么似曾相识,好像只是多了几行代码,多了几个方法,的确也是这样的,整个ConcurrentHashMap和HashMap有些类似,但实际上却在为了保证线程安全的情况下,做了无数个保障保护措施,尤其是在扩容这一段,所以笔者选择从ConcurrentHashMap的基本特性入手之后,直接切入ConcurrentHashMap的扩容原理,然后再回过头将讲解Put方法,这样笔者觉得更容易理解ConcurrentHashMap的一些线程安全的基本原理和整个类的工作原理。
基本特性
在前言说过,ConcurrentHashMap在数据结构上和HashMap类似,实际上,ConcurrentHashMap和HashMap的数据结构基本一致,都是采用了数组 + 链表 + 红黑树的数据结构,并且,ConcurrentHashMap在链表的插入中,选择了和1.8的HashMap一致,都是采用的尾插法。
在ConcurrentHashMap中整个都是线程安全的,主要采用了CAS + synchronized技术。
在ConcurrentHashMap中是不允许key和value为null的,这点和HashMap不太一样,在HashMap中是允许key和value为null的。
在ConcurrentHashMap中槽中对于红黑树的引用并不是直接引用TreeNode节点,而是在TreeNode节点上封装了一个TreeBin,这个TreeBin主要是对红黑树的根节点进行了封装,这个封装里面主要包括了根节点的引用和一个读写锁,之所以这样设计的原因是因为在高并发的情况下,红黑树存在许多创建以及调整的过程,在这些过程中可能会导致红黑树根节点的移动,这样导致在ConcurrentHashMap的槽中的引用出现问题,导致线程不安全的情况产生。
基本参数介绍
- table:table就是Node的数组,里面存放的都是各个key-value的Node,以及链表的头节点的Node,还有红黑树的TreeBin。
- nextTable:这个是在要扩容时,生成的新表临时存放的位置,这个参数的值仅仅在扩容的时候不为空。
- baseCount:这个参数是基本计数器,它是在ConcurrentHashMap中没有竞争的时候用到,不能代表ConcurrentHashMap的大小。
- counterCells:这个参数是用来辅助baseCount来对ConcurrentHashMap进行统计技术的,主要用在并发竞争的时候,同样也不能来代表ConcurrentHashMap的大小。
- sizeCtl:这个参数比较复杂,它在几种不同的范围区间值的时候代表的不同意思:
4.1. 当整个表没有初始化的时候,sizeCtl的值为0;
4.2. 当整个表初始化完成之后,sizeCtl的值大于0的时候,sizeCtl代表着当前表的阈值;
4.3. 当sizeCtl的值等于-1的时候,表示当前ConcurrentHashMap正在进行表的初始化;
4.4. 当sizeCtl的值等于-n (此时n > 1)的时候,sizeCtl的值的低16位减一为当前正在进行扩容的线程数。
关于sizeCtl的这几个参数,先有个映像,如果没有仔细去看源码,这几个参数是记不住的,那么,下面首先就从扩容开始讲起。
从扩容讲起
扩容主流程
在HashMap中,最常见的扩容的入口就是在put方法执行完最后,在ConcurrentHashMap中也是一样,在执行完put方法最后,会调用一个方法对集合中的元素进行计数统计,和检查扩容,这个方法就是addCount。
addCount方法解析:
private final void addCount(long x, int check)
- addCount方法的功能作用:addCount方法其实主要是由两个大块组成,第一大块是统计计数,也就是通过传入进来的形参 x ,加入到ConcurrentHashMap的计数器中,另一大块是检查扩容,一旦符合扩容条件就对当前的table进行扩容;
- addCount方法的大致思路讲解:这里将统计和扩容进行分开讲解,
2.1. 统计思路:
2.1.1. 先判断当前集合的counterCells是不是为空,如果为空再尝试通过CAS往baseCount中添加;
2.1.2. 如果当前集合的counterCells为空,或者CAS添加baseCount失败,就会尝试往counterCells数组中的槽去添加当前的值;
2.1.3. 如果第一次往counterCells数组中的槽去添加当前的值失败(失败的原因有好几种,比如counterCells根本就不存在,或者往counterCells数组中的槽去添加时存在竞争,具体看后面的源码分析),那么就会调用fullAddCount方法(这个方法会在后面解析)进行counterCells的创建和统计数据的插入。
2.2. 扩容思路:
在ConcurrentHashMap中扩容是支持多线程同时扩容的,而且又由于在多线程的情况下,可能会大量数据,所以导致在一次扩容完成后,马上又要第二次扩容,所以在源码中可以看到ConcurrentHashMap的扩容实现不单单只是用一个if语句,而是用while循环来进行包裹着的。
2.2.1. 首先判断当前集合的大小是否超过了阈值,并且集合的table不为空,集合的table的大小是否小于最大值;
2.2.2. 如果上诉条件都满足,那么就去生成一个标识位rs(关于rs和sizeCtl的生成具体过程会在后面讲解);
2.2.3. 先会判断当前的sizeCtl是否小于0,然后通过一系列判断,去判断当前集合是否需要继续扩容,如果不满足,直接返回,如果满足,将会将当前线程加入到扩容线程的大军;
2.2.3. 如果sizeCtl不小于0,就证明当前线程是第一个进行扩容的线程,那么就通过CAS将sizeCtl变成一个很小的负数,然后开始扩容转移。
2.3. rs和sizeCtl的分析:
先来看rs是怎么生成的:
int rs = resizeStamp(n);
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
private static int RESIZE_STAMP_BITS = 16;
首先来解释一下Integer.numberOfLeadingZeros(n),下面是官方注释:
Returns the number of zero bits preceding the highest-order("leftmost") one-bit in the two's complement binary representationof the specified int value. Returns 32 if thespecified value has no one-bits in its two's complement representation,in other words if it is equal to zero.
这段话的大概意思就是,返回当前数字的二进制的补码中最高位为1(最左一位为1)前面的所有位的位数,如果没有为1的最高位就返回32,示例如下:
1 = 0000 0000 0000 0000 0000 0000 0000 0001
2 = 0000 0000 0000 0000 0000 0000 0000 0010
3 = 0000 0000 0000 0000 0000 0000 0000 0011
16 = 0000 0000 0000 0000 0000 0000 0001 0000
Integer.numberOfLeadingZeros(1) = 31
Integer.numberOfLeadingZeros(2) = 30
Integer.numberOfLeadingZeros(3) = 30
Integer.numberOfLeadingZeros(16) = 27
然后因为计算rs的n是table的长度,所以,Integer.numberOfLeadingZeros(n)的范围是0 - 32,由此可以得出rs = 215 + [0, 32],这样子还看不出来rs有什么具体含义,那么继续往下看,在扩容时有一行代码是通过rs计算出sizeCtl的,如下:
U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)
// 这行代码在单线程下也可以等同于
sizeCtl = (rs << RESIZE_STAMP_SHIFT) + 2
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
private static int RESIZE_STAMP_BITS = 16;
由此可以得出在扩容时的sizeCtl等于rs左移16位再加2,这样可能还不懂,举个例子,如下:
// 当n = 16时
rs = 2^15 | 27 = 2^15 + 27
= 0000 0000 0000 0000 1000 0000 0001 1011
sizeCtl = (rs << RESIZE_STAMP_SHIFT) + 2
= (rs << 16) + 2
= (1000 0000 0001 1011 0000 0000 0000 0000) + 2
= 1000 0000 0001 1011 0000 0000 0000 0010
由此可以看出sizeCtl的高16位其实就是rs,低16位实际就是(扩容的线程数目 + 1),这里又有两个问题,第一,rs到底是什么,有什么具体含义嘛?第二,为什么低16位不能直接放扩容的线程数目,而是需要加一?
首先第一个问题:rs实际上就代表着当前表的table的长度,为什么这么说,因为table始终都是2的幂次方,这个在table的注释中间说过了,所以可以直接通过看rs的最后五位的数值,确定table长度的二进制位前面有多少个0,由此可以得出table的长度,那么为什么rs的首位需要设置为1,是因为需要在rs右移16位后,将最高位置为1,变成负数。
其次第二个问题:为什么需要加一,想象这么一个场景,因为rs = 215 + [0, 32],所以当rs = 215 时,右移16位 = 1000 0000 0000 0000 0000 0000 0000 0001,而 -1的原码也是1000 0000 0000 0000 0000 0000 0000 0001,这样就造成了sizeCtl = -1这个值有多重语义。
- addCount方法代码解析:
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
// 1.先判断counterCells是不是空的
// 2.如果counterCells是空的就会通过CAS尝试往baseCount中间插入统计数据x
// 3.当如果counterCells是空的或者往baseCount中间插入数据失败,就会进入判断语句的后续处理
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
// 1.判断counterCells是不是空的
// 2.判断counterCells的长度是不是为空
// 3.判断counterCells数组中当前线程对应的槽是不是为空
// 4.尝试去往counterCells数组中当前线程对应的槽去插入统计数据
// 上述四个判断尝试里面只要有一个成功就会会去做初始化counterCells或者插入统计数据操作
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
// 这个方法里面涉及到counterCells的初始化,大小调整,以及碰撞插入等
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
// 统计当前表内有多少数据
s = sumCount();
}
// 判断是否需要检查扩容
if (check >= 0) {
Node[] tab, nt; int n, sc;
// 1.判断当前表内的数据量是否大于阈值,同时也判断当前表是否正在扩容,因为扩容时sizeCtl是一个小于-1的负数
// 2.判断table是不是为空
// 3.判断当前当前的table的大小是否小于最大值,如果已经大于等于最大值了,那么不能扩容了
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
// 计算出标识位rs,rs也代表着当前table的大小
int rs = resizeStamp(n);
// 判断当前sc是否正在扩容
if (sc < 0) {
// 1.判断当前sizeCtl的高16位是否和当前计算出来的rs一致,如果不一致,则证明table的大小已经改变,完成了扩容
// 2.sc == rs + 1 || sc == rs + MAX_RESIZERS这两个其实在当前代码中写错了,因为在这里
// sizeCtl = (rs << RESIZE_STAMP_SHIFT) + 2,所以sizeCtl不管在什么情况下,都不可能等于这两个条件,
// 这个Bug在2018年就提交给了Oracle官方了,在2020年解决,官方Bug的具体地址是:https://bugs.java.com/bugdatabase/view_bug.do?bug_id=JDK-8214427
// 3.判断nextTable是否为空,因为nextTable只有是不为空时才表示正在迁移
// 4.判断transferIndex是否小于等于0,transferIndex代表着转移下标,因为在ConcurrentHashMap中,转移元素逆序转移的
// 当以上条件满足任意一个时代表着扩容完成了,无需再扩容。
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);
}
// 如果进入这个判断分支,代表着当前线程是第一个进行扩容的线程,需要将sizeCtl设置成(rs << RESIZE_STAMP_SHIFT) + 2
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
// 进行扩容
transfer(tab, null);
s = sumCount();
}
}
}
transfer方法解析:
private final void transfer(Node[] tab, Node[] nextTab)
transfer方法的功能作用:
transfer方法是ConcurrentHashMap中真正对数据进行扩容转移的方法,transfer方法第一个参数是当前集合中的table,第二个参数是新的扩容table,这也代表了,在ConcurrentHashMap中是支持多个线程同时进行扩容的。transfer方法的大致思路讲解:
第一步:计算本次集合需要迁移的步长;
第二步:创建一个两倍大的新的table;
第三步:利用死循环对原有的数组数据进行迁移。
以上是一个最粗略的思路,在每一步后面都有大量的细节,所有首先讲讲扩容的整体思路,以及上面提出来的步长是什么?
在ConcurrentHashMap中,我们反复说过,是支持多线程扩容的,那么在ConcurrentHashMap中是怎么避免线程在扩容的过程产生碰撞,对一个槽中的数据反复操作的呢。这就是我在上面提到的步长,在每次扩容中,计算出来了一个步长,通过这个步长决定每个线程每次处理迁移负责table里面多少个槽,以及每个线程自己处理的边界,这样每个线程直接就不会在处理的时候发生碰撞,每个线程处理分配给自己的范围区间,处理完成之后,再根据迁移的下标(transferIndex),来去重新获取新的区间,或者退出。
在第一步中,计算步长的时候,步长是根据CPU的核心数以及table数组的大小来确定的,但是却有一个最小值16,也就是不管怎么样,每次扩容时,线程的步长最小为16。
在第二步中,当如果尝试扩容两倍失败,将不会再进行扩容,直接会把sizeCtl设置成最大阈值。
在第三步中,第一个细节点就是利用一个死循环来进行扩容的,这个死循环是根据一个finishing字段来进行控制的,第二个点就是,每当处理完一个槽的数据,就会在这个槽上放置一个ForwardingNode对象来占住这槽,表示这个槽已经被迁移了,第三个点就是,会利用Synchronized来锁住需要的迁移对象,防止在迁移的时候进行Put操作。transfer方法代码解析:
private final void transfer(Node[] tab, Node[] nextTab) {
int n = tab.length, stride;
// 通过CPU核心数和table的长度来计算本次迁移操作的步长:table长度的八分之一除以CPU核心数
// 如果计算出来的步长小于16,将会用16来作为线程扩容的步长
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
// 判断是否为第一个线程进行扩容,如果是第一个就创建一个两倍大新的扩容数组
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
Node[] nt = (Node[])new Node,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
// 设置整个table数组的迁移下标为当前table的长度
transferIndex = n;
}
int nextn = nextTab.length;
// 创建一个新的占位符节点
ForwardingNode fwd = new ForwardingNode(nextTab);
// 这个变量代表着是否需要通过步长进行下一个迁移区间分配
boolean advance = true;
// 这个变量代表着是否已经完成了本次迁移
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
// i代表着当前线程所在的下标,bound代表着本次迁移的边界
Node f; int fh;
// 判断是否需要分配下一个迁移区间
while (advance) {
int nextIndex, nextBound;
// 1.判断当前线程所在的下标是否越过了要迁移区间的边界
// 2.判断本次迁移是否已经完成
// 满足任意一个条件申请分配新区间失败
if (--i >= bound || finishing)
advance = false;
// 判断本次的迁移下标已经小于等于0,如果满足,则申请分配新区间失败
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
// 尝试通过步长和当前的整个table数组的迁移下标算出新的区间分配
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
// 1.判断当前线程所在下标是否小于0越界了
// 2.i >= n || i + n >= nextn 这两个判断没看懂,i不可能满足这两个条件
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// 判断一下是否完成了扩容
if (finishing) {
// 如果完成了扩容将nextTable赋值到table上面,并且把nextTable变为null
nextTable = null;
table = nextTab;
// 设置新的阈值为当前新table大小的0.75,
// 其实就是将原来的大小n,进行2n - 0.5n,得出来的结果就是新的大小的0.75
sizeCtl = (n << 1) - (n >>> 1);
return;
}
// 如果上面没有完成扩容的话,就尝试将当前的sizeCtl减去1,表示当前线程已经完成
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
// 如果将sizeCtl减去1后,不等于(resizeStamp(n) << RESIZE_STAMP_SHIFT + 2),
// 就代表着当前线程不是最后一个完成的线程,因为在之前的addCount方法里面说过
// 如果是第一个开始的扩容线程,会将sizeCtl设置为(resizeStamp(n) << RESIZE_STAMP_SHIFT + 2),
// 所以如果不等于的话这个线程就可以直接返回
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);
// 判断如果当前节点已经是占位符(占位符对象的hash值是固定的-1)的话,代表着已经处理过,就直接跳过
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
// 进入这里就代表着当前线程所在的下标内是非空并且未处理过的节点了,通过synchronized来对当前节点进行锁定
synchronized (f) {
if (tabAt(tab, i) == f) {
Node ln, hn;
// 判断当前节点不是红黑树节点
if (fh >= 0) {
// 这里的其实和JDK1.7的ConcurrentHashMap类似
// 首先这里的第一个点是,判断链表下面的节点是不是需要迁移到新的下标,
// 在链表下面的节点在迁移下标时只有两个位置,一是原位置,二是原位置加上原有的数组长度,
// 而且又因为集合中table的长度始终为二的幂次方,所以只需要看原先数组长度为1的那一位bit位
// 举例:原先数组长度 :16 = 0001 0000 , 计算下标的方式 :(table.leangth - 1) & hash
// 假如现在有两个hash :h1 = 1001 1001 , h2 = 1000 1001;这两个hash对应当前长度的下标都是后四个bit位
// 所以如果扩容一倍,那么就只用往前面多看一位bit位来决定是否是需要迁移到新下标
int runBit = fh & n;
Node lastRun = f;
// 这个是第二个要注意的点:
// 在ConcurrentHashMap中,会找出出一段末尾连续的链表,具体意思还是看举例:
// 比如现在槽的链表:0 -> 1 -> 0 -> 1 -> 1 -> 0 -> 0 , 在这里面每位数字代表着是否需要迁移到新下标
// 那么在迁移的过程中就会找到链表最后两个连续的 0 -> 0
// runBit : 代表了最后这段连续的链表是在原下标还是新下标
// lastRun : 代表着最后这段链表的其实结点
for (Node 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 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(ph, pk, pv, ln);
else
hn = new Node(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 t = (TreeBin)f;
TreeNode lo = null, loTail = null;
TreeNode hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode p = new TreeNode
(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(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
统计数据主要方法
fullAddCount方法解析:
private final void fullAddCount(long x, boolean wasUncontended)
- fullAddCount方法的功能作用:
fullAddCount方法只在一个地方调用,那就是在addCount方法中被调用,这个方法主要是为了用来初始化counterCells或者用来往counterCells里面添加数据,整个方法里面用的思想和LongAdder类的思想一致。 - fullAddCount方法的大致思路讲解:
fullAddCount里面的思想和LongAdder类的思想一致,里面都是把不同线程的计数,分配到了数组的不同位置上面去,在整个方法内主要由四步组成:
2.1. 得到当前线程的线程探测值;
2.2. 判断当前的counterCells是否为空,如果不为空,则往counterCells中在指定的位置进行计数;
2.3. 如果counterCells初始化,并且有抢到了数组的标识位(cellsBusy),那么就进行数据的初始化;
2.4. 如果没有抢到标识位(cellsBusy),那么就尝试往baseCount里面进行计数。
上面的2.2 - 2.4是一个死循环,这个死循环直到一个条件满足并且将线程的计数加进去为止。 - fullAddCount方法代码解析:
private final void fullAddCount(long x, boolean wasUncontended) {
int h;
// 获取当前线程的探测值,如果当前线程的探测值为0,则去初始化
if ((h = ThreadLocalRandom.getProbe()) == 0) {
ThreadLocalRandom.localInit(); // force initialization
h = ThreadLocalRandom.getProbe();
wasUncontended = true;
}
boolean collide = false; // True if last slot nonempty
for (;;) {
CounterCell[] as; CounterCell a; int n; long v;
// 判断counterCells是否为空,如果不为空则进入进行计数插入
if ((as = counterCells) != null && (n = as.length) > 0) {
// 判断counterCells中线程对应的槽是否为空,如果为空,则对该槽进行初始化
if ((a = as[(n - 1) & h]) == null) {
// 判断标识位是否为0(为0表示没有线程在对counterCells进行操作)
if (cellsBusy == 0) { // Try to attach new Cell
// 创建一个新的计数槽
CounterCell r = new CounterCell(x); // Optimistic create
// 再次判断标识位是否为0,并且尝试把标识位从0改成1
if (cellsBusy == 0 &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
boolean created = false;
try { // Recheck under lock
CounterCell[] rs; int m, j;
// 进行二次确认
if ((rs = counterCells) != null &&
(m = rs.length) > 0 &&
rs[j = (m - 1) & h] == null) {
rs[j] = r;
created = true;
}
} finally {
// 释放标识位
cellsBusy = 0;
}
// 看是否创建成功,如果创建成功就直接返回
if (created)
break;
continue; // Slot is now non-empty
}
}
collide = false;
}
// 判断是否无竞争的,如果是有竞争的,则去重新刷新线程的探测值
else if (!wasUncontended) // CAS already known to fail
wasUncontended = true; // Continue after rehash
// 尝试往对应的计数槽中间增加计数,如果增加成功直接返回
else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
break;
// 判断counterCells是否改变了,或者counterCells的长度是否大于等于了CPU核心数,
// 如果满足了表示可以进行扩容了,但是在扩容千还是会尝试去改变线程的探测值,重新试试
else if (counterCells != as || n >= NCPU)
collide = false; // At max size or stale
// 这个相当于扩容前最后一次尝试重新改变线程的探测值
else if (!collide)
collide = true;
// 到这一步就会尝试去获取标识位,将标识位变成1
else if (cellsBusy == 0 &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
try {
if (counterCells == as) {// Expand table unless stale
// 对counterCells进行扩容迁移
CounterCell[] rs = new CounterCell[n << 1];
for (int i = 0; i < n; ++i)
rs[i] = as[i];
counterCells = rs;
}
} finally {
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
// 重新生成新的线程探测值
h = ThreadLocalRandom.advanceProbe(h);
}
// 走到这里代表着counterCells为空,需要初始化counterCells
else if (cellsBusy == 0 && counterCells == as &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
boolean init = false;
try { // Initialize table
if (counterCells == as) {
// 初始化的counterCells长度是2
CounterCell[] rs = new CounterCell[2];
rs[h & 1] = new CounterCell(x);
counterCells = rs;
init = true;
}
} finally {
cellsBusy = 0;
}
if (init)
break;
}
// 如果counterCells为空,并且没有抢到counterCells的标识位,就再次尝试往baseCount里面进行计数插入
else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
break; // Fall back on using base
}
}
辅助扩容流程
helpTransfer方法解析:
final Node[] helpTransfer(Node[] tab, Node f)
- helpTransfer方法的功能作用:
helpTransfer方法的作用主要是帮助当前的ConcurrentHashMap进行扩容。 - helpTransfer方法的大致思路讲解:
helpTransfer方法的思路非常简单,
2.1. 首先第一步,判断当前线程想要操作的节点是不是ForwardingNode类型的,并且判断当前的节点中是否指向新创建的nextTable,已经指向的nextTable是否为空;
2.2. 第二步,就去通过sizeCtl判断当前的是否在迁移状态,如果在扩容状态就去尝试帮助扩容。 - helpTransfer方法代码解析:
final Node[] helpTransfer(Node[] tab, Node f) {
Node[] nextTab; int sc;
// 1. 判断table是否未被初始化
// 2. 判断当前线程操作的节点是否是ForwardingNode类型(占位符节点)
// 3. 判断当前线程操作的占位符节点是否有指向新的table,并判断是否为空,
// 如果nextTable为空表示没有在迁移
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode)f).nextTable) != null) {
// 下面就和addCount方法里面的操作一样了
// 根据当前线程去算出来当前table大小的标识符
int rs = resizeStamp(tab.length);
// 循环可以实现连续帮助扩容
// 通过sizeCtl判读是否是在扩容状态
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
// 这个判断和addCount方法里面一模一样,里面同样也是有错误,
// 主要就是判断当前的扩容是否终止了
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中的插入流程,对比于扩容来说,ConcurrentHashMap在插入的流程上没有过大的改变,和HashMap的大致流程还是一致的。下面进入到思路讲解:
插入主流程
插入流程的大致思路讲解:
- 在ConcurrentHashMap会首先去校验key和value是不是为空了,这点和HashMap不一样,在ConcurrentHashMap中是不允许key和value为空的;
- 在校验完key-value之后,会去算出当前key的hash值;
- 在算出当前key的hash值之后,先回去判断当前的table数组是否为空,如果为空就去初始化table数组;
- 在上述判断之后,会去通过当前ConcurrentHashMap的table长度去算出当前的key-value在哪个槽,并且去检查对应的槽内是否为空,如果为空,就会通过CAS来尝试对该槽进行插入;
- 如果当前槽不为空的话,就会去判断当前节点的hash值是否为-1,也就是判断当前节点是否为ForwardingNode类型,因为ForwardingNode类型的节点的hash值都默认为-1,如果当前节点是ForwardingNode类型的节点,当前线程就要去尝试帮助当前的ConcurrentHashMap进行扩容;
- 如果当前槽也不是ForwardingNode类型的节点,那么就会对当前节点进行进行加锁;
- 在对当前节点进行加锁之后,就会去判断当前节点是链表节点还是红黑树节点,判断的依据是当前的节点的hash值,因为在ConcurrentHashMap中,红黑树在槽上的节点都是TreeBin类型的节点,TreeBin类型的节点的hash值都是为-2;
- 在确定完节点类型之后,就会根据各自的方式去添加节点,添加完成之后,对当前节点进行解锁;
- 在解锁完成之后,就回去判断当前的链表是否需要转换成为红黑树;
- 在整个key-value被加入到当前的ConcurrentHashMap对象之后,就会通过调用addCount方法对当前对象进行统计和扩容检测;
在上述的过程中,3 - 9步都在一个自旋的循环中,确保节点能被添加成功。
插入流程代码分析:
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 判断当前key-value是否为空,如果为空则抛出空指针异常
if (key == null || value == null) throw new NullPointerException();
// 计算当前key的hash值
int hash = spread(key.hashCode());
// 这个字段是用来记录链表长度的
int binCount = 0;
// 进入自旋
for (Node[] tab = table;;) {
Node f; int n, i, fh;
// 判断当前的table数组是否初始化,如果没有就对其进行初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 判断当前这个hash值对应的槽是否为空,如果为空就尝试通过CAS对该槽进行插入
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// 判断当前节点是不是ForwardingNode类型的节点,如果是的就要去尝试帮忙扩容线程
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
// 锁住当前节点
synchronized (f) {
if (tabAt(tab, i) == f) {
// 判断当前节点是不是链表
if (fh >= 0) {
binCount = 1;
for (Node 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 pred = e;
if ((e = e.next) == null) {
// 利用尾插法进行插入
pred.next = new Node(hash, key,
value, null);
break;
}
}
}
// 判断当前节点是不是红黑树
else if (f instanceof TreeBin) {
Node p;
binCount = 2;
if ((p = ((TreeBin)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;
}
}
}
// 对当前ConcurrentHashMap对象里面Node的数量进行统计和扩容检测
addCount(1L, binCount);
return null;
}
初始化流程
初始化流程的大致思路讲解:
- 首先判断当前的table是否有初始化;
- 如果当前table没有经行初始化,就会判断当前的sizeCtl是否为-1,因为sizeCtl为-1代表着table正在进行初始化,如果当前table正在进行初始化,就是让当前线程放弃CPU资源;
- 如果sizeCtl不为-1,那么当前线程就尝试将sizeCtl变成-1,如果修改成功,则证明拿到了初始化的权限,就会将table经行初始化,同时设定sizeCtl为新的阈值;
在这个里面,2、3步在一个循环中,是为了确保初始化成功。
初始化流程代码分析:
private final Node[] initTable() {
Node[] tab; int sc;
// 判断当前的table是否已经初始化了
while ((tab = table) == null || tab.length == 0) {
// 判断是否有其他线程正在经行初始化,如果有的话,就让当前线程释放CPU资源
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
// 如果没有线程正在进行初始化,当前线程就尝试将sizeCtl设置成-1
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
// 二次判断
if ((tab = table) == null || tab.length == 0) {
// 对table进行初始化
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node[] nt = (Node[])new Node,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
和JDK7的ConcurrentHashMap对比
- 结构不同:
在JDK1.7中ConcurrentHashMap是由Segment + HashEntry组成的,这两块都是数组,但是真正存储数据的地方是在HashEntry里面,结构如下图所示:
在这个里面Segment决定了ConcurrentHashMap的并发数目,是不可扩容的,真实数据存在Segment下面的HashEntry里面,这个HashEntry是可以扩容的,这就是JDK1.7中ConcurrentHashMap的分段锁概念,每个Segment都是一个数据段,支持多个线程同时操作多个Segment,但是不支持多个线程同时操作一个Segment,并且在HashEntry里面采用的是数组 + 链表的形式来存储数据;
在JDK8中,ConcurrentHashMap采用的是和JDK8的HashMap一样的数据结构,如下所示:
在JDK8的ConcurrentHashMap中丢掉了原来的Segment结构,而是把每个槽都当作一个Segment,并且增加了碰撞时的红黑树结构。
在去掉Segment之后,会有一个明显的优势,颗粒度更小了,并发级别更高了,而且,避免两次hash带来的损耗。
- 使用的线程安全机制不同:
JDK7中,Segment是通过继承ReentrantLock来对操作Segment内部数据的时候保证线程安全的,而在JDK8中,是采用的自旋 + CAS + synchronized来实现线程安全的。在这个中间为什么采用synchronized来代替ReentrantLock,个人理解为,因为在JDK8之后,锁的颗粒度要比JDK7中要小了,这样ReentrantLock对比于synchronized来说就失去了Condition的优势了。
总结
在看完JDK8中最主要的两个流程之后,可以看出,ConcurrentHashMap在结构上和JDK8的HashMap保持了一致,但是在线程安全的保障上,对CAS、自旋和加锁几乎运用到了极致,ConcurrentHashMap全类6300多行,几乎到处都是精华,这次的分析实属管中窥豹,如有不对之处,烦请指正,不胜感激。
参考文档:
ConcurrentHashMap的sizeCtl含义纠正
源码阅读 - ConcurrentHashMap#addCount 方法里面的 bug
ConcurrentHashMap源码分析(JDK8) 扩容实现机制