秋招准备之——深入理解ConcurrentHashMap(JDK1.8)

秋招复习笔记系列目录(不断更新中):

  • 1.数据结构全系列
  • 2.计算机网络知识整理(一)
  • 3.计算机网络知识整理(二)
  • 4. Java虚拟机知识整理
  • 5.计算机操作系统
  • 6.深入理解HashMap
  • 7.深入理解ConcurrentHashMap
  • 8.MySQL

一、前言

以前感觉HashMap难懂,直到我看了ConcurrentHashMap。。。不过,等真的读懂了源码,不得不感叹,Doug Lea大爷还是你大爷,看的过程中,不时惊呼:原来是这样啊!这也太牛了!好了,首先介绍一下,ConcurrentHashMap是一个线程安全的HashMap,其主要采用CAS操作+synchronized锁的方式,实现线程安全,其中synchronize锁的粒度为桶中头结点(包括链表Node结点,包装红黑树的TreeBin结点),底层依然由 “数组”+链表+红黑树 的方式实现。

二、基础

2.1 几个重要的属性

  • sizeCtl: 它是一个控制标志符,取值不同有不同的含义:

    • 负数代表正在进行初始化或扩容操作
    • -1代表正在初始化
    • -N 表示有N-1个线程正在进行扩容操作
    • 正数或0代表hash表还没有被初始化,这个数值表示初始化或下一次进行扩容的大小,这一点类似于扩容阈值的概念。它的值始终是当前ConcurrentHashMap容量的0.75倍,这与loadfactor是对应的。
  • 桶中元素的hash值的含义:

    • ①hash==-1 (MOVED) : 表示当前节点是ForwardingNode节点
    • ②hash==-2 (TREEBIN) : 表示当前节点已经树化,且当前节点为TreeBin对象,TreeBin节点代理红黑树操作
    • ③hash==-3 (RESERVED) : 临时保留的哈希

2.2 几个重要的内部类

  • Node节点类:HashMap中的节点类似,只是其val变量和next指针都用volatile来修饰。且不允许调用setValue方法修改Node的value值。这个类是后面三个类的基类
  • TreeNode: 树节点类,当链表长度过长的时候,会转换为TreeNode。但是与HashMap不相同的是,它并不是直接转换为红黑树,而是把这些结点包装成TreeNode放在TreeBin对象中,由TreeBin完成对红黑树的包装。而且TreeNodeConcurrentHashMap继承自Node类,而并非HashMap中的继承自LinkedHashMap.Entry类,也就是说TreeNode带有next指针,这样做的目的是方便基于TreeBin的访问。
  • TreeBin: 这个类并不负责包装用户的key、value信息,而是直接放了一个名为rootTreeNode节点,这个是他所包装的红黑树的根节点,也就是说在实际的ConcurrentHashMap“数组”中,存放的是TreeBin对象,而不是TreeNode对象,这是与HashMap的区别。另外这个类还带有了读写锁。HashMap桶中存储的是TreeNode结点,这里的根本原因是==并发过程中,有可能因为红黑树的调整,树的形状会发生变化,这样的话,桶中的第一个元素就变了,而使用TreeBin包装的话,就不会出现这种情况。 这种类型的节点, hash值为-2,从下面的构造函数中就可以看出来。这样我们通过hash值是否等于-2就可以判断桶中的节点是否是红黑树。
    秋招准备之——深入理解ConcurrentHashMap(JDK1.8)_第1张图片
  • ForwardingNode: 一个用于连接两个table的节点类。它包含一个nextTable指针,用于指向下一张表。而且这个节点的key value next指针全部为null,它的hash值为-1. 这里面定义的find的方法是从nextTable里进行查询节点,而不是以自身为头节点进行查找。在扩容操作中,我们需要对每个桶中的结点进行分离和转移,如果某个桶结点中所有节点都已经迁移完成了(已经被转移到新表 nextTable 中了),那么会在原 table 表的该位置挂上一个 ForwardingNode 结点,说明此桶已经完成迁移。这种类型的节点的hash值是-1,通过构造函数也可以看出来
    秋招准备之——深入理解ConcurrentHashMap(JDK1.8)_第2张图片

2.3 使用CAS操作的三个核心方法

类中定义了三个静态的用于CAS操作的方法:

 	//获得在i位置上的Node节点
    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);
    }
		//利用CAS算法设置i位置上的Node节点。之所以能实现并发是因为他指定了原来这个节点的值是多少
    static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                        Node<K,V> c, Node<K,V> v) {
        return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
    }
		//利用volatile方法设置节点位置的值
    static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
        U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
    }

