阿里巴巴写进Java开发手册里推荐的JUC工具类:LongAdder,确定不点进来学一下嘛?

107 阅读15分钟

大家好,我是程序员牛肉。

昨天又从朋友那里倒腾过来一个好的题材:“JUC中的高性能计数器工具类LongAdder”。初步在网上搜索之后,发现其实阿里巴巴的Java开发手册中也有对于这个工具包的推荐:

图片

JUC包下的所有工具类我都很感兴趣,因此昨晚加急看完了源码,在感叹其设计精巧的同时也决定写这篇文章分享给大家。

在高并发的场景下如果有统计类的需求的话,一开始我们使用的计数器是AtomicLong。但是AtomicLong的性能会随着并发量的上升而急剧下降,让我在代码层面看一看为什么会这样。

AtomicLong的增值操作调用的是getAndIncrement方法:

图片

这个方法的底层走的是unsafe的CAS操作:

图片

上面这段代码的逻辑很简单:手动写了一个死循环比较期望值,如果持有值与期望值相等就进行交换。

坏就坏在这里了:AtomicLong做自增操作的时候使用CAS+自旋会导致大量的线程在这里频繁的比较失败和自旋,这在大并发量的背景下,对整体项目的性能更是迎头重击。

图片

所以JDK1.8中引入了一个新的原子操作类来解决这个问题。而它就是我们今天要介绍的LongAdder类:

图片

Lon``gAdder的整体设计思想并不难。我们照着AtomicLong的缺陷推理就能推理出来。

前面我们说过:AtomicLong的性能瓶颈主要在于高并发环境下会有大量的线程进入“比较失败和自旋”的漩涡中。

那我们就对这一部分进行优化,整体采用“分治+求和”思想。

A``tomic``Long不是多个线程争夺一个value嘛?现在我们在LongAdder中就创建多个临时的value来供线程进行增值操作,而真正的value值等于这些临时value的值求和。

图片

通过增加临时value的操作,我们大大减轻了高并发的场景下多线程在CAS操作中的竞争激烈度。

[LongAdder的基本思路就是分散热点,将value值的新增操作分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的那个value值进行CAS操作,这样热点就被分散了,冲突的概率就小很多。]

我们可以尝试起一千个线程,每个线程都分别基于AtomicLong和LongAdder执行自增操作一万次。看一看AtomicLong和LongAdder的性能差距:

图片

无需多言,这已经是单方面的羞辱了。现在知道LongAdder的性能有多高了吧?

如果你能理解我上面说的这些东西,那恭喜你已经掌握LongAdder的基本原理了,刷会抖音休息一下吧。

图片

休息过后,让我们尝试来深入LongAdder的源码部分,学习其中精妙的设计思想。

严格的说LongAdder中的具体操作流程应该是这样的:

图片

让我们来看看LongAdder中最重要的add方法,注释我一行行都写清了,这块一定要看:

public void add(long x) {
    //cs是来分摊竞争压力的cell数组
    Striped64.Cell[] cs;
    //b是base的期望值 
    long b;
    //如果cell数组已经存在或者cas写base的时候写失败
    if ((cs = this.cells) != null || !this.casBase(b = this.base, b + x)) {
    //随机的获取一个数组作为cell数组的下标
        int index = getProbe();
        //设置存在竞争
        boolean uncontended = true;
        //期望值
        long v;
        //cell数组的长度
        int m;
        Striped64.Cell c;
          
        //1.((cs == null || (m = cs.length - 1) < 0 )
        //true->说明此时cell还未初始化。此时的背景是多线程写base的时候出现竞争了。
        //false->说明此时cell已经初始化,现在线程应该根据前面分配的index往cell数组中的指定位置写值
        if (cs == null || (m = cs.length - 1) < 0
            || 
            // 2.(c = cs[index & m]) == null 
            //true->这个下标位置为空,需要创建一个longAccumulate进行累加操作
            //false->不为空,下一步就需要在该位置上进行累加操作
           (c = cs[index & m]) == null
           || 
           //3.这里有取反操作,所以要注意:
           //true->说明这个cell内有竞争
           //false->说明cas操作成功
           !(uncontended = c.cas(v = c.value, v + x))) {
           
           //也就是说一共会有以下操作会走到这里:
           //1.true->说明此时cell还未初始化。此时的背景是多线程写base的时候出现竞争了。          
           //2.cell对应的下标位置为空,需要创建一个longAccumulate进行累加操作        
           //3.这个cell内有竞争,需要进行重试    
            this.longAccumulate(x, (LongBinaryOperator)null, uncontended, index);
        }
    }
}

这一长串代码,实际上可以被总结为:

  • 首先尝试更新 base 值。

  • 如果更新失败(或 cells 数组已存在),则获取一个随机索引,并尝试更新对应 Cell 的值。

  • 如果 Cell 未初始化、为空或更新失败(存在竞争或者cell数组在扩容),则调用 longAccumulate 方法处理更复杂的累加逻辑

