JUC之ConcurrentHashMap(八)

一、Hash表 

          1. 什么是Hash表

                 hash函数就是根据key计算出应该存储地址的位置,而哈希表是基于哈希函数建立的一种查找表

          2.  hash函数设计的考虑因素

  •   计算散列地址所需要的时间(即hash函数本身不要太复杂)
  •   关键字的长度
  •   表长
  •   关键字分布是否均匀,是否有规律可循
  •   设计的hash函数在满足以上条件的情况下尽量减少冲突

           3.哈希冲突的解决方案

              不管hash函数设计的如何巧妙,总会有特殊的key导致hash冲突,特别是对动态查找表来说。hash函数解决冲突的方法有以下几个常用的方法

                    A.开放定制法(线性探索)
                    B.链地址法(HashMap)
                    C.公共溢出区法建立一个特殊存储空间,专门存放冲突的数据。此种方法适用于数据和冲突较少的情况。
                    D.再散列法(布隆过滤器)准备若干个hash函数,如果使用第一个hash函数发生了冲突,就使用第二个hash函数,第二个也冲突,使用第三个……     

   开放定址法

       当一个关键字和另一个关键字发生冲突时,使用某种探测技术在Hash表中形成一个探测序列,然后沿着这个探测序列依次查找下去,当碰到一个空的单元时,则插入其中。基本公式为:hash(key) = (hash(key)+di)mod TableSize。其中di为增量序列,TableSize为表长。根据di的不同我们又可以分为线性探测,平方(二次)探测,双散列探测。 

 1)线性探测 
以增量序列 1,2,……,(TableSize -1)循环试探下一个存储地址,即di = i。如果table[index+di]为空则进行插入,反之试探下一个增量。但是线性探测也有弊端,就是会造成元素聚集现象,降低查找效率。具体例子如下图: 

 

 

特别对于开放定址法的删除操作,不能简单的进行物理删除,因为对于同义词来说,这个地址可能在其查找路径上,若物理删除的话,会中断查找路径,故只能设置删除标志。

//插入函数,利用线性探测法 
bool Insert_Linear_Probing(int num){
    //哈希表已经被装满,则不在填入 
    if(this->size == this->length){
        return false;
    }
    int index = this->hash(num);
    if(this->data[index] == MAX){
        this->data[index] = num;
    }else{
        int i = 1;
        //寻找合适位置 
        while(this->data[(index+i)%this->length] != MAX){
            i++;
        }
        index = (index+i)%this->length; 
        this->data[index] = num;
    }
    if(this->delete_flag[index] == 1){//之前设置为删除 
        this->delete_flag[index] = 0; 
    }
    this->size++;
    return true;
}

链地址法

 HashMap即是采用了链地址法,也就是数组+链表的方式,HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。  

//HashMap的主干数组,可以看到就是一个Entry数组,初始值为空数组{},主干数组的长度一定是2的次幂,至于为什么这么做,后面会有详细分析。
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

Entry是HashMap中的一个静态内部类。代码如下

static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;//存储指向下一个Entry的引用,单链表结构
        int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算

        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        } 

所以,HashMap的整体结构如下  

 

 

 

 

 

 

 

 简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。

二.ConcurrentHashMap

      ConcurrentHashMap是Java并发包中提供的一个线程安全且高效的HashMap实现,ConcurrentHashMap在并发编程的场景中使用频率非常之高,下面我们来分析下ConcurrentHashMap的实现原理,并对其实现原理进行分析 。

     众所周知,哈希表是种非常高效,复杂度为O(1)的数据结构,在Java开发中,我们最常见到最频繁使用的就是HashMap和HashTable,但是在线程竞争激烈的并发场景中使用都不够合理。

    HashMap :先说HashMap,HashMap是线程不安全的,在并发环境下,可能会形成环状链表(多线程扩容时可能造成),导致get操作时,cpu空转,  所以,在并发环境中使用HashMap是非常危险的。

  HashTable : HashTable和HashMap的实现原理几乎一样,差别无非是1.HashTable不允许key和value为null;2.HashTable是线程安全的。但是HashTable线程安全的策略实现代价却太大了,简单粗暴,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。

 

      HashTable性能差主要是由于所有操作需要竞争同一把锁,而如果容器中有多把锁,每一把锁锁一段数据比喻[11],这样在多线程访问时不同段的数据时,就不会存在锁竞争了,这样便可以有效地提高并发效率。这就是ConcurrentHashMap所采用的"分段锁"思想。java1.7后的CHM中把每个数组叫Segment,每个segment下面存的是默认16段的Hashhenery,Hashhenery解决充突是在Hashhenery下面挂载链表,我们就画图说明下分段锁

 

 