三、数组的初始化

数组的初始化是在ConcurrentHashMap插入元素的时候发生的,如调用put等方法时发生的。初始化操作在initTable方法中,该没有加锁,因为采取的策略是,当sizeCtl<0时,说明已经有线程在给扩容了,这个线程就会调用Thread.yield()让出一次CPU执行时间。看代码,我们可以看到上面说的sizeCtl的作用:负数表示正在扩容,扩容完成后,用来表示阈值。

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    //tab为空时才进行初始化
    while ((tab = table) == null || tab.length == 0) {
        //如果sizeCtl<0,说明有其他的线程正在初始化,当前线程让出资源
        if ((sc = sizeCtl) < 0)
            Thread.yield(); // lost initialization race; just spin
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {//当前没有线程扩容,那就利用CAS方法把sizectl的值置为-1 表示本线程正在进行初始化
            try {
                if ((tab = table) == null || tab.length == 0) {
                    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);//相当于0.75*n 用sizeCtl来表示阈值
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

四、put过程

4.1 整体过程

整个put过程,主要在putVal函数中实现,具体过程为:

  • ① 如果数组未没初始化,则先去初始化
  • ② 如果对应的桶中的元素为空,那就新建一个链表节点,然后利用CAS操作将其放到桶中的位置。这个过程是在③前面的,我们知道,扩容过程中,每个桶位置迁移节点结束后,会将这个节点设置为ForwardingNode,所以这种情况下,你尽管放,放了以后,扩容的线程总会遍历到这个节点,然后将这个节点迁移到新数组中。
  • ③ 如果有线程在扩容,那就先去帮助扩容,扩容结束后,再重新put
  • ④ 最后,如果当前桶中已经有元素了,那就用synchronized锁住当前桶中的节点,然后在桶中插入元素,插入的时候,要么插入到链表中,要么插入到红黑树中。我们发现,这里的锁粒度是很小的,就锁住一个桶,不像JDK1.7中的ConcurrentHashMap,是分段锁,锁住很多的桶,所以并发效率更高。
  • ⑤ 插入结束后,如果是插入到链表中,那去看看链表的长度有没有超过长度阈值8,如果超过了,就要将链表转换成红黑树。
  • ⑥ 最后,让HashMap的size加一(这里其实是用baseCount来记录长度的,而且处理的时候很复杂,继续看下面)。

整个putVal函数的代码如下,我对重要的地方都做了注释,应该很容易看懂:

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (ConcurrentHashMap.Node<K,V>[] tab = table;;) {
        ConcurrentHashMap.Node<K,V> f; int n, i, fh;
        //哈希表未初始化,则先对数组进行初始化操作,见上面的初始化操作
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        //如果桶中元素为空,则直接通过CAS操作放到对应位置
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null,
                    new ConcurrentHashMap.Node<K,V>(hash, key, value, null)))
                break;                    // no lock when adding to empty bin
        }
        //如果有线程正在扩容,则两个线程一起帮忙扩容,扩容完毕后tab指向新table
        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 (ConcurrentHashMap.Node<K,V> e = f;; ++binCount) {
                            K ek;
                            if (e.hash == hash &&//找到对象,将其val替换
                                    ((ek = e.key) == key ||
                                            (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            ConcurrentHashMap.Node<K,V> pred = e;
                            if ((e = e.next) == null) {//没找到,插入到尾部
                                pred.next = new ConcurrentHashMap.Node<K,V>(hash, key,
                                        value, null);
                                break;
                            }
                        }
                    }
                    //红黑树节点的插入
                    else if (f instanceof ConcurrentHashMap.TreeBin) {
                        ConcurrentHashMap.Node<K,V> p;
                        binCount = 2;
                        if ((p = ((ConcurrentHashMap.TreeBin<K,V>)f).putTreeVal(hash, key,
                                value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            //binCount不为0说明插入了新节点,为0说明在空桶中插入了一个节点(这种情况不需要树化)
            if (binCount != 0) {
                //默认桶中结点数超过8个数据结构会转为红黑树
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    //更新size,扩容检测
    addCount(1L, binCount);
    return null;
}

4.2 更新size

和HashMap一样,ConcurrentHashMap内部也有一个数字,用来记录当前容器中有多少个键值对,但HashMap比较简单,就用一个size记录,然后插入删除元素的时候变化就行了。但ConcurrentHashMap就很复杂了,这也是第一个让人大呼厉害的地方。

1.size的记录方式

ConcurrentHashMap使用一个long型名为baseCount变量和一个CounterCell数组类型的名为counterCells的变量一起来记录size。首先来看一下CounterCell类的定义:

 @sun.misc.Contended static final class CounterCell {
        volatile long value;
        CounterCell(long x) { value = x; }
    }

很简单,里面就一个long类型的名为value的变量。那他是怎么和baseCount配合来记录元素数量的呢?先说结论,counterCells可以看成是一个小型的HashMap,每个桶中的位置存储的CounterCell类型的变量,记录了在桶中的线程需要增加的size的值。有点绕,大概解释一下,比如有两个线程,他们都向ConcurrentHashMap中添加元素了,然后他们都需要去更新ConcurrentHashMap的baseCount属性,那这时候,两个个线程会通过CAS操作竞争去给baseCount加1,这样竞争的话会一直自旋,很浪费性能对吧,那我这样,建立一个CounterCell类型的数组counterCells,然后每个线程都能生成一个随机数,然后我用这个随机数当这个线程的哈希码,然后通过这个哈希码,就能把这个线程对应到counterCells数组中的一个位置对吧,我现在这个位置上,先把要更新的值更新到这个位置上的CounterCellvalue值上面,最后,我再同步到baseCount上就好了。真的很绕,可以多读几遍理解一下。我们来看ConcurrentHashMap的size函数:

	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;
    }

从上面是可以看到的,最后size的计算方式,是用baseCount的值,加上counterCells中每个元素的value值得到的,印证了我们上面的解释。

2.addCount的实现

再来看putVal中的最后一行,调用了addCount这个函数,这个函数的作用就是更新容器的size值,addCount里面又调用了fullCount这个函数。具体的代码如下:

private final void addCount(long x, int check) {
    //这里的作用是,对hashMap的size进行更新,更新的时候,为了防止多个线程竞争更改baseCount的值,会将多个线程分散到CounterCell数组里面,对cell中的value值进行更改,最后再同步给baseCount
    CounterCell[] as; long b, s;//@解释1
    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 ||
            //通过ThreadLocalRandom.getProbe() & m算出在CounterCell中的下标
            (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended =
              //尝试给CounterCell.val加1
              U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
            fullAddCount(x, uncontended);
            return;
        }
        if (check <= 1)
            return;
        //更新baseCount的值
        s = sumCount();
    }
    //check就是结点数量,有新元素加入成功才检查是否要扩容。
    if (check >= 0) {//@解释③
        Node<K,V>[] tab, nt; int n, sc;
        //s表示加入新元素后容量大小,计算已省略。
        //新容量大于当前扩容阈值并且小于最大扩容值才扩容,如果tab=null说明正在初始化,死循环等待初始化完成。
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
               (n = tab.length) < MAXIMUM_CAPACITY) {
            int rs = resizeStamp(n); 
            //sc<0表示已经有线程在进行扩容工作
            if (sc < 0) {
                //条件1:检查是对容量n的扩容,保证sizeCtl与n是一块修改好的
              //条件2与条件3:应该是进行sc的最小值或最大值判断。
                //条件4与条件5: 确保tranfer()中的nextTable相关初始化逻辑已走完。
                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))  //有新线程参与扩容则sizeCtl加1
                    transfer(tab, nt);
            }
            //没有线程在进行扩容,将sizeCtl的值改为(rs << RESIZE_STAMP_SHIFT) + 2),原因见下面sizeCtl值的计算分析。
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
            s = sumCount();
        }
    }
}

private final void fullAddCount(long x, boolean wasUncontended) {//@解释②
      int h;
      if ((h = ThreadLocalRandom.getProbe()) == 0) {
          ThreadLocalRandom.localInit();      // force initialization
        h = ThreadLocalRandom.getProbe();
          wasUncontended = true;
      }
      boolean collide = false;                // true表示需要扩容,false表示不扩容
    //wasUncontended为true表示冲突,false表示不冲突
    for (;;) {
          CounterCell[] as; CounterCell a; int n; long v;
          if ((as = counterCells) != null && (n = as.length) > 0) {
            // 通过该值与当前线程probe求与,获得cells的下标元素,和hash 表获取索引是一样的
              if ((a = as[(n - 1) & h]) == null) {
                  //cellsBusy=0表示counterCells不在初始化或者扩容状态下
                  if (cellsBusy == 0) {            // Try to attach new Cell
                      //构造一个CounterCell的值,传入元素个数
                    CounterCell r = new CounterCell(x); // Optimistic create
                      //通过cas设置cellsBusy标识,防止其他线程来对counterCells并发处理
                    if (cellsBusy == 0 &&
                          U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
                          boolean created = false;
                          try {               // Recheck under lock
                              CounterCell[] rs; int m, j;
                               //将初始化的r对象的元素个数放在对应下标的位置
                              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;
              }
               //说明在addCount方法中cas失败了,并且获取probe的值不为空
              else if (!wasUncontended)       // CAS already known to fail
                   //设置为未冲突标识,进入下一次自旋
                  wasUncontended = true;      //这里设置为true后,会跳到ThreadLocalRandom.advanceProbe(h);重新哈希
              //由于指定下标位置的cell值不为空,则直接通过cas进行原子累加,如果成功,则直接退出
              else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
                  break;
              //如果已经有其他线程建立了新的counterCells或者CounterCells大于CPU核心数(很巧妙,线程的并发数不会超过cpu核心数)
              else if (counterCells != as || n >= NCPU)
                  //设置当前线程的循环失败不进行扩容
                  collide = false;            // At max size or stale
              //恢复collide状态,标识下次循环会进行扩容
              else if (!collide)
                  collide = true;
              //给线程生成一个新的哈希值以后,还冲突,说明竞争激烈,将counterCells的容量扩展成原来的一倍
              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
              }
              //wasUncontended=true,此时生成一个全新的线程hash,重新自旋
              h = ThreadLocalRandom.advanceProbe(h);
          }
          //counterCells为空,就开辟数组,初始数组大小是2
          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;
          }
          //各种条件都不满足的时候,尝试着直接去更新baseCount的值
          else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
              break;                          // Fall back on using base
      }
  }
  • 解释1: 先看addCount中的第一部分。这部分首先通过CAS操作去更新baseCount,如果失败,那说明有多个线程在竞争更新baseCount,那就尝试去更新counterCells中的value,这里要解释一下,as[ThreadLocalRandom.getProbe() & m])这里,ThreadLocalRandom.getProbe()就是给线程生成一个随机数作为线程的唯一标志,并且这个方法对于同一个线程每次生成的值是一样的ThreadLocalRandom.getProbe() & m这个代码是不是很熟悉?对!这不就是上一节HashMap中,知道key的hash值,然后计算其在数组中的位置的代码么。那我说counterCells是一个小型的HashMap没问题吧。如果尝试更新counterCells中的value失败,就会进入到fullAddCount方法。

  • 解释2: fullAddCount方法,应该是这个addCount过程的精华了,你细细读,会发现作者为了提高性能,真的是将每一种情况都考虑到了,而且,里面if-else的顺序非常非常讲究。我这里直接说一下执行的顺序,具体的自己再详细看上面的源码对照过程理解。

    • ①第一次执行的时候,counterCells这个数组应该是空的,那就会跳到下面这段代码,可以看到,这里是先生成了一个大小为2的数组,赋值给了counterCells,然后通过h & 1操作,创建一个初始值为x(要更新的量)的CounterCell放在counterCells对应位置上。
    //counterCells为空,并且没有别的线程占用counterCells(cellsBusy=0),就开辟数组,初始数组大小是2
          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;
          }
    
    • ②如果通过(n - 1) & h计算得到的线程对应的桶为空,那直接new一个初始值为xCounterCell,放到桶中。对应的代码为:
    			if ((a = as[(n - 1) & h]) == null) {
                    if (cellsBusy == 0) {            // Try to attach new Cell
                        CounterCell r = new CounterCell(x); // Optimistic create
                        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
                        }
                    }
    
    • ③ 第②步失败的话,wasUncontended=false,那就通过下面的代码,将wasUncontended更新为true,说明已经冲突了,开启下一次循环
    else if (!wasUncontended)       // CAS already known to fail
                    wasUncontended = true;
    
    • ④ 这一步,会再次尝试去更新counterCells中的线程所在位置的value值,如果成功了,那就更新成功,break;如果失败,进入下一次循环。对应代码为:
    else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
                    break;
    
    • ⑤ 走到这一步,说明前面地操作都做不了,在counterCells一直冲突,需要扩容,会将collide设置为true,然后进行下一次循环。对应代码为:
    else if (!collide)
                    collide = true;
    
    • ⑥ 这里就开始扩容了,扩容是将counterCells的容量扩大到原来的2倍,和HashMap很像有没有!扩容的代码如下:
    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
                }
    

    好了,整个过程大概说完了,但是还有一些需要注意的东西。
    注意点1: h = ThreadLocalRandom.advanceProbe(h);这句代码的作用是,给线程生成一个新的随机数作为ID(区别ThreadLocalRandom.getProbe(),它生成的值一直是不变的,但是一旦ThreadLocalRandom.advanceProbe(h)生成新的值了,那ThreadLocalRandom.getProbe()得到的也就是新的值了)。从这里我们看出,冲突了以后,并不是马上去扩容,而是先将线程的hash值变一变,让他映射到counterCells的其他位置,看再有没有冲突。 而且,做后面的wasUncontended还有collide这几个变量之前,每次循环,前面的像CAS更新counterCells中的线程所在位置的value值这些动作都是还要做的,这就保证了在最坏的情况下,才会去扩容counterCells,这样就能节约内存资源。现在看,是不是要大呼厉害。
    注意点2: counterCells的扩容是一直扩下去吗?它的容量是无限增大的吗?不是的,我们还有一句代码没有分析。这句话的意思是:如果其他线程也在扩容(对应counterCells != as条件)或者counterCells的数量已经达到CPU的核心数时(对应n >= NCPU条件)时,就将collide置为false,这样就再不能扩容了。继续大呼厉害!

    else if (counterCells != as || n >= NCPU)
                    collide = false;
    
  • 解释3: 扩容条件的判断。这里的部分,看代码中的注释,应该都能看懂,这里借助resizeStamp函数说一下sizeCtl的一些知识。首先看resizeStamp的实现。

    /**
     * The number of bits used for generation stamp in sizeCtl.
     * Must be at least 6 for 32bit arrays.
    */
    private static int RESIZE_STAMP_BITS = 16;
    
    /**
     * The bit shift for recording size stamp in sizeCtl.
     */
    private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
    
    static final int resizeStamp(int n) {
            return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
    }
    

    Integer.numberOfLeadingZeros(n) 用于计算n转换成二进制后前面有几个0。(1 << (RESIZE_STAMP_BITS - 1)即是1<<15,表示为二进制即是高16位为0,低16位为1:

    0000 0000 0000 0000 1000 0000 0000 0000
    

    那比如输入的n为8,其前导0的个数就是28,那得到的结果就是
    1000 0000 0001 1100
    这是一个很大的负数。 resizeStamp(n) 其实返回的是对 n 的一个数据校验标识,占 16 位。而 RESIZE_STAMP_SHIFT 的值为 16,那么位运算后,整个表达式必然在右边空出 16 个零。也正如我们所说的,sizeCtl 的高 16 位为数据校验标识,低 16 为表示正在进行扩容的线程数量。
    (resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2 表示当前只有一个线程正在工作,相对应的,如果 (sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT,说明当前线程就是最后一个还在扩容的线程,那么会将 finishing 标识为 true,并在下一次循环中退出扩容方法。

五、扩容过程

上面说了put操作的过程,这里再来看扩容的过程。扩容的过程又是一个让人大呼牛逼的部分。因为,它实现了并发迁移的过程,就是当一个线程对ConcurrentHashMap进行put或者remove等操作时,如果发现在扩容,那我这个线程就先不去做我的工作了,我就去帮助正在扩容的线程去迁移数据。在上面的put里面,出现了helpTransfer 这个函数,就是去帮助扩容,put操作的最外层是一个死循环,所以帮助扩容完成后,它会继续完成自己的put操作。

5.1 并发扩容的整体流程

每个线程承担不小于 16 个桶中的元素的扩容,然后从右向左划分 16 个桶(不一定能够16个)给当前线程去迁移,每当开始迁移一个桶中的元素的时候,线程会锁住当前槽中列表的头元素,扩容完成后会将这个桶中的节点设置为ForwardingNode。假设这时候正好有 get请求过来会仍旧在旧的列表中访问,如果是插入、修改、删除、合并、compute 等操作时遇到 ForwardingNode,就表示正在扩容,那当前线程会加入扩容大军帮忙一起扩容,扩容结束后再做元素的更新操作。下面来看代码:

 private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
      int n = tab.length, stride;
      //根据CPU核数计算每个线程的负责扩容的步长stride,最小步长为MIN_TRANSFER_STRIDE=16
      if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
          stride = MIN_TRANSFER_STRIDE; 
      if (nextTab == null) { 
          try {
              @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;
              return;
          }
          nextTable = nextTab;
          //transferIndex这个变量在旧的桶的末尾
          transferIndex = n;
      }
      int nextn = nextTab.length;
      //扩容时的特殊节点,标明正在扩容,且当前位置已经迁移完成,扩容期间的元素查找要调用其find()方法在nextTable中查找元素。
      ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
      boolean advance = true;//当前线程是否需要继续寻找下一个可处理的节点
      boolean finishing = false; //所有桶是否都已迁移完成。
      //i指当前迁移任务的开始下标,bound为当前迁移任务的结束下标
      for (int i = 0, bound = 0;;) {
          Node<K,V> f; int fh;
          //这个while循环体的作用就是在控制i--  通过i--可以依次遍历原hash表中的节点
          while (advance) {
              int nextIndex, nextBound;
              if (--i >= bound || finishing)
                  advance = false;
              //迁移总进度<=0,表示所有桶都已迁移完成。
              else if ((nextIndex = transferIndex) <= 0) {
                  i = -1;
                  advance = false;
              }
              //通过CAS控制只有一个线程拿到bound~i间元素的迁移权利
              else if (U.compareAndSwapInt
                       (this, TRANSFERINDEX, nextIndex,
                        nextBound = (nextIndex > stride ?
                                     nextIndex - stride : 0))) {
                  //计算出bound~i的范围,这个范围是该条线程负责迁移的范围
                  bound = nextBound;
                  i = nextIndex - 1;
                  advance = false;
              }
          }
          if (i < 0 || i >= n || i + n >= nextn) {
              int sc;
              if (finishing) {//如果所有的节点都已经完成复制工作  就把nextTable赋值给table 清空临时对象nextTable
                  nextTable = null;
                  table = nextTab;
                  sizeCtl = (n << 1) - (n >>> 1);//扩容阈值设置为原来容量的1.5倍  依然相当于现在容量的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
              }
          }
          //如果遍历到的节点为空 则放入ForwardingNode指针
          else if ((f = tabAt(tab, i)) == null)
              advance = casTabAt(tab, i, null, fwd);
          //如果遍历到ForwardingNode节点  说明这个点已经被处理过了 直接跳过这里是控制并发扩容的核心
          else if ((fh = f.hash) == MOVED)
              advance = true; // already processed
          else {
              //节点上锁
              synchronized (f) {
                  if (tabAt(tab, i) == f) {
                      Node<K,V> ln, hn;
                      //如果fh>=0 证明这是一个Node节点
                      if (fh >= 0) {
                          //使用fn&n可以快速把链表中的元素区分成两类,A类是hash值的第X位为0,B类是hash值的第X位为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) {
                                  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);
                          }
                          //在nextTable的i位置上插入一个链表
                          setTabAt(nextTab, i, ln);
                          //在nextTable的i+n的位置上插入另一个链表
                          setTabAt(nextTab, i + n, hn);
                          //在table的i位置上插入forwardNode节点  表示已经处理过该节点
                          setTabAt(tab, i, fwd);
                          //设置advance为true 返回到上面的while循环中 就可以执行i--操作
                          advance = true;
                      }
                      //红黑树节点的处理
                      else if (f instanceof TreeBin) {
                          TreeBin<K,V> t = (TreeBin<K,V>)f;
                          TreeNode<K,V> lo = null, loTail = null;
                          TreeNode<K,V> hi = null, hiTail = null;
                          int lc = 0, hc = 0;
                          for (Node<K,V> e = t.first; e != null; e = e.next) {
                              int h = e.hash;
                              TreeNode<K,V> p = new TreeNode<K,V>
                                  (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;
                              }
                          }
                          //如果扩容后已经不再需要tree的结构 反向转换为链表结构
                          ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
                              (hc != 0) ? new TreeBin<K,V>(lo) : t;
                          hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                              (lc != 0) ? new TreeBin<K,V>(hi) : t;
                          setTabAt(nextTab, i, ln);
                          setTabAt(nextTab, i + n, hn);
                          setTabAt(tab, i, fwd);
                          advance = true;
                      }
                  }
              }
          }
      }
  }

