ConcurrentHashMap如何实现扩容机制之学习笔记

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,极大提升了效率;

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值