让我们继续来看longAccumulate这个方法,先介绍一下传入的参数:

图片

让我们继续来看代码:

图片

这里的逻辑是:如果index的位置为0(未分配位置),就给这个index重新取一个值。并且设置wasUncontended为“未竞争”。

这么做的意义是因为如果当前线程的hash值h=getProbe()为0,0与任何数取模都是0,会固定到数组第一个位置,所有使用0作为初始 index 的线程都会尝试更新 Cell 数组的第一个位置,这会导致激烈的线程竞争。所以这里做了优化,使用ThreadLocalRandom为当前线程重新计算一个hash值。

最后设置wasUncontended = true,这里含义是重新计算了当前线程的hash后认为此次不算是一次竞争。hash值被重置就好比一个全新的线程一样,所以设置了竞争状态为true

之后就开始进入逆天if循环了,首先在最外围写一个while死循环。实现“自旋”。

图片

为了防止有些同学看懵逼,我们用文字串一下逻辑。在这一过程中,我们要明白:无论是多少层if嵌套循环,它本质上代表的还是一种情况而已。我们只要慢慢梳理就是可以梳理出来的。

最顶层一共有三种情况,分别是:

情况一:cells数组已经初始化了,接下来的操作就是尝试把线程当前的值写到对应的cell中。

图片

具体内部的条件有点复杂,我们后面再讲。先说完顶层的大分支再说。

情况二:cell数组还没有初始化(所以情况1走不通),接下来的操作就是先持有锁,再去初始化cell数组。

图片

这个代码就很简单,我们可以看到我们创建了一个叫rs的cell对象。之后将[index&1]的位置赋值为一个初始值为x的cell对象。

[INDEX&1 实际上是(index & (数组长度-1),只不过此时的数组长度是2,做完减一操作之后为1,直接写死在这里了。

我们可以把(index & (数组长度-1))看作是一个取余操作,思想和Hashmap中的设计一摸一样。这里不多作介绍]

情况三:走到这里说明当前的cell数组正在被初始化。因此它争取不到cellsBusy锁。开始尝试调用casBase操作来往base中写值。如果成功就结束本次循环。

图片

顶层的分支介绍完了,现在就只剩下情况一的小分支了。让我们继续看:

情况1.1:cell数组虽然存在,但是内部对应的下标位置无cell。因此我们就要创建出来一个cell填充到cell数组中。

具体一行一行的代码逻辑下面会作解释的

图片

情况1.2:说明在走到1.1中的this.cellsBusy的时候获取锁失败。在这个我们要修改竞争条件为“有竞争”。

图片

情况1.3:cell数组存在,而且对应的下标位置中cell也不为空。开始尝试给cell做加值操作。

图片

当累加完毕之后,开始走扩容操作。如果当前的cell数组长度小于当前电脑的CPU核心数并且cell的引用没有发生变化的话,就开始尝试扩容。

图片

至此所有的大逻辑就已经讲完,具体的逐行代码讲解如下:

//while死循环做自旋
  while(true) {
      Cell[] cs;
      //cell数组的长度
      int n;
      //V表示期望值
      long v;
      //case1:cell已经初始化了,当前线程就要把值写到对应的cell数组中
      if ((cs = this.cells) != null && (n = cs.length) > 0) {
       
          Cell c;
          //case1.1:如果当前线程对应的cell是空,就走初始化的这一套
          if ((c = cs[n - 1 & index]) == null) {
          //当前锁未被占用
              if (this.cellsBusy == 0) {
              //用当前待插入的X值去初始化cell
                  Cell r = new Cell(x);
                  //再次判断锁是否被占用,如果没有被占用就使用casCellsBusy来获取锁
                  if (this.cellsBusy == 0 && this.casCellsBusy()) {
                      try {
                          Cell[] rs;
                          //m代表cell数组长度
                          int m;
                          //j代表当前线程命中的索引下标
                          int j;
                          //如果cell数组的引用又发生变化或者cell数组rs对应的位置中不为空。说明已经有线程进行过更改了。为了避免覆盖旧值,我们放弃本次更改。
                          if ((rs = this.cells) == null || (m = rs.length) <= 0 || rs[j = m - 1 & index] != null) {
                              continue;
                                }
                          //在cell数组的j位置上填充上cell r,结束本次循环
                          rs[j] = r;
                          break;
                      } finally {
                      //释放当前的锁
                          this.cellsBusy = 0;
                      }
                  }
              }
              //修改扩容意向为false。
              collide = false;
          } 
          //case1.2: 只有可能出现在cell初始化之后,当前线程竞争修改失败才会是false。
          elseif (!wasUncontended) {
              wasUncontended = true;
          } else {
          //case1.3: 当前线程rehash过,新命中的cell不为空,开始走写cell操作。
          //true->写新的cell写成功
          //fasle->写新的cell的时候又存在竞争,开始走下一轮自旋
              if (c.cas(v = c.value, fn == null ? v + x : fn.applyAsLong(v, x))) 
                  break;
              }
              
              //这里的Ncpu的意思是你的电脑的cpu核心数。
              //case1.4: 如果cell数组长度小于Ncup并且当前线程持有的仍然是cs这个引用的话。(其他线程没有对cs进行扩容等操作)
              if (n < NCPU && this.cells == cs) {
                 //将扩容标志位从false设置为true。 
                  if (!collide) {
                      collide = true;
                  } 
                  //开始走真正的扩容,先去判断锁的情况(cellsBusy),如果没有线程持有锁的情况下,在使用casCellsBusy来持有锁
                  elseif (this.cellsBusy == 0 && this.casCellsBusy()) {
                  //再次判断一下当前线程持有的是不是cs这个数组。
                       try {
                          if (this.cells == cs) {
                          //使用copyof方法来将cs数组扩容为原线程的两倍长度。
                              this.cells = (Cell[])Arrays.copyOf(cs, n << 1);
                          }
                      } 
                      //释放锁
                      finally {
                          this.cellsBusy = 0;
                      }
                      //由于已经进行过扩容,因此在这里修改扩容意向为false。
                       collide = false;
                      continue;
                  }
              } 
              //case1.5: 由于当前的cell数组的长度已经超过当前电脑的CPU核心数了。因此不进行扩容。
              //         由于当前线程持有的不再是cs这个cell数组了。说明已经有其他线程做了扩容操作,因此不再进行扩容  
              else {
                  collide = false;
              }
          }
          //重置线程的hash值,相当于是重新给线程找了一个索引下标。
          index = advanceProbe(index);
      }
      //case2:cell数组还没初始化,
      //       当前的cell没被修改,仍然指向cs
      //       获取锁成功(CellsBusy :0为未持有,1为已持有)
      elseif (this.cellsBusy == 0 && this.cells == cs && this.casCellsBusy()) {
          try {
          //再次判断,避免线程并发下的问题:两个线程都进行了初始化,导致丢数据的情况
              if (this.cells == cs) {
                  Cell[] rs = new Cell[2];
                  rs[index & 1] = new Cell(x);
                  this.cells = rs;
                  break;
              }
          } finally {
              this.cellsBusy = 0;
          }
      } 
      //case3: 当前的cell是加锁状态
      //        cell被其他线程初始化了。导致当前线程找不到对应的cell,当前线程需要把数据累加到base中。
      elseif (this.casBase(v = this.base, fn == null ? v + x : fn.applyAsLong(v, x))) {
          break;
      }
  }
}