5.2 边界的确定过程

在并发扩容的过程中,最重要的是确定每个线程负责迁移的边界,每个线程负责不同的范围,这样就不会发生冲突。那我们先来以单个线程来看边界的确定过程。我们假设每个线程负责迁移2个桶(实际每个线程负责的桶最小是16个,我们这里为了方便看2个的情况),旧的数组长度为8,扩容后的长度为16。

  • 初始化: 刚开始时,i=bound=0transferIndex=8,然后通过nextBound = (nextIndex > stride ? nextIndex - stride : 0)这里将nextBound 初始化为nextIndex - stride,我们的举例中就是7-2=5,然后通过bound = nextBound; i = nextIndex - 1;得到该线程扩容的范围为5~7,如下图所示:
    秋招准备之——深入理解ConcurrentHashMap(JDK1.8)_第3张图片
  • 开始遍历: 通过上面的初始化操作,将advance设置为false,现在就是要从后往前遍历,将bound~i之间的元素都迁移到新的数组中。然后就开始遍历。遍历的过程中,有以下几种情况:
    • i < 0 || i >= n || i + n >= nextn:说明扩容完成了,将nextTable置空,然后将table赋值为nextTable
    • (f = tabAt(tab, i)) == null:说明当前位置为空,不需要迁移数据,但是需要将此位置设置为ForwardingNode类型
    • (fh = f.hash) == MOVED:说明当前的节点已经是ForwardingNode类型的节点了,说明当前的bound~i内的元素已经有线程在处理了,那就设置advance = true,重新去更新bound~i
    • ④ 上面的情况都不符合,那就对当前的节点进行迁移,迁移的过程中,会像HashMap一样,对桶中的元素进行拆分,以减少一个桶中的元素,具体拆分过程见下面。
    • 拆分: 通过上面的代码,可以看到,每迁移完成一个桶,会通过setTabAt(tab, i, fwd);这一句,将这个桶进行标记。然后将advance设置为true
    • ⑥这里又会进入到while循环,然后主要是执行下面这一句,将i--继续循环遍历。
    if (--i >= bound || finishing)
                  advance = false;
    