ConcurrentHashMap初始化时,计算出Segment数组的大小ssize和每个SegmentHashEntry数组的大小cap,并初始化Segment数组的第一个元素;其中ssize大小为2的幂次方默认为16cap大小也是2的幂次方最小值为2,最终结果根据初始化容量initialCapacity进行计算,计算过程如下

if (c * ssize < initialCapacity)
    ++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
    cap <<= 1;

因为Segment继承了ReentrantLock,所有segment是线程安全的,但是在1.8中放弃了Segment分段锁的设计,使用的是Node+CAS+Synchronized来保证线程安全性,而且这样设计的好处是层级降低了,锁的粒度更小了,可以说是一种优化,比喻锁的是2,那么他锁的就只是发生冲突的2下面的链表,而不像1.7样,是锁整个HashEntry;而且1.8中对链表的长度进行了优化,在1.7的链表中链表查询的复杂度是O(n),但是在1.8中为了解决这问题引入了红黑树,在1.8中当我们链表长度大于8时并且数组长度大于64时,就会发生一个链表的转换,会把单向链表转换成红黑树。

 

 

  put操作

     在1.7 中当执行put方法插入数据的时候,根据key的hash值,在Segment数组中找到对应的位置如果当前位置没有值,则通过CAS进行赋值,接着执行Segmentput方法通过加锁机制插入数据;假如有线程AB同时执行相同Segmentput方法

线程A 执行tryLock方法成功获取锁,然后把HashEntry对象插入到相应位置

线程B 尝试获取锁失败,则执行scanAndLockForPut()方法,通过重复执行tryLock()方法尝试获取锁

在多处理器环境重复64次,单处理器环境重复1次,当执行tryLock()方法的次数超过上限时,则执行lock()方法挂起线程B
 
当线程A执行完插入操作时,会通过unlock方法施放锁,接着唤醒线程B继续执行 

但在1.8 中执行put方法插入数据的时候,根据key的hash值在Node数组中找到相应的位置如果当前位置的 Node还没有初始化,则通过CAS插入数据

else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
    //如果当前位置的`Node`还没有初始化,则通过CAS插入数据
    if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
        break;                   // no lock when adding to empty bin
}

如果当前位置的Node已经有值,则对该节点加synchronized锁,然后从该节点开始遍历,直到插入新的节点或者更新新的节点  

if (fh >= 0) {
    binCount = 1;
    for (Node<K,V> 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<K,V> pred = e;
        if ((e = e.next) == null) {
            pred.next = new Node<K,V>(hash, key, value, null);
            break;
        }
    }
}

如果当前节点是TreeBin类型,说明该节点下的链表已经进化成红黑树结构,则通过putTreeVal方法向红黑树中插入新的节点  

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,说明put操作对数据产生了影响,如果当前链表的节点个数达到了8个,则通过treeifyBin方法将链表转化为红黑树  

 

 size操作

       在1.7中统计每个segment对象中的元素个数,然后进行累加,但是这种方式计算出来的结果不一定准确,因为在计算后面的segment的元素个数时,前面计算过了的segment可能有数据的新增或删除;他的计算方式是:先采用不加锁的方式,连续计算两次;如果两次结果相等,说明计算结果准确,如果两次结果不相等,说明计算过程中出现了并发新增或者删除操作于是给每个segment加锁,然后再次计算