基于这种操作,我们就实现了LongAdder中的“分摊竞争压力”。不得不说,这段代码真的是简洁高效且逻辑复杂。

一遍看不懂是正常的,我非常推荐你自己去看一看。下来我们看一看这个cell数组中的cell对象:

图片

这个@Contended注解比较有意思一点:

Cell注定要被多线程所共享,所以在这一过程中数组中的每一个cell对象都使用了@Contended注解来避免伪共享问题。

[伪共享是指多个线程访问不同的变量,但这些变量恰好位于同一个缓存行中。由于缓存行是共享的,当一个线程修改其中一个变量时,整个缓存行的状态会被标记为“脏”(dirty),其他线程必须重新加载整个缓存行,即使它们访问的是缓存行中的其他变量。这种不必要的缓存行刷新和重新加载会导致性能下降。]

假设我们有以下代码:

public class Counter {
    int a = 0;
    int b = 0;

    public void incrementA() {
        a++;
    }

    public void incrementB() {
        b++;
    }
}

在多线程环境中,线程 1 调用 incrementA(),线程 2 调用 incrementB()a 和 b 是两个独立的变量,但它们可能位于同一个缓存行中。当线程 1 修改 a 时,整个缓存行会被标记为脏,线程 2 也需要重新加载缓存行,尽管它只关心 b。这就是伪共享问题。

最后``我们再来看一看LongAdder中是如何对base和cells数组的值进行求和的:

图片

没啥好``讲的,直接硬遍历了。需要注意的是这里的sum并不一保证能拿到精确值。

这是因为当多个线程同时更新 `LongAdder` 时,它们可能正在修改 `base` 值或者 `Cell` 数组中的```值。由于这些更新操作是并发进行的,所以在调用 sum()` 方法时,可能有些更新尚未完成,从而导致返回的总和不是最新的精确值。``

在读完这个类之后,其实越来越发现JUC的底层很多类的设计思想是共通的。LongAdder中所体现出来的“分治”的思想,其实就有点像“分段锁”。

那今天关于“LongAdder”的文章就介绍到这里了。相信通过我的介绍,你已经大致了解了LongAdder。希望我的文章可以帮到你。

关于JUC中的各种类,你有什么想分享的嘛?欢迎在评论区留言。

关注我,带你了解更多技术干货。