多线程的话,其实和单个线程很像,多线程的边界,通过volatile修饰transferIndex确定,很容易理解,。放一个扩容的大致流程图先体会一个(图来自别人的博客,我找不到是哪篇博客的了,这里感谢原作者):
秋招准备之——深入理解ConcurrentHashMap(JDK1.8)_第4张图片

5.3 链表和红黑树的拆分细节

这里来看一下,节点的拆分问题。在上一篇博客的HashMap中,我们看到了将一个链表或红黑树通过高位分成了两个部分,这里也一样,也是分成两个部分,但是,这个分割的过程,做了优化。看下面这部分的代码:

	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);
        }				

比如有这样一个ConcurrentHashMap秋招准备之——深入理解ConcurrentHashMap(JDK1.8)_第5张图片
它其实是通过lastRun记录最后需要处理的节点,即为6号节点(此处,6号节点以后的节点,都是红色节点,这样在创建新链表的过程中,将6号前面的部分反转,后面的部分直接接到前面反转完成的部分就行了),A类和B类节点可以分散到新数组的槽位14和30中,在原数组的槽位14中,蓝色节点第X为0,红色节点第X为1,把链表拉平显示如下:
秋招准备之——深入理解ConcurrentHashMap(JDK1.8)_第6张图片
扩容后经过拆分,变成了两个部分:

  • ln链:和原来相比,变成了倒序
    秋招准备之——深入理解ConcurrentHashMap(JDK1.8)_第7张图片
  • hn链:hn链,在lastRun之前,被逆序了,lastRun之后,顺序没变

