1 addCount transfer扩容
首先判断是否需要扩容,也就是当更新后的键值对总数baseCount>=阀值(sizeCtl)时,则重新进行hash;扩容进行两个阶段:
1.有一个线程正在扩容,另一个线程进来协助扩容;
2.如果当前没有扩容,直接触发扩容操作;
private final void addCount(long x, int check) {
...
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&//判断s 是concurrentMap的总个数 是否大于阀值,并且表不为空
(n = tab.length) < MAXIMUM_CAPACITY) {//表的长度不小于2的30次方
int rs = resizeStamp(n);//生成唯一的时间戳?下面详解
if (sc < 0) {//判断是否有线程正在扩容
这 5 个条件只要有一个条件为 true,说明当前线程不能帮助进行此次的扩容,
直接跳出循环
//sc >>> RESIZE_STAMP_SHIFT!=rs 表示比较高 RESIZE_STAMP_BITS 位
生成戳和 rs 是否相等,相同
//sc=rs+1 表示扩容结束
//sc==rs+MAX_RESIZERS 表示帮助线程线程已经达到最大值了
//nt=nextTable -> 表示扩容已经结束
//transferIndex<=0 表示所有的 transfer 任务都被领取完了,没有剩余的
hash 桶来给自己自己好这个线程来做 transfer
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))
transfer(tab, null);//
s = sumCount();
}
}
}
1.1 resizeStamp
用来生成一个和容器相关的扩容戳;
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
Integer.numberOfLeadingZeros 这个方法是返回无符号整数n最高位的非零个数的和;
10的二进制 是 0000 0000 0000 0000 0000 0000 0000 1010 ,这个方法返回的就是28;
根据resizeStamp 运算规则,resizeStamp(16)= 32796 ,二进制位
【0000 0000 0000 0000 1000 0000 0001 1100】
当第一个线程要扩容时,U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)
,相当于原来的二进制地位变为高位,【1000 0000 0001 1100 0000 0000 0000 0000 】。加2后,【1000 0000 0001 1100 0000 0000 0000 0010 】
,高16位标识扩容戳,低16位标识扩容的线程数,
这样存储的优点有:
1.ConcurrentHashMap支持并发扩容,当前数组扩容的话,可以有多个线程来共同负责;
2.可以保证每次扩容都生成一个唯一的时间戳,每一次扩容都会有不同的n,所以生成的时间戳也不同;
疑惑点2:第一个线程扩容的时候为什么要+2?
因为+1表示线程正在进行初始化,2表示一个西安测绘给你正在进行扩容操作,对于sizeCtl的操作都时基于位运算,不用关心算出来的数值是多少, 只关心二进制下的值,而sc+1 会在低16位上加1;
transfer
这个方法是用来扩容的,扩容的核心操作在于数据的转移,,在单线程环境 下数据的转移很简单,无非就是把旧数组中的数据迁移到新的数组。但是这在多线程环境下, 在扩容的时候其他线程也可能正在添加元素,这时又触发了扩容怎么办?如果只是加互斥锁,则会带来性能上很大的开销,因为互斥锁会导致西安测绘给你在访问临界资源时,进行阻塞,持有锁的线程耗时越长, 其他竞争线程就会一直被阻塞,导致吞吐量较低。而且还可能导致死锁。
ConcurrentHashMap 采用了cas实现无锁并发同步,利用多线程来进行扩容,它时把node数据当作多线程之间的共享任务队列,通过指针来划分每个线程负责的区间,每个线程通过逆序遍历来实现扩容,迁移完的bucket 会被ForwardingNode替换;
1、fwd:这个类是个标识类,用于指向新表用的,其他线程遇到这个类会主动跳过这个类,因 为这个类要么就是扩容迁移正在进行,要么就是已经完成扩容迁移,也就是这个类要保证线 程安全,再进行操作。
2、advance:这个变量是用于提示代码是否进行推进处理,也就是当前桶处理完,处理下一个 桶的标识
3、finishing:这个变量用于提示扩容是否结束用的
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//将n>>>3 除以cup核心数,如果小于16 则就使用16
// 这里的目的是让每个 CPU 处理的桶一样多,避免出现转移任务不均匀的现象,如果桶较少
的话,默认一个 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 {
//新加一个数组 将n*2
@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;//扩容失败,将sizeCtl只为最大
return;
}
nextTable = nextTab;
transferIndex = n;
}
int nextn = nextTab.length;//新表的长度
// 创建一个 fwd 节点,表示一个正在被迁移的 Node,并且它的 hash 值为-1(MOVED),也
就是前面我们在讲 putval 方法的时候,会有一个判断 MOVED 的逻辑。它的作用是用来占位,表示
原数组中位置 i 处的节点完成迁移以后,就会在 i 位置设置一个 fwd 来告诉其他线程这个位置已经
处理过了,具体后续还会在讲
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);//标志位
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
// 首次推进为 true,如果等于 true,说明需要再次推进一个下标(i--),反之,如果是
false,那么就不能推进下标,需要将当前的下标处理完毕才能继续推进
//通过 for
自循环处理每个槽位中的链表元素,默认 advace
为真,通过 CAS
设置
transferIndex
属性值,并初始化 i
和 bound
值, i
指当前处理的槽位序号, bound
指需要处理
的槽位边界,先处理槽位 15
的节点;
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
//i表示下一个待处理的桶
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {//所有桶被分配完毕
i = -1;
advance = false;
}
通过 cas 来修改 TRANSFERINDEX,为当前线程分配任务,处理的节点区间为
(nextBound,nextIndex)->(0,15)
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;//0
i = nextIndex - 1;//15
advance = false;
}
}
i<0 说明已经遍历完旧的数组,也就是当前线程已经处理完所有负责的 bucket
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {//扩展完
nextTable = null;//删除成员变量
table = nextTab;//更新table数组
sizeCtl = (n << 1) - (n >>> 1);//(32*0.75)
return;
}
//sizeCtl在迁移前会设置为(rs << RESIZE_STAMP_SHIFT) + 2
//每个线程参与扩容则sizeCtl+1
//线程退出扩容则-1
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
//第一个扩容的线程,执行 transfer 方法之前,会设置 sizeCtl =
//(resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2)
//后续帮其扩容的线程,执行 transfer 方法之前,会设置 sizeCtl = sizeCtl+1
//每一个退出 transfer 的方法的线程,退出之前,会设置 sizeCtl = sizeCtl-1
那么最后一个线程退出时:必然有
//sc == (resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2),即 (sc - 2)
//== resizeStamp(n) << RESIZE_STAMP_SHIFT
// 如果 sc - 2 不等于标识符左移 16 位。如果他们相等了,说明没有线程在
帮助他们扩容了。也就是说,扩容结束了。
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);
表示该位置已经完成了迁移,也就是如果线程 A 已经处理过这个节点,那么线程 B 处理这个节点
时,hash 值一定为 MOVED
else if ((fh = f.hash) == MOVED)//
advance = true; // already processed
...
扩容的图解
ConcurrentHashMap支持并发扩容,实现方式时把node进行拆分,让每个线程处理 自己的区域,假设table数组总长度是64,默认情况下,那么每个线程可以分到16个bucket。 然后每个线程处理的范围,按倒序来做迁移
通过for循环自行处理每个槽位的中的链表元素,,默认advace为真,通过CAS设置transferIndex 属性值,并初始化i和bound值, i指当前处理的槽位序号,bound指需要处理的槽位边界, 先处理槽位31的节点; (bound,i) =(16,31) 从31的位置往前推动。
假设这个时候ThreadA在进行transfer,那么逻辑图表示如下
在当前假设条件下,槽位 15 中没有节点,则通过 CAS 插入在第二步中初始化的 ForwardingNode节点,用于告诉其它线程该槽位已经处理过了;
sizeCtl扩容退出机制
在扩容操作transfer的第2414行,代码如下
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
每存在一个线程执行扩容操作,就通过cas执行sc-1,接着判断(sc-2) !=resizeStamp(n) << RESIZE_STAMP_SHIFT ;如果相等,表示当前整个扩容操作的最后一个线程,则表示扩容结束,如果不相等,还需要这样操作,防止不同扩容之间出现相同的sizeCtl,可以避免sizeCtl重叠的问题;
数据迁移
synchronized (f) {//对该节点加锁,开始处理数组该位置的迁移
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;//ln表示低位,ln:表示高位
//下面代码是把链表拆分为两部分,高位和地位,0在高位,1在地位。
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);将低位的链表放在 i 位置也就是不动
setTabAt(nextTab, i + n, hn);;//将高位链表放在 i+n 位置
setTabAt(tab, i, fwd);//标记已处理
advance = true;
}
...
高低位原理分析
ConcurrentHashMap在做链表迁移时,采用高低位来实现;
1.如何实现高低位链表的分区
假设队列是这样的,
第14个槽位链表长度大于8时,且数组长度为16,优先通过扩容来解决链表过长;
假如当前线程正在处理槽位为14的节点,它是一个链表结构,在代码中,首先定义两个变量 节点 ln 和 hn,实际就是 lowNode 和 HighNode,分别保存 hash 值的第 x 位为 0 和不等于 0的节点 ,通过fn&n可以将这个链表分为两类,,A类是hash值的第X位为0,B类是hash值 的第 x位为不等于 0(至于为什么要这么区分,稍后分析),并且通过lastRun记录最后要处 理的节点。最终要达到的目的是,A 类的链表保持位置不动,B 类的链表为 14+16(扩容增加 的长度)=30
我们把 14 槽位的链表单独伶出来,我们用蓝色表示 fn&n=0 的节点,假如链表的分类是这 样
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
通过上面这段代码遍历,会记录 runBit 以及 lastRun,按照上面这个结构,那么 runBit 应该 是蓝色节点,lastRun应该是第6个节点 接着,再通过这段代码进行遍历,生成ln链以及hn链
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把hn链表放到i+n位上,并且设置当前节点为fwd,表示已经被当前线程迁移完了 ;
迁移完后的图形
为什么要实现高低位划分?
这个要根据ConcurrentHashMap计算下标的来看,
(f = tabAt(tab, i = (n - 1) & hash)) == null ;
通过(n-1) & hash来获得在table中的数组下标来获取节点数据,【&运算是二进制运算符,1 & 1=1,其他都为0】
例如:
数组长16,二进制是【0001 0000】,减一以后的二进制是 【0000 1111】 假如某个key的hash值=9,对应的二进制是【0000 1001】,那么按照(n-1) & hash的算法 0000 1111 & 0000 1001 =0000 1001 , 运算结果是9 ,
当我们扩容以后,16变成了32,那么(n-1)的二进制是 【0001 1111】 仍然以hash值=9的二进制计算为例 0001 1111 & 0000 1001 =0000 1001 ,运算结果仍然是9 ,
我们换一个数字,假如某个key的hash值是20,对应的二进制是【0001 0100】,仍然按照(n-1) & hash 算法,分别在16为长度和32位长度下的计算结果 16位: 0000 1111 & 0001 0100=0000 0100 32位: 0001 1111 & 0001 0100 =0001 0100.
同一个结果在扩容前和扩容后的位置不一样,而使用高低位的迁移方式,就是解决这个问题. 大家可以看到,16位的结果到32位的结果,正好增加了16. 比如 20 & 15=4 、20 & 31=20 ; 4-20 =16 比如 60 & 15=12 、60 & 31=28; 12-28=16 所以对于高位,直接增加扩容的长度,当下次hash获取数组位置的时候,可以直接定位到对应的位置。
这个地方又是一个很巧妙的设计,直接通过高低位分类以后,就使得不需要在每次扩容的时候来重新计 算hash,极大提升了效率;