try {
    for (;;) {
        if (retries++ == RETRIES_BEFORE_LOCK) {
            for (int j = 0; j < segments.length; ++j)
                ensureSegment(j).lock(); // force creation
        }
        sum = 0L;
        size = 0;
        overflow = false;
        for (int j = 0; j < segments.length; ++j) {
            Segment<K,V> seg = segmentAt(segments, j);
            if (seg != null) {
                sum += seg.modCount;
                int c = seg.count;
                if (c < 0 || (size += c) < 0)
                    overflow = true;
            }
        }
        if (sum == last)
            break;
        last = sum;
    }
} finally {
    if (retries > RETRIES_BEFORE_LOCK) {
        for (int j = 0; j < segments.length; ++j)
            segmentAt(segments, j).unlock();
    }
}

  但是在1.8中使用一个volatile类型的变量baseCount记录元素的个数,当新增或者删除节点的时候会调用,addCount()更新baseCount

    private final void addCount(long x, int check) {
        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) { int rs = resizeStamp(n); if (sc < 0) { 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(); } } }

  初始化时counterCells为空,在并发量很高时,如果存在两个线程同时执行CAS修改baseCount值,则失败的线程会继续执行方法体中的逻辑,使用CounterCell记录元素个数的变化,如果CounterCell数组counterCells为空,调用fullAddCount()方法进行初始化,并插入对应的记录数,通过CAS设置cellsBusy字段,只有设置成功的线程才能初始化CounterCell数组