秋招准备之——深入理解ConcurrentHashMap(JDK1.8)_第8张图片
再来一个链表拆分的图(图的来源见水印):
秋招准备之——深入理解ConcurrentHashMap(JDK1.8)_第9张图片
来一个红黑树拆分的图(图的来源见水印):
秋招准备之——深入理解ConcurrentHashMap(JDK1.8)_第10张图片

六、get过程

在get的过程中,桶中的元素可能有以下三种类型:

  • 链表: 这个很简单,直接遍历链表查找就行了
  • TreeBin结点: 去调用TreeBin中的find方法去查找。其中,TreeBin里面是实现了读写分离锁的。TreeBin通过root属性维护红黑树的根结点,因为红黑树在旋转的时候,根结点可能会被它原来的子节点替换掉,在这个时间点,如果有其他线程要写这棵红黑树就会发生线程不安全问题,所以在ConcurrentHashMap中TreeBin通过waiter属性维护当前使用这棵红黑树的线程,来防止其他线程的进入。下面是一些锁的标识:
    秋招准备之——深入理解ConcurrentHashMap(JDK1.8)_第11张图片
  • ForwardNode: 如果是ForwardNode类型,说明此时链表正在迁移,但是此节点已经迁移完成了,所以,就通过ForwardNode中的nextTable属性,获取新表,在新表中去查找。查找的代码如下:
		Node<K,V> find(int h, Object k) {
            // loop to avoid arbitrarily deep recursion on forwarding nodes
            outer: for (Node<K,V>[] tab = nextTable;;) {
                Node<K,V> e; int n;
                if (k == null || tab == null || (n = tab.length) == 0 ||
                    (e = tabAt(tab, (n - 1) & h)) == null)
                    return null;
                for (;;) {
                    int eh; K ek;
                    if ((eh = e.hash) == h &&
                        ((ek = e.key) == k || (ek != null && k.equals(ek))))
                        return e;
                    if (eh < 0) {
                        if (e instanceof ForwardingNode) {
                            tab = ((ForwardingNode<K,V>)e).nextTable;
                            continue outer;
                        }
                        else
                            return e.find(h, k);
                    }
                    if ((e = e.next) == null)
                        return null;
                }
            }
        }

