ConcurrentHashMap源码分析

目录
    # 源码
    ## put()阶段
    ```java
    //ConcurrentHashMap.putVal()
    final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;😉 {
    Node<K,V> f; int n, i, fh;
    //当tab为空时,先初始化
    if (tab == null
    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 {
    //...略
    }
    }
    //进行size+1的操作 同时判断是否需要扩容
    addCount(1L, binCount);
    return null;
    }
    ```
    ### 数组初始化
    put(object key)时,若数组未初始化,则先进行数组初始化,这个方法比较简单,就是初始化一个合适大小的数组。
    ```java
    private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null
    //sizeCtl<0 表示有其他线程正在进行初始化
    if ((sc = sizeCtl) < 0)
    Thread.yield(); // 出让CPU时间片
    //CAS操作当前线程是否能够成功设置sizeCtl为-1 抢占
    else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
    try {
    //当CAS操作成功后,需要再次判断tab是否为空防止并发设值
    if ((tab = table) == null
    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
    @SuppressWarnings("unchecked")
    Node<K,V>[] nt = (Node<K,V>[])new Node[n];
    table = tab = nt;
    sc = n - (n >>> 2);//sc为扩容阈值
    }
    } finally {
    sizeCtl = sc;
    }
    break;
    }
    }
    return tab;
    }
    ```
    sizeCtl是一个标记位
    ```java
    private transient volatile int sizeCtl;
    ```
    * 当为0时,表示Node 数组还没有被初始化;
    * 大于0时,表示已初始化,下次扩容的阈值大小;
    * 当为-1时,表示正在被某一线程进行初始化;
    * 当为-N N!=1 表示有N-1个线程正在进行扩容操作,这里不是简单的理解成 n 个线程。
    ### tabAt()
    HashMap中一样,也是通过(n-1) & h方式来计算得到数组下标的位置。
    ```java
    else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
    //...略
    }
    ```
    那么按照我们正常的逻辑得到数组下标后,直接tab[i]不就行了么,为什么还需要通过一个方法来获取值呢?
    ```java
    static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
    }
    ```
    getObjectVolatile()看到volatile关键字,我们就会想到可见性;因为对于volatile的写操作happens-beforevolatile的读操作,因此其他线程对于table的修改对于get()读取操作是可见的。
    虽然table数组本身有volatile修饰,但是“volatile 的数组只针对数组的引用具有volatile 的语义,而不是它的元素”
    所以如果有其他线程对这个数组的元素进行写操作,那么当前线程来读的时候不一定能读到最新的值。出于性能考虑,直接通过unsafe来操作。
    ### addCount()
    put(Object key)或者remove(Object key)后,需要对Map的容量大小进行调整,同时判断是否需要扩容。
    那么高并发下size()的是怎么设计的呢?
    ```java
    // 从 putVal 传入的参数是 1, binCount,binCount 默认是0,只有 hash 冲突了才会大于 1.且他的大小是链表的长度(如果不是红黑数结构的话)。
    private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    // 如果counterCells不是空 或者 修改 baseCount 失败(表示存在并发冲突)
    if ((as = counterCells) != null
    !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
    CounterCell a; long v; int m;
    boolean uncontended = true;
    // 如果counterCells是空(尚未出现并发)或如果随机取余一个数组位置为空 修改这个槽位的变量失败(出现并发了)
    if (as == null
    (a = as[ThreadLocalRandom.getProbe() & m]) == null
    !(uncontended =
    U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
    //fullAddCount 主要是用来初始化 CounterCell,来记录元素个数,里面包含扩容,初始化等操作
    fullAddCount(x, uncontended);
    return;
    }
    if (check <= 1)
    return;
    s = sumCount();
    }
    // 如果需要检查,检查是否需要扩容,在 putVal 方法调用时,默认就是要检查的。
    if (check >= 0) {
    Node<K,V>[] tab, nt; int n, sc;
    // 如果map.size() 大于 sizeCtl(达到扩容阈值需要扩容) 且
    // table 不是空;且 table 的长度小于 1 << 30。(可以扩容)
    while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
    (n = tab.length) < MAXIMUM_CAPACITY) {
    // 根据 length 得到一个标识
    int rs = resizeStamp(n);
    // 如果正在扩容
    if (sc < 0) {
    // 如果 sc 的低 16 位不等于 标识符(校验异常 sizeCtl 变化了)
    // 如果 sc == 标识符 + 1 (扩容结束了,不再有线程进行扩容)(默认第一个线程设置 sc ==rs 左移 16 位 + 2,当第一个线程结束扩容了,就会将 sc 减一。这个时候,sc 就等于 rs + 1)
    // 如果 sc == 标识符 + 65535(帮助线程数已经达到最大)
    // 如果 nextTable == null(结束扩容了)
    // 如果 transferIndex <= 0 (转移状态变化了)
    // 结束循环
    if ((sc >>> RESIZE_STAMP_SHIFT) != rs
    sc == rs + MAX_RESIZERS
    transferIndex <= 0)
    break;
    // 如果可以帮助扩容,那么将 sc 加 1. 表示多了一个线程在帮助扩容
    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
    // 扩容
    transfer(tab, nt);
    }
    // 如果不在扩容,将 sc 更新:标识符左移 16 位 然后 + 2. 也就是变成一个负数。高 16 位是标识符,低 16 位初始是 2.
    else if (U.compareAndSwapInt(this, SIZECTL, sc,
    (rs << RESIZE_STAMP_SHIFT) + 2))
    // 更新 sizeCtl 为负数后,开始扩容。
    transfer(tab, null);
    s = sumCount();
    }
    }
    }
    ```
    * 当counterCells为空时,尝试直接通过baseCount+1方式来累加size;若counterCells不为空时,直接采用counterCells来计数。
    然就是判断是否需要调用fullAddCount()方法,调用完这个方法后,直接return。因为在fullAddCount()方法中,会对counterCells进行初始化,此时自然就能够进行size大小的维护动作。
    ```java
    //1.计数表为空则直接调用 fullAddCount
    //2.从计数表中随机取出一个数组的位置为空,直接调用 fullAddCount
    //3.通过CAS 修改 CounterCell 随机位置的值,如果修改失败说明出现并发情况 调用 fullAndCoun。
    if (as == null
    (a = as[ThreadLocalRandom.getProbe() & m]) == null
    !(uncontended =
    U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
    //fullAddCount 主要是用来初始化 CounterCell,来记录元素个数,里面包含扩容,初始化等操作
    fullAddCount(x, uncontended);
    return;
    }
    ```
    前面已经判断了,进入这里的条件是
    * counterCells为空,但是通过CAS操作更新baseCount失败,意味着存在并发
    * counterCells不为空
    counterCells说明
    ```properties
    一般的集合记录size大小,直接定义一个size变量,每次进行加减操作即可;为什么ConcurrentHashMap要用采用counterCells数组来记录元素的个数呢?
    问题还是出在并发上,ConcurrentHashMap 是并发集合,如果用一个成员变量来统计元素个数,为了保证并发情况下共享变量的的安全性,势必会需要通过加锁或者自旋来实现,如果竞争比较激烈的情况下,size的设置上会出现比较大的冲突反而影响了性能,所以在ConcurrentHashMap采用分片来记录元素个数。
    ```
    ```java
    private transient volatile CounterCell[] counterCells;//CounterCell数组作为成员变量
    @sun.misc.Contended static final class CounterCell {
    volatile long value;//CounterCell对象只有一个成员变量就是整型的数值记录每片的大小
    CounterCell(long x)
    }
    //当需要累计时,则对整个counterCells数组进行累加求和
    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;
    }
    ```
    ### fullAddCount()
    fullAddCount() 主要是用来初始化 CounterCell来记录元素个数,里面包含CounterCell的扩容,初始化等操作。
    ```java
    private final void fullAddCount(long x, boolean wasUncontended) {
    int h;
    if ((h = ThreadLocalRandom.getProbe()) == 0) {
    ThreadLocalRandom.localInit(); // force initialization
    h = ThreadLocalRandom.getProbe();
    wasUncontended = true;//刚开始初始化当前线程的随机值,所以将未冲突标记位为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) {
    if ((a = as[(n - 1) & h]) == null) {//获得当线程的在counterCells数组的下标值
    if (cellsBusy == 0) { //cellsBusy=0 表示 counterCells 不在初始化或者扩容状态
    CounterCell r = new CounterCell(x); // 构造一个CounterCell对象r
    if (cellsBusy == 0 &&//CAS设置cellsBusy的值,防止其他线程进来
    U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
    boolean created = false;
    try { //将初始化的 r 对象的元素个数放在对应下标的位置
    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; // 说明counterCells数组中计算出来的下标位置不为空,进行下一次循环
    }
    }
    collide = false;
    }
    else if (!wasUncontended) // 入参 说明在addCount中通过CAS操作将增加的size值加到随机的下标中失败 且当前线程的probe值不为空
    wasUncontended = true; // 直接重新设置为true 同时在下面会更新hash值 来执行下一次循环
    //由于指定下标位置的 cell 值不为空,则直接通过 cas 进行原子累加,如果成功,则直接退出
    else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
    break;
    //如果已经有其他线程建立了新的 counterCells 或者 CounterCells 大于 CPU 核心数 (很巧妙,线程的并发数不会超过 cpu 核心数)
    else if (counterCells != as
    collide = false; //设置当前线程的循环失败不进行扩容
    else if (!collide)
    collide = true;
    else if (cellsBusy == 0 &&//进入到这里,说明竞争比较大,所以可以加大counterCells数组的大小
    U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
    try {
    if (counterCells == as) {//再次确认没有其他线程扩容成功
    CounterCell[] rs = new CounterCell[n << 1];//扩容为原来的2倍
    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);
    }
    //再次确认标记位cellsBusy为0 且成功将cellsBusy设置为1 表示正在初始化counterCells
    else if (cellsBusy == 0 && counterCells == as &&
    U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
    boolean init = false;
    try {
    if (counterCells == as) {
    CounterCell[] rs = new CounterCell[2];//初始化容量为2
    rs[h & 1] = new CounterCell(x);
    counterCells = rs;
    init = true;
    }
    } finally {
    cellsBusy = 0;//初始化完毕,将cellsBusy重新置为0
    }
    if (init)
    break;
    }
    //竞争激烈 其他线程占据着counterCells数组,尝试将值累加至baseCount 若不成功,则进入下一次循环
    else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
    break; // Fall back on using base
    }
    }
    ```
    上述中,使用了ThreadLocalRandom.getProbe()来进行哈希线程,从而得到在counterCells数组中的下标位置;在ConcurrentHashMap中,放置key的元素到哪个数组中也是通过哈希值计算的,那么他们有什么不同呢?
    ```
    map中计算key在数组中的下标位置,是一次的程序运行期间是固定的,每次放置相同的元素要保证命中到同一个下标,不然就有问题了。
    counterCells计数器中,则只为了避免多个线程写入同一个下标,因此尽量保证每次线程得到的哈希值都不一样,避免冲突。
    ```
    * ThreadLocalRandom.getProbe()
    ```java
    ThreadLocalRandom.getProbe()的作用是产生一个随机数,为什么不使用Random呢?
    其实ThreadLocalRandom也是继承自Random,主要是因为Random的关键是随机种子,如果多线程并发情况下,对随机种子进行CAS竞争操作,对效率是一个影响,所以这里为了避免竞争,通过ThreadLocal方式来隔离每个线程的Random。
    ```
    最后,我们来总结下addCount()统计元素个数的过程:
    image-20210428072943244
    ## transfer扩容阶段
    addCount()方法中,除了需要增加size大小以外,还需要判断当前Map是否需要进行扩容以提高查询的效率。
    ```java
    private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    //...略 该部分为统计size的大小 初始化以及对counterCells的扩容
    if (check >= 0) {//检查是否需要扩容 默认都需要
    Node<K,V>[] tab, nt; int n, sc;
    //当size容量大于阈值且table不为空时且容量小于最大容量时
    while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
    (n = tab.length) < MAXIMUM_CAPACITY) {
    int rs = resizeStamp(n);//生成一个扩容戳
    if (sc < 0) {//小于0 说明此时已经有别的线程在扩容
    if ((sc >>> RESIZE_STAMP_SHIFT) != rs
    sc == rs + MAX_RESIZERS
    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();
    }
    }
    }
    ```
    当size容量大于阈值且table不为空时且容量小于最大容量时,会进行扩容操作:
    > 1.如果当前正在处于扩容阶段,则当前线程会加入并且协助扩容
    >
    > 2.如果当前没有在扩容,则直接触发扩容操作
    当满足如下判断时,会跳出扩容,说明当前线程不符合协助扩容,直接跳出循环
    ```java
    if ((sc >>> RESIZE_STAMP_SHIFT) != rs
    sc == rs + MAX_RESIZERS
    transferIndex <= 0)
    break;
    //sc>>>RESIZE_STAMP_SHIFT!=rs表示比较高位生成戳和rs是否相等
    //sc=rs+1表示扩容结束
    //sc=s+ MAX_RESIZERS 表示帮助线程线程已经达到最大值了
    //nt= nexttable->表示扩容已经结束
    // transferindex<=0表示所有的 transfer任务都被领取完了,没有剩余的hash桶来给自己这个线程来做 transfer
    ```
    ### reslzestamp()
    resizestamp用来生成一个和扩容有关的扩容戳,具体有什么作用呢?我们基于它的实现来做一个分析。
    ```java
    static final int resizeStamp(int n) {
    return Integer.numberOfLeadingZeros(n)
    }
    ```
    Integer.numberOfLeadingZeros(n)这个方法是返回无符号整数n前面0的个数。
    比如如 16 的二进制是 0000 0000 0000 0000 0000 0000 0001 0000,那么这个方法返回的值就是 27。
    转成二进制就是 0000 0000 0000 0000 1000 0000 0001 1011
    1 << (RESIZE_STAMP_BITS - 1)1 << 15,表示二进制即是高16位为0,第16位为1:
    ```java
    0000 0000 0000 0000 1000 0000 0000 0000
    ```
    所以根据 resizestamp()的运算逻辑,我们来推演一下,假如n=16,那么 resizestamp(16)=32795, 转化为二进制就是是如下:
    ```
    0000 0000 0000 0000 1000 0000 0001 1011
    ```
    那么通过resizeStamp()方法主要是起到了什么作用呢?
    >首先因为CHM的元素的个数肯定是2的次幂,所以每个数组容量的前面0的个数肯定是不同的,这样可以保证是在原容量为n的情况下进行扩容。而不会出现因为多线程问题导致的多次扩容问题。
    接着再来看,当第一个线程尝试进行扩容的时候,会先判断执行下面这段代码能否成功。
    ```java
    U.compareAndSwapInt(this, SIZECTL, sc,(rs << RESIZE_STAMP_SHIFT) + 2)
    ```
    当成功CAS操作后, rs左移16位,相当于原本的二进制低位変成了高位1000 0000 0001 1011 0000 0000 0000 0000,然后再+2
    ```java
    1000 0000 0001 1011 0000 0000 0000 0000 + 0010 = 1000 0000 0001 1011 0000 0000 0000 0010
    ```
    高16位代表扩容的标记、低16位代表并行扩容的线程数
    这样来存储有什么好处呢?
    > 1.首先在CHM中是支持并发扩容的,也就是说如果当前的数组需要进行扩容操作,可以由多个线程来共同负责。
    > 2.可以保证每次扩容都生成唯一的生成戳,每次新的扩容,都有一个不同的n,这个生成戳就是根据n来计算出来的一个数字,n不同,这个数字也不同
    第一个线程尝试扩容的时候,为什么是+2?
    > 因为ziseCtrol 为-1表示初始化,-2表示一个线程在执行扩容,而且对 sizectl的操作都是基于位运算的, 所以不会关心它本身的数值是多少,只关心它在二进制上的数值,而SC+1会在低16位上加1。
    ### transfer()
    transfer(Node<K,V>[] tab, Node<K,V>[] nextTab)
    正常来说,当我们需要对Map进行扩容时,直接将旧数组中的数据重新散列到新的下标,然后进行迁移到新的数组中即可;但是在高并发时,可能会有多个线程进行扩容,同时也可能存在扩容的同时存在添加元素,这就需要另外的处理机制。
    如通过加锁的过程,将扩容的过程的上锁,完成后释放,这样的方式在效率上比较低;CHM中采用了CAS的无锁并发同步机制;同时当发现已经有线程在进行扩容时,当前线程会加入其中,协助其扩容。
    ```java
    //ConcurrentHashMap.transfer()
    private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    //将原table的桶进行划分范围最小为16 当桶较小的时候,只会有一个CPU(线程)进行扩容 减少资源竞争
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
    stride = MIN_TRANSFER_STRIDE; // subdivide range
    if (nextTab == null) { // initiating
    try {
    //申请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; //扩容失败 将sizeCtl标记为Integer的最大值
    return;
    }
    nextTable = nextTab;
    transferIndex = n;//本次扩容时要处理的开始下标
    }
    int nextn = nextTab.length;
    ////创建一个fwd节点,表示一个正在被迁移的Node,并且它的hash值为-1( MOVED),它的作用是用来占位,表示
    //原数组中位置i处的节点完成迁移以后,就会在i位置设置一个fwd来告诉其他线程这个位置已经处理过了
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    boolean advance = true;//标识是否能够往前推进一个槽位 否则要等其处理完毕
    boolean finishing = false; // 判断是否已扩容完成
    for (int i = 0, bound = 0;😉 {//i 指当前处理的槽位序号,bound 指需要处理的槽位边界,先处理槽位 15 的节点;
    Node<K,V> f; int fh;
    while (advance) {
    int nextIndex, nextBound;
    if (--i >= bound
    advance = false;
    else if ((nextIndex = transferIndex) <= 0) {//表示所有bucket都已经分配完毕
    i = -1;
    advance = false;
    }
    ///这里只有分配完当前处理的任务才会进来 通过cas来修改 RAINSHERINEX,为当前线程分配任务,处理的节点区间为(nextbound, nextindex)->(16, 31)
    //当只有一个线程时 第二次领取任务时 [bound,nextIndex) =[0,16)
    else if (U.compareAndSwapInt
    (this, TRANSFERINDEX, nextIndex,
    nextBound = (nextIndex > stride ?
    nextIndex - stride : 0))) {
    bound = nextBound;// 初始容量为32的map nextBound为16 i=31
    i = nextIndex - 1;
    advance = false;
    }
    }
    //i<0 说明已经遍历完旧的数组,也就是当前线程已经处理完所有负责的 bucket
    if (i < 0
    int sc;
    if (finishing) {
    nextTable = null;
    table = nextTab;
    sizeCtl = (n << 1) - (n >>> 1);
    return;
    }
    // sized1在迁移前会设置为(ェs<< RESIZE STAMP SHIFT)+2 表示正在参与扩容的线程数
    //这里减1 表示当前线程已完成自己的任务 退出扩容
    if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
    if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)//此处说明见下文:
    return;
    finishing = advance = true;//表示扩容结束了
    i = n; // 再次检查整张表
    }
    }
    else if ((f = tabAt(tab, i)) == null)
    advance = casTabAt(tab, i, null, fwd);
    else if ((fh = f.hash) == MOVED)
    advance = true; // already processed
    else {
    //...略 具体的转移过程
    }
    }
    }
    ```
    分配区间
    ```java
    //这个循环使用CAS不断尝试为当前线程分配任务
    //直到分配成功或任务队列已经被全部分配完毕
    //如果当前线程已经被分配过 bucket区域
    //那么会通过--i指向下一个待处理 bucket然后退出该循环
    while (advance) {
    int nextIndex, nextBound;
    if (--i >= bound
    advance = false;
    else if ((nextIndex = transferIndex) <= 0) {//表示所有bucket都已经分配完毕
    i = -1;
    advance = false;
    }
    ///这里只有分配完当前处理的任务才会进来 通过cas来修改 RAINSHERINEX,为当前线程分配任务,处理的节点区间为(nextbound, nextindex)->(16, 31)
    //当只有一个线程时 第二次领取任务时 [bound,nextIndex) =[0,16)
    else if (U.compareAndSwapInt
    (this, TRANSFERINDEX, nextIndex,
    nextBound = (nextIndex > stride ?
    nextIndex - stride : 0))) {
    bound = nextBound;// 初始容量为32的map nextBound为16 i=31
    i = nextIndex - 1;
    advance = false;
    }
    }
    ```
    假设CHM的容量为32,且需要进行扩容则其示意图如下:
    image-20210428171041443
    结束扩容
    ```java
    // sized1在迁移前会设置为(sc << RESIZE STAMP SHIFT)+2 表示正在参与扩容的线程数
    //这里减1 表示当前线程已完成自己的任务 退出扩容
    if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
    if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
    return;
    finishing = advance = true;//表示扩容结束了
    i = n; // 再次检查整张表
    }
    ```
    当无可分配任务时,将sizeCtrl -1,表示正在参与扩容的线程数减少了一个。
    在开始transfer()时 ,(rs << RESIZE_STAMP_SHIFT) + 2会有这个操作,此处通过(sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)判断是否结束。
    如果相等,表示当前线程为整个扩容操作的最后一个线程;
    否则说明没有结束扩容,直接return。但是前面已经判断了没有正在参与扩容的线程数了?为什么这里还会不相等呢?
    ```j
    这么做的目的,一方面是防止不同扩容之间出现相同的 sizectl,另外一方面,还可以避免 sizectl 的ABA问题导致的扩容重的情况
    ```
    ## 高低位扩容转移
    从前面可知,参与扩容的线程倒序遍历数组槽位上的单链表,那么他们是具体是怎么转移到一个新的table数组的呢?
    在JDK1.8的HashMap中,是通过区分待转移元素是在原位置还是需要变动位置区分成两组,遍历转移过程中,形成两个单链表,结束遍历当前链表时,将新生成的链表的头结点指向新的Table的下标即结束该槽位的转移。
    CHM中也是采用这种思想,将需要转移的链表分成高低位链表,低位链表表示扩容后再新数组的下标位置不变,高位链表表示在新数组的下标位置变为 i+n(i为当前所属的槽位号,n为旧数组的容量)
    ```java
    //ConcurrentHashMap.transfer()
    synchronized (f) {
    if (tabAt(tab, i) == f) {
    Node<K,V> ln, hn;
    if (fh >= 0) {//链表头节点的 hash值大于0 转移完成的是-1
    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) {//只要跟前面的位结果不一致(就是区分0 和1),那么将runBit替换成当前的 同时lastRun也变更成最新的
    runBit = b;
    lastRun = p;
    }
    }
    if (runBit == 0) {//如果最后一截相同的链表为低位 那么将lastRun赋值给ln
    ln = lastRun;
    hn = null;
    }
    else {//如果最后一截相同的链表为高位 那么将lastRun赋值给hn
    hn = lastRun;
    ln = null;
    }
    for (Node<K,V> p = f; p != lastRun; p = p.next) {//再次遍历当链表 但是只到lastRun前一位置即停止
    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);//把旧 tabble的hash桶中放置转发节点,表明此hash桶已经被处理
    advance = true;
    }
    //...红黑树部分略
    }
    ```
    以下如高低位扩容转移元素的示例图:
    image-20210429124259595
    runbit ==0这是非常重要的一点
    > runbit实际上等于 p.hash & n,所以实际上就是 p.hash & n==0。正常情况下,计算节点在table中的下标的方法是:hash&(oldTable.length-1),扩容之后,table长度翻倍,计算table下标的方法是 hash&(newTable.length-1),也就是 hash&(oldTable.length*2-1),于是我们有了这样的结论:这新旧两次计算下标的结果,要不然就相同,要不然就是新下标等于旧下标加上旧数组的长度。
    image-20210410115558428
    上面示例中,原数组大小为16,扩容后大小为32,那么也就是进行数组下标定位算法时,第五位变成1。
    那么进行&运算后,第五位为0时,就是跟原来的结果是一致的,即该元素进行扩容后数组下标没有改变;
    若为1,则新table按位与的结果就比旧table的结果多了10000(二进制),而这个二进制10000就是旧table的长度16。
    换言之,新的散列值下标需要不需要加上旧数组的长度,就看hash值第五位(即原oldCap位置)是0还是1就行了。
    > e.hash & oldCap 就是用于计算oldCap位置(扩容后就是n-1位置)是为0还是1,上述示例中,oldCap值为16,就是10000。第5位为1也就是扩容后的 e.hash & (n-1)
    ## 协助扩容 helpTransfer()
    putVal(K key, V value, boolean onlyIfAbsent)方法中,当需要添加槽位号的头节点元素为hash值为 MOVED(-1),说明当前节点是ForwardingNode 节点。
    意味着有其他线程正在进行扩容,那么当前现在直接帮助它进行扩容,因此调用 helpTransfer()来协助扩容。
    ```java
    //ConcurrentHashMap.putVal()
    else if ((fh = f.hash) == MOVED)
    tab = helpTransfer(tab, f);
    ```
    ```java
    //ConcurrentHashMap.helpTransfer()
    final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {//入参为table数组 f为头节点
    Node<K,V>[] nextTab; int sc;
    if (tab != null && (f instanceof ForwardingNode) &&
    //判断此时是否仍然在执行扩容, ForwardingNode的nextTable=nu11的时候说明扩容已经结束了
    (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
    int rs = resizeStamp(tab.length);//生成扩容戳
    while (nextTab == nextTable && table == tab &&
    (sc = sizeCtl) < 0) {//说明当前扩容还未完成情况下,通过循环不断尝试加入到协助扩容中
    //// transferindex<=0 表示所有的Node都已经分配了线程
    //sc=rs+ MAX_RESIZERS 表示扩容线程数达到最大扩容线程数
    //(sc >>> RESIZE_STAMP_SHIFT) != rs,如果在同一轮扩容中,那么sc无符号右移比较高位和rs的值,那么应该是相等的。如果不相等,说明扩容结束了
    //sc==rs+1表示扩容结束
    if ((sc >>> RESIZE_STAMP_SHIFT) != rs
    sc == rs + MAX_RESIZERS
    break;
    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {//CAS尝试给sc+1 成功则加入扩容
    transfer(tab, nextTab);
    break;
    }
    }
    return nextTab;
    }
    return table;//返回新的数组
    }
    ```
    # Issues
    ### ConcurrentHashMap 1.7和1.8的不同?
    在JDK1.7的实现上, Conrruenthashmap由一个个 Segment组成,简单来说, Concurrenthashmap是一个 Segment数组,它通过继承 Reentrantlock来进行加锁,通过每次锁住一个 segment来保证每个 segment内的操作的线程安全性从而实现全局线程安全。
    image-20210429145743676
    每个Segment中,又相当于有一个HashMap,当每个操作分布在不同的 segment上的时候,默认情况下,理论上可以同时支持16个线程的并发写入。
    相较于JDK1.7,1.8有以下变化:
    1.1.8中取消了Segment分段锁的设计,直接使用Node数组来实现,采用CAS机制+synchronized。
    2.采用数组+链表+红黑树的结构存储Map中的元素。
    ### 什么是ConcurrentHashMap的弱一致性?
    因为添加元素和统计元素个数是分开独立的,所以有可能元素添加成功 但是拿到的size()不是最新的
    ### 计算size()
    1.7中统计size大小是通过:
    ```
    先采用不加锁的方式,连续计算元素的个数,最多计算3次:
    1、如果前后两次计算结果相同,则说明计算出来的元素个数是准确的;
    2、如果前后两次计算结果都不同,则给每个Segment进行加锁,再计算一次元素的个数;
    ```
    1.8中采用分段锁的设计思想
    竞争激烈情况下,不适合像一般的用while 循环里面自旋CAS来实现。
    ```java
    while(true){
    cas(count+1)
    }
    ```
    过多的自旋反而会耗费性能,所以CHM中采用分段锁的设计思想:
    * 首先通过baseCount+x 看是否能操作成功;
    * 失败则说明有竞争,然后通过随机一个Countercells[]数组下标,进行+x的操作;
    * 失败,则说明竞争比较激烈,判断是否需要扩容;
    * 最后将baseCount和CounterCells数组的所有的value值进行累加得到size()。
    ```java
    int n = tab.length, stride;
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE) //每个线程处理桶的最小数目,可以看出核数越高步长越小,最小16个。
    stride = MIN_TRANSFER_STRIDE; // subdivide range
    if (nextTab == null) {
    try {
    @SuppressWarnings("unchecked")
    Node<K,V>[] nt = (Node<K,V>[])new Node[n << 1]; //扩容到2倍
    nextTab = nt;
    } catch (Throwable ex) { // try to cope with OOME
    sizeCtl = Integer.MAX_VALUE; //扩容保护
    return;
    }
    nextTable = nextTab;
    transferIndex = n; //扩容总进度,>=transferIndex的桶都已分配出去。
    }
    int nextn = nextTab.length;
    //扩容时的特殊节点,标明此节点正在进行迁移,扩容期间的元素查找要调用其find()方法在nextTable中查找元素。
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    //当前线程是否需要继续寻找下一个可处理的节点
    boolean advance = true;
    boolean finishing = false; //所有桶是否都已迁移完成。
    for (int i = 0, bound = 0;😉 {
    Node<K,V> f; int fh;
    //此循环的作用是确定当前线程要迁移的桶的范围或通过更新i的值确定当前范围内下一个要处理的节点。
    while (advance) {
    int nextIndex, nextBound;
    if (--i >= bound
    advance = false;
    //迁移总进度<=0,表示所有桶都已迁移完成。
    else if ((nextIndex = transferIndex) <= 0) {
    i = -1;
    advance = false;
    }
    else if (U.compareAndSwapInt
    (this, TRANSFERINDEX, nextIndex,
    nextBound = (nextIndex > stride ?
    nextIndex - stride : 0))) { //transferIndex减去已分配出去的桶。
    //确定当前线程每次分配的待迁移桶的范围为[bound, nextIndex)
    bound = nextBound;
    i = nextIndex - 1;
    advance = false;
    }
    }
    //当前线程自己的活已经做完或所有线程的活都已做完,第二与第三个条件应该是下面让"i = n"后,再次进入循环时要做的边界检查。
    if (i < 0
    int sc;
    if (finishing) { //所有线程已干完活,最后才走这里。
    nextTable = null;
    table = nextTab; //替换新table
    sizeCtl = (n << 1) - (n >>> 1); //调sizeCtl为新容量0.75倍
    return;
    }
    //当前线程已结束扩容,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 = n; // recheck before commit
    }
    }
    else if ((f = tabAt(tab, i)) == null)
    advance = casTabAt(tab, i, null, fwd); //如果i处是ForwardingNode表示第i个桶已经有线程在负责迁移了。
    else if ((fh = f.hash) == MOVED)
    advance = true; // already processed
    else {
    synchronized (f) { //桶内元素迁移需要加锁。
    if (tabAt(tab, i) == f) {
    Node<K,V> ln, hn;
    if (fh >= 0) { //>=0表示是链表结点
    //由于n是2的幂次方(所有二进制位中只有一个1),如n=16(0001 0000),第4位为1,那么hash&n后的值第4位只能为0或1。所以可以根据hash&n的结果将所有结点分为两部分。
    int runBit = fh & n;
    Node<K,V> lastRun = f;
    //找出最后一段完整的fh&n不变的链表,这样最后这一段链表就不用重新创建新结点了。
    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;
    }
    //lastRun之前的结点因为fh&n不确定,所以全部需要重新迁移。
    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);
    }
    //低位链表放在i处
    setTabAt(nextTab, i, ln);
    //高位链表放在i+n处
    setTabAt(nextTab, i + n, hn);
    setTabAt(tab, i, fwd); //在原table中设置ForwardingNode节点以提示该桶扩容完成。
    advance = true;
    }
    else if (f instanceof TreeBin) { //红黑树处理。
    ...
    ```
    ### put()元素的过程
    image-20210429145334612
    1.判断是否为空
    2.table是否未被初始化
    3.命中槽位是否存在元素,为空则直接插入
    4.命中的槽位是否为MOVE节点,是则加入协助扩容
    5.遍历所属的链表,找到相同的key值则覆盖,否则新节点添加至尾部
    ### CHM读操作需要加锁么?
    虽然ConcurrentHashMap的读不需要锁,但是需要保证能读到最新数据,所以必须加volatile。
    即数组的引用需要加volatile,同时一个Node节点中的val和next属性也必须要加volatile,之所以不会读到过期的值,是根据java内存模型的happen before原则,对volatile字段的写入操作先于读操作,即使两个线程同时修改和获取volatile变量,get操作也能拿到最新的值,这是用volatile替换锁的经典应用场景。
    ```java
    static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val; //value值加volatile保证可见性
    volatile Node<K,V> next;////next值加volatile保证可见性
    }
    ```
    posted @ 2021-04-29 16:51  骑着单车的程序猿  阅读(57)  评论(0)    收藏  举报