else if (cellsBusy == 0 && counterCells == as &&
         U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
    boolean init = false;
    try {                           // Initialize table
        if (counterCells == as) {
//初始化数组长度 CounterCell[] rs = new CounterCell[2];
//表示元素个数 rs[h & 1] = new CounterCell(x); counterCells = rs; init = true; } } finally { cellsBusy = 0; } if (init) break; }

  因为初始长度就只有两个,如果线程竞争很激烈的话,长度不够就要扩容,下面就是扩容的代码

 else if (cellsBusy == 0 &&
                         U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
                    try {
                        if (counterCells == as) {// Expand table unless stale
                            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
                }

  

如果通过CAS设置cellsBusy字段失败的话,则继续尝试通过CAS修改baseCount字段,如果修改baseCount字段成功的话,就退出循环

else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
    break; 

所以在1.8中的size实现比1.7简单多,因为元素个数保存baseCount中,部分元素的变化个数保存在CounterCell数组中,通过累加baseCountCounterCell数组中的数量,即可得到元素的总个数。实现如下:

public int size() {
    long n = sumCount();
    return ((n < 0L) ? 0 :
            (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
            (int)n);
}
​
final long sumCount() {
    CounterCell[] as = counterCells; CounterCell a;
    long sum = baseCount;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}

三.transfer扩容

       

判断是否需要扩容,也就是当更新后的键值对总数 baseCount >= 阈值 sizeCtl 时,进行rehash,还有就是前面说时当链表长度超过一定值是会转成红黑暗树时也会扩容;这里面会有两个逻辑
    
1. 如果当前正在处于扩容阶段,则当前线程会加入并且协助扩容
2. 如果当前没有在扩容,则直接触发扩容操作
我们再次进入我们前面看的addCount方法中去;
      //如果 binCount>=0,标识需要检查扩容
 if (check >= 0) {
            Node<K,V>[] tab, nt; int n, sc;
                               //s 标识集合大小,如果集合大小大于或等于扩容阈值(默认值的 0.75)
                           //并且 table 不为空并且 table 的长度小于最大容量
            while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
                   (n = tab.length) < MAXIMUM_CAPACITY) {
                       //这里是生成一个唯一的扩容戳 
                int rs = resizeStamp(n);
//rc值等于扩容阈值,第一次一定不小于 if (sc < 0) {
                                        //sc<0,也就是 sizeCtl<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;
                                    //当前线程尝试帮助此次扩容,如果成功,则调用 transfer
                    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                        transfer(tab, nt);
                }
                                        // 如果当前没有在扩容,那么 rs 肯定是一个正数,通过 rs<<RESIZE_STAMP_SHIFT 将 sc 设置为一个负数,+2 表示有一个线程在执行扩容
                else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                             (rs << RESIZE_STAMP_SHIFT) + 2))
                    transfer(tab, null);
                                // 重新计数,判断是否需要开启下一轮扩容
                s = sumCount();
            }
        }

  

resizeStamp(n)这块逻辑要理解起来,也有一点复杂。resizeStamp 用来生成一个和扩容有关的扩容戳,具体有什么作用呢?我们基于它的实现来做一个分析
static final int resizeStamp(int n) {
 return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}

Integer.numberOfLeadingZeros 这个方法是返回无符号整数 n 最高位非 0 位前面的 0 的个数;

比如 10 的二进制是 0000 0000 0000 0000 0000 0000 0000 1010

那么这个方法返回的值就是 28

根据 resizeStamp 的运算逻辑,我们来推演一下,假如 n=16,那么 resizeStamp(16)=32795转化为二进制是
[0000 0000 0000 0000 1000 0000 0001 1100]
接着再来看,当第一个线程尝试进行扩容的时候,会执行下面这段代码
U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2)
rs 左移 16 位,相当于原本的二进制低位变成了高位 1000 0000 0001 1100 0000 0000 0000 0000
然后再+2 =1000 0000 0001 1100 0000 0000 0000 0000+10=1000 0000 0001 1100 0000 0000 0000 0010
高 16 位代表扩容的标记、低 16 位代表并行扩容的线程数
这样来存储有什么好处呢?
   1. 首先在 CHM 中是支持并发扩容的,也就是说如果当前的数组需要进行扩容操作,可以由多个线程来共同负责
   2. 可以保证每次扩容都生成唯一的生成戳,每次新的扩容,都有一个不同的 n,这个生成戳就是根据 n 来计算出来的一个数字,n 不同,这个数字也不同
第一个线程尝试扩容的时候,为什么是+2
      因为 1 表示初始化,2 表示一个线程在执行扩容,而且对 sizeCtl 的操作都是基于位运算的,所以不会关心它本身的数值是多少,只关心它在二进制上的数值,而 sc + 1 会在低 16 位上加 1
 

transfer

 上面的解析完后下面我们走进正真的扩容代码;扩容是 ConcurrentHashMap 的精华之一,扩容操作的核心在于数据的转移,在单线程环境下数据的转移很简单,无非就是把旧数组中的数据迁移到新的数组。但是这在多线程环境下,

在扩容的时候其他线程也可能正在添加元素,这时又触发了扩容怎么办?可能大家想到的第一个解决方案是加互斥锁,把转移过程锁住,虽然是可行的解决方案,但是会带来较大的性能开销。因为互斥锁会导致所有访问临界区的线程陷入到阻塞状态,持有锁的线程耗时越长,其他竞争线程就会一直被阻塞,导致吞吐量较低。而且还可能导致死锁。而 ConcurrentHashMap 并没有直接加锁,而是采用 CAS 实现无锁的并发同步策略,最精华的部分是它可以利用多线程来进行协同扩容简单来说,它把 Node 数组当作多个线程之间共享的任务队列,然后通过维护一个指针来划分每个线程锁负责的区间,每个线程通过区间逆向遍历来实现扩容,一个已经迁移完的bucket 会被替换为一个 ForwardingNode 节点,标记当前 bucket 已经被其他线程迁移完了。
下面我们看下源码是怎么玩的:
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 相当于 n/8) 然后除以 CPU 核心数。如果得到的结果小于 16,那么就使用 16
// 这里的目的是让每个 CPU 处理的桶一样多,避免出现转移任务不均匀的现象,如果桶较少的话,默认一个 CPU(一个线程)处理 16 个桶,也就是长度为 16 的时候,扩容的时候只会有一个线程来扩容
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) <
MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
//nextTab 未初始化,nextTab 是用来扩容的 node 数组
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
//新建一个 n<<1 原始 table 大小的 nextTab,也就是 32
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;//赋值给 nextTab
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE; //扩容失败,sizeCtl 使用 int 的最大值
return;
}
nextTable = nextTab; //更新成员变量
transferIndex = n;//更新转移下标,表示转移时的下标
}
int nextn = nextTab.length;//新的 tab 的长度
// 创建一个 fwd 节点,表示一个正在被迁移的 Node,并且它的 hash 值为-1(MOVED),也就是前面我们在讲 putval 方法的时候,会有一个判断 MOVED 的逻辑。它的作用是用来占位,表示原数组中位置 i 处的节点完成迁移以后,就会在 i 位置设置一个 fwd 来告诉其他线程这个位置已经处理过了,
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
// 首次推进为 true,如果等于 true,说明需要再次推进一个下标(i--),反之,如果是false,那么就不能推进下标,需要将当前的下标处理完毕才能继续推进
boolean advance = true;
//判断是否已经扩容完成,完成就 return,退出循环
boolean finishing = false; // to ensure sweep before committing
nextTab
通过 for 自循环处理每个槽位中的链表元素,默认 advace 为真,通过 CAS 设置transferIndex 属性值,并初始化 i 和 bound 值,i 指当前处理的槽位序号,bound 指需要处理的槽位边界,先处理槽位 15 的节点;
for (int i = 0, bound = 0;;) {
// 这个循环使用 CAS 不断尝试为当前线程分配任务
// 直到分配成功或任务队列已经被全部分配完毕
// 如果当前线程已经被分配过 bucket 区域
// 那么会通过--i 指向下一个待处理 bucket 然后退出该循环
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
//--i 表示下一个待处理的 bucket,如果它>=bound,表示当前线程已经分配过
bucket 区域
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {//表示所有 bucket 已经
被分配完毕
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=24)
return;
}
// sizeCtl 在迁移前会设置为 (rs << RESIZE_STAMP_SHIFT) + 2 (详细介
绍点击这里)
// 然后,每增加一个线程参与迁移就会将 sizeCtl 加 1,
// 这里使用 CAS 操作对 sizeCtl 的低 16 位进行减 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;
// 如果相等,扩容结束了,更新 finising 变量
finishing = advance = true;
// 再次循环检查一下整张表
i = n; // recheck before commit
}
}
// 如果位置 i 处是空的,没有任何节点,那么放入刚刚初始化的 ForwardingNode ”空节点“
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 的 ABA 问题导致的扩容重叠的情况 
下面我们简单看下数据迁移阶段的实现分析 
synchronized (f) {//对数组该节点位置加锁,开始处理数组该位置的迁移工作
 if (tabAt(tab, i) == f) {//再做一次校验
 Node<K,V> ln, hn;//ln 表示低位, hn 表示高位;接下来这段代码的作用
是把链表拆分成两部分,0 在低位,1 在高位
 if (fh >= 0) {//下面部分代码原理点击这里
 int runBit = fh & n; 
 Node<K,V> lastRun = f;
//遍历当前 bucket 的链表,目的是尽量重用 Node 链表尾部的一部分
 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) {如果最后更新的 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); // 把旧 table 的 hash 桶中放置转发节
点,表明此 hash 桶已经被处理
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 也就是 14+16 的位置,ln 链保持原来的位置不动。并且设置当前节点为 fwd,表示已经被当前线程迁移完了

setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);  
迁移完成以后的数据分布如下 

在上面设计中分高低位的原因要从ConcurrentHashMap 的根据下标获取对象的算法来看,在 putVal 方法中 1018 行 ,通过(n-1) & hash 来获得在 table 中的数组下标来获取节点数据,【&运算是二进制运算符,1& 1=1,其他都为 0】

(f = tabAt(tab, i = (n - 1) & hash)) == null

  假设我们的 table 长度是 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
从结果来看,同样一个 hash 值,在扩容前和扩容之后,得到的下标位置是不一样的,这种情况当然是不允许出现的,所以在扩容的时候就需要考虑,而使用高低位的迁移方式,就是解决这个问题.大家可以看到,16 位的结果到 32 位的结果,正好增加了 16.
比如 20 & 15=4 、20 & 31=20 ; 4-20 =16
比如 60 & 15=12 、60 & 31=28; 12-28=16
所以对于高位,直接增加扩容的长度,当下次 hash 获取数组位置的时候,可以直接定位到对应的位置。这个地方又是一个很巧妙的设计,直接通过高低位分类以后,就使得不需要在每次扩容的时候来重新计算 hash,极大提升了效率。
 
posted @ 2020-10-06 22:27  童话述说我的结局  阅读(224)  评论(0编辑  收藏  举报