为什么链表在get时不需要加锁,而红黑树需要加锁: 在更新期间链表遍历总是可以进行的,但是树遍历不行,因为树旋转时可能会改变根结点或者其链接。而且,链表中成员都是valuenext都是volatile修饰的,所以当对链表中的元素有修改时,对其他线程是可见的。
秋招准备之——深入理解ConcurrentHashMap(JDK1.8)_第12张图片
最后,贴一下get方法的源码:

public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        int h = spread(key.hashCode());
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            if ((eh = e.hash) == h) {  //总是检查头结点
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            else if (eh < 0)  //在迁移或都是TreeBin
                return (p = e.find(h, key)) != null ? p.val : null;
            while ((e = e.next) != null) {  //链表则直接遍历查询
                if (e.hash == h &&
                    ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;
    }

七、remove操作

remove方法和put方法很像,他其实是调用了一个replaceNode的方法,然后将对应的value设置为null。代码如下

public boolean remove(Object key, Object value) {
        if (key == null)
            throw new NullPointerException();
        return value != null && replaceNode(key, null, value) != null;
    }

replacaNode方法的实现如下,可以看到,最后,需要通过addCount方法去更新size,只不过这里的参数是负数。具体我再不细说了。

final V replaceNode(Object key, V value, Object cv) {
        int hash = spread(key.hashCode());
        for (ConcurrentHashMap.Node<K,V>[] tab = table;;) {
            ConcurrentHashMap.Node<K,V> f; int n, i, fh;
            //map中不包含key的元素
            if (tab == null || (n = tab.length) == 0 ||
                    (f = tabAt(tab, i = (n - 1) & hash)) == null)
                break;
            //正在扩容迁移数据,去帮助迁移
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                boolean validated = false;
                //对桶加锁
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        //在链表中删除节点
                        if (fh >= 0) {
                            validated = true;
                            for (ConcurrentHashMap.Node<K,V> e = f, pred = null;;) {
                                K ek;
                                if (e.hash == hash &&
                                        ((ek = e.key) == key ||
                                                (ek != null && key.equals(ek)))) {
                                    V ev = e.val;
                                    if (cv == null || cv == ev ||
                                            (ev != null && cv.equals(ev))) {
                                        oldVal = ev;
                                        if (value != null)
                                            e.val = value;
                                        else if (pred != null)
                                            pred.next = e.next;
                                        else
                                            setTabAt(tab, i, e.next);
                                    }
                                    break;
                                }
                                pred = e;
                                if ((e = e.next) == null)
                                    break;
                            }
                        }
                        //红黑树中删除节点
                        else if (f instanceof ConcurrentHashMap.TreeBin) {
                            validated = true;
                            ConcurrentHashMap.TreeBin<K,V> t = (ConcurrentHashMap.TreeBin<K,V>)f;
                            ConcurrentHashMap.TreeNode<K,V> r, p;
                            if ((r = t.root) != null &&
                                    (p = r.findTreeNode(hash, key, null)) != null) {
                                V pv = p.val;
                                if (cv == null || cv == pv ||
                                        (pv != null && cv.equals(pv))) {
                                    oldVal = pv;
                                    if (value != null)
                                        p.val = value;
                                    else if (t.removeTreeNode(p))
                                        setTabAt(tab, i, untreeify(t.first));
                                }
                            }
                        }
                    }
                }
                if (validated) {
                    if (oldVal != null) {
                        if (value == null)
                            //最后,去更新size
                            addCount(-1L, -1);
                        return oldVal;
                    }
                    break;
                }
            }
        }
        return null;
    }

八、总结

最后,对ConcurrentHashMap做一个总结:

  • 通过CAS操作和桶粒度的锁 ,保证线程安全
  • 在对集合修改时,如果数组正在扩容,则先去帮忙扩容迁移数据,迁移完了,再重新去修改数据
    最后,再大呼一声,Doug Lea牛逼!

你可能感兴趣的:(复习)