ConcurrentHashMap原理分析(三)-计数
概述
由于ConcurrentHashMap是一个高并发的集合,集合中增删就比较频繁,那计数就变成了一个问题,如果使用像AtomicInteger这样类型的变量来计数,虽然可以保证原子性,但是太多线程去竞争CAS,自旋也挺浪费时间的,所以ConcurrentHashMap使用了一种类似LongAddr的数据结构去计数,其实LongAddr是继承Striped64,有关于这个类的原理大家可以参考这篇文章:并发之STRIPED64(累加器)和 LONGADDER,大家了解了这个类的原理,理解ConcurrentHashMap计数就没有一点压力了,因为两者在代码实现上基本一样。
ConcurrentHashMap计数原理
private transient volatile long baseCount; private transient volatile CounterCell[] counterCells; @sun.misc.Contended static final class CounterCell { volatile long value; CounterCell(long x) { value = x; } }
ConcurrentHashMap就是依托上面三个东东进行计数的,那下面就详细解释一下这三个东东。
- baseCount:最基础的计数,比如只有一个线程put操作,只需要通过CAS修改baseCount就可以了。
- counterCells:这是一个数组,里面放着CounterCell对象,这个类里面就一个属性,其使用方法是,在高并发的时候,多个线程都要进行计数,每个线程有一个探针hash值,通过这个hash值定位到数组桶的位置,如果这个位置有值就通过CAS修改CounterCell的value(如果修改失败,就换一个再试),如果没有,就创建一个CounterCell对象。
- 最后通过把桶中的所有对象的value值和baseCount求和得到总值,代码如下。
final long sumCount() { CounterCell[] as = counterCells; CounterCell a; //baseCount作为基础值 long sum = baseCount; if (as != null) { //遍历数组 for (int i = 0; i < as.length; ++i) { if ((a = as[i]) != null) //对每个value累加 sum += a.value; } } return sum; }
通过上面的分析,相信大家已经了解了高并发计数的方法,在上面的介绍中提到一点,每个线程的探针hash值,大家先有个印象,一会分析代码的时候会使用这个,其实这个值很有趣。
addCount()方法
又到了这个方法,在上篇文章中分析扩容的时候也分析过这个方法,不过分析的是一部分,现在分析另一部分。
private final void addCount(long x, int check) { CounterCell[] as; long b, s; //如果数组还没有创建,就直接对baseCount进行CAS操作 if ((as = counterCells) != null || !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) { CounterCell a; long v; int m; //设置没有冲突标志位为true boolean uncontended = true; //第一个条件as == null成立说明,counterCells数组没有创建,而且通过CAS修改baseCount失败,说明有别的线程竞争CAS //a = as[ThreadLocalRandom.getProbe() & m]) == null,说明数组是创建了,但是通过探针hash定位的桶中没有对象 //如果有对象,执行最后一个,进行CAS修改CounterCell对象,如果也失败了,就要进入下面的方法 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(); } //省略部分代码 }
上面整体过程还是很好理解的,就是我在上面介绍计数原理中说的步骤,但是上面有一个地方需要注意下就是:
a = as[ThreadLocalRandom.getProbe() & m]) == null
这里的ThreadLocalRandom.getProbe(),看着名字大家应该可以猜到应该是要获取一个随机值,因为有Random嘛,其实这里就是在获取一个随机值。那既然是要获取一个随机值,为什么要使用ThreadLocalRandom,而不直接使用Random,那看到ThreadLocal,大家也应该可以想到就是这个随机值的获取,线程之间是隔离的,每个线程获取自己的随机值,互相之间没有影响,为什么要互相之间没有影响呢?因为Random要实现随机,有一个关键的东西就是种子(seed),具体过程如下:
- 初始化Random的时候,如果没有传seed,就根据时间戳进行一些计算初始化一个种子
- 如果某个线程需要获取随机数,先通过CAS更新种子seed1 = function1(seed)
- 根据seed1,计算随机数 = function2(seed1)
- 上面的两个function是固定的,就是说如果初始种子一样,两个不同的Random对象生成随机数会完全一样
上面的过程咋一看没啥问题,其实最大问题就是第二步那个CAS,在高并发的时候效率会很差,所以这里才使用了ThreadLocalRandom,相当于每个线程都有一个Random,都有自己的种子,这样就不会存在多线程竞争修改种子。想要详细了解ThreadLocalRandom,参考:并发包中ThreadLocalRandom类原理剖析
fullAddCount()方法
其实这个方法没什么好分析的,其实就是Striped64#longAccumulate()方法,据我的观察好像一行不差,完全一样,这里还是分析下吧。
1 private final void fullAddCount(long x, boolean wasUncontended) { 2 int h; 3 //上面我贴出来了介绍ThreadLocalRandom的文章,这里如果是首次获取,其实就是0 4 if ((h = ThreadLocalRandom.getProbe()) == 0) { 5 //如果为0,就初始化,这里其实就是把种子和随机数设置到(Thread)线程中 6 ThreadLocalRandom.localInit(); // force initialization 7 h = ThreadLocalRandom.getProbe(); 8 wasUncontended = true; 9 } 10 boolean collide = false; // True if last slot nonempty 11 12 //死循环,保证计数一定成功 13 for (;;) { 14 CounterCell[] as; CounterCell a; int n; long v; 15 //说明数组已经初始化,在后面有判断数组没有初始化的情况 16 if ((as = counterCells) != null && (n = as.length) > 0) { 17 //这里是不是和ConcurrentHashMap定位桶的位置很像,其实是一摸一样的 18 //说明数组中这个位置没有元素 19 if ((a = as[(n - 1) & h]) == null) { 20 //这个字段保证数组新增节点,扩容只有一个线程在进行,防止多线程并发 21 //这里限制一个线程处理只是在数组新增节点和扩容的时候,修改对象的值并不需要限制这个变量 22 if (cellsBusy == 0) { // Try to attach new Cell 23 CounterCell r = new CounterCell(x); // Optimistic create 24 25 //如果为0表示没有别的线程在修改数组,通过CAS修改为1,表示当前线程在修改数组 26 if (cellsBusy == 0 && 27 U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) { 28 boolean created = false; 29 try { // Recheck under lock 30 CounterCell[] rs; int m, j; 31 //再次校验,确保数组没有变化 32 //rs[j = (m - 1) & h] == null,再次确认该位置是否为null,防止别的线程插入了 33 if ((rs = counterCells) != null && 34 (m = rs.length) > 0 && 35 rs[j = (m - 1) & h] == null) { 36 //插入数组 37 rs[j] = r; 38 created = true; 39 } 40 } finally { 41 //释放CAS锁 42 cellsBusy = 0; 43 } 44 if (created) 45 //如果新节点插入成功,表示计数已经成功,这里直接break了 46 break; 47 //如果失败会一直重试 48 continue; // Slot is now non-empty 49 } 50 } 51 collide = false; 52 } 53 else if (!wasUncontended) // CAS already known to fail 54 wasUncontended = true; // Continue after rehash 55 56 //定位到桶中有值,然后通过CAS修改其值 57 else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x)) 58 break; 59 //下面的两个elseif其实是为了防止数组一直扩容使用的,数组的最大容量就是CPU的核数 60 //因为核数就是并发数,数组太大没有意义,没有那么多线程可以同时操作 61 //就是说上面的新建节点或者CAS修改值事变了,就会到这里,然后拦截住,不让执行扩容 62 else if (counterCells != as || n >= NCPU) 63 collide = false; // At max size or stale 64 else if (!collide) 65 collide = true; 66 //先竞争到CAS锁,然后执行扩容 67 else if (cellsBusy == 0 && 68 U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) { 69 try { 70 if (counterCells == as) {// Expand table unless stale 71 72 //每次扩容成原来的两倍 73 CounterCell[] rs = new CounterCell[n << 1]; 74 //复制元素,看过ConcurrentHashMap的扩容,再看这个,简直就跟一个大学生看小学数学题一样,😄 75 for (int i = 0; i < n; ++i) 76 rs[i] = as[i]; 77 counterCells = rs; 78 } 79 } finally { 80 cellsBusy = 0; 81 } 82 collide = false; 83 continue; // Retry with expanded table 84 } 85 //这里是重新生成一个随机数,换个位置试试,比如上面新增节点失败了,换个位置试试,或者通过CAS修改值失败,也换个位置再试试 86 h = ThreadLocalRandom.advanceProbe(h); 87 } 88 //这里就是判断数组没有初始化的情况,搞不明白没啥放在这里,不放在开头 89 else if (cellsBusy == 0 && counterCells == as && 90 U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) { 91 boolean init = false; 92 try { // Initialize table 93 if (counterCells == as) { 94 //初始化的数组大小是2,非常小 95 CounterCell[] rs = new CounterCell[2]; 96 rs[h & 1] = new CounterCell(x); 97 counterCells = rs; 98 init = true; 99 } 100 } finally { 101 cellsBusy = 0; 102 } 103 if (init) 104 break; 105 } 106 //如果以上CAS修改,创建新节点都失败了,这里还有一道防线,通过CAS修改baseCount 107 //这也是再addCount中,当判断数组不为空,不先修改下baseCount试试,而是直接跳到这个方法中,因为在这个方法中也会修改baseCount 108 else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x)) 109 break; // Fall back on using base 110 } 111 }
方法流程梳理如下:
- 判断数组是否为空,为空的话初始化数组
- 如果数组存在,通过探针hash定位桶中的位置,如果桶中为空,新建节点,通过CAS锁插入数组,如果成功,结束,如果失败转到第5步
- 如果定位到桶中有值,通过CAS修改,如果成功,结束,如果失败向下走
- 如果数组大小小于CPU核数,扩容数组
- 重新计算探针hash
总结
计数的原理就是概述中说的使用的是striped64,为啥不直接继承striped64,不太懂,可能striped64出来晚一点,里面使用到ThreadLocalRandom,这个其实还是挺有意思的,总的来说计数过程并不复杂,是看ConcurrentHashMap源码的时候比较愉快的部分。