只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年7个月粉丝:14关注:8

42、累加器

内容来自王争 Java 编程之美

前面几节中我们讲到:锁、自旋 + CAS、原子类,对于如下代码,如果我们希望将其改造成线程安全的,那么该如何来做呢?

public class Counter {
private long sum;
public long get() {
return sum;
}
public void add(long value) {
sum += value;
}
}

为了让以上代码线程安全,我们只需要对 add() 函数进行处理,对于 add() 函数

  • 第一种线程安全的实现方式是:对 add() 函数加锁,但是加锁会影响程序本身的性能
  • 第二种线程安全的实现方式是:使用自旋 + CAS 的方式,这样可以避免加锁,在低并发的情况下,这种实现方式的性能远优于加锁
    但是从零实现自旋 + CAS,需要用到 Unsafe 类,风险比较大且编程复杂
  • 第三种线程安全的实现方式是:直接使用封装了自旋 + CAS 的原子类,相对于第二种实现方式,编程实现简单了许多
// 线程安全实现方式一: 加锁
public void add_lock(long value) {
synchronized (this) {
sum += value;
}
}
// 线程安全实现方式二: 自旋 + CAS
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long sumOffset;
static {
try {
sumOffset = unsafe.objectFieldOffset(Counter.class.getDeclaredField("sum"));
} catch (Exception ex) {
throw new Error(ex);
}
}
public void add_cas(long value) {
boolean succeeded = false;
while (!succeeded) {
long oldValue = sum;
succeeded = unsafe.compareAndSwapLong(this, sumOffset, oldValue, oldValue + value);
}
}
// 线程安全实现方式三: 原子类
private AtomicLong atomicSum = new AtomicLong(); // 替代 sum 成员变量
public void add_atomic(int value) {
atomicSum.addAndGet(value);
}

实际上,针对累加这种特殊的业务场景,JUC 提供了专门的 LongAdder 累加器,它比 AtomicLong 原子类性能更高
在高并发的情况下,多线程同时执行 add() 函数,AtomicLong 会因为大量线程不断自旋而性能下降,LongAdder 却可以持续保持高性能
那么如此高性能,LongAdder 是如何做到的呢?本节,我们就来讲一讲 LongAdder 累加器的用法及其实现原理

1、基本用法

LongAdder 在 JDK 8 中引入,功能非常专精尖,用来实现线程安全的高性能累加操作
LongAdder 中包含的主要函数有两个:add() 函数和 sum() 函数,add() 函数用来累加,sum() 函数用来返回累加之后的总和

我们使用 LongAdder 对 Counter 类进行改造,以保证其线程安全,改造之后的代码如下所示,这里需要注意的是

  • 在高并发的情况下,sum() 函数并不能返回精确的累加值,这也是其为了实现高性能所付出的代价
  • 也正因如此,LongAdder 一般仅限用于对累加值的精确性要求不高的场合,比如:应用于数据统计中

至于 sum() 函数为什么不能返回精确值,我们稍后讲解

public class CounterLongAdder {
private LongAdder ladder = new LongAdder();
public void add(long value) {
ladder.add(value);
}
public long get() {
return ladder.sum();
}
}

LongAdder 的使用方法非常简单,但其底层实现原理却比较复杂
为了实现高性能累加,LongAdder 的底层实现原理涉及:数据分片、哈希优化、去伪共享、非精确求和等各种优化手段,接下来我们就一一讲解一下它们

2、数据分片

2.1、基本实现原理

在高并发的情况下,AtomicLong 性能不高的主要原因是:多线程同时 CAS 更新一个变量(累加变量)
相比于 AtomicLong,在高并发的情况下,LongAdder 的累加操作依然可以保持高性能,这主要归功于数据分片,如下图所示

  • LongAdder 将一个累加变量分解为多个累加变量
    多线程同时执行累加操作时,不同的线程对不同的累加变量进行操作,线程之间互不影响,这样就避免了一个线程需要等待另一个线程操作完成之后再操作
  • 当调用 LongAdder 上的 sum() 函数时,LongAdder 将多个累加值相加,便可以得到最终的累加值

image

2.2、LongAdder、Striped64、Cell

以上只是基本的实现原理,在具体的代码实现中,LongAdder 还包含很多细节优化,我们结合 LongAdder 的源码,来看下累加操作的主要处理流程
LongAdder 中的部分源码如下所示,以下源码包含 LongAdder 类中的核心的成员变量和函数

abstract class Striped64 extends Number {
static final int NCPU = Runtime.getRuntime().availableProcessors();
transient volatile Cell[] cells;
transient volatile long base;
transient volatile int cellsBusy;
// LongAdder 中 add() 函数的实现依赖于 Striped64 中的 longAccumulate() 函数
final void longAccumulate(long x, LongBinaryOperator fn, boolean wasUncontended) {
// ...
}
// 注意这个注解
@sun.misc.Contended
static final class Cell {
volatile long value;
Cell(long x) {
value = x;
}
// 以下是实现 value 的 CAS 函数
private static final sun.misc.Unsafe UNSAFE;
private static final long valueOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
valueOffset = UNSAFE.objectFieldOffset(Cell.class.getDeclaredField("value"));
} catch (Exception e) {
throw new Error(e);
}
}
final boolean cas(long cmp, long val) {
return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val);
}
}
}
public class LongAdder extends Striped64 {
public LongAdder() {}
public void add(long x) {
// ...
}
public long sum() {
// ...
}
}

2.3、Striped64 成员变量

从以上代码,我们可以看出,LongAdder 中不包含任何成员变量,成员变量完全继承自 Striped64(分段锁、分段计数器),我们先简单介绍一下这几个成员变量,如下所示

1、cells

cells 数组保存多个累加变量,Cell 本身的定义非常简单,只包含一个成员变量 value,以及一个操作 value 的 cas() 函数
为了节省空间,cells 数组支持动态扩容,并且最开始初始化为 null,只有第一次出现线程竞争执行 add() 函数时,cells 数组才会被创建

2、NCPU

NCPU 表示 JVM 最大可用 CPU 核数
cells 数组的长度必须是 2 的幂次方,每次扩容都会增加为原来数组长度的 2 倍
当 cells 数组长度为大于等于 NCPU 的最小 2 的幂次方时(比如:如果 NCPU 为 6,那么 cells 数组最大长度为 8),cells 数组就不再扩容了
cells 数组的长度之所以要求是 2 的幂次方,跟 HashMap 中数组的长度是 2 的幂次方的原因相同
都是为了快速求模(求模的使用场景待会会讲到),建议你回过头去看下第 13 节

之所以 cells 数组的长度大于等于 NCPU 之后就不再扩容,是因为:同时执行累加操作的线程数不可能大于 CPU 的核数
当 cells 数组的长度大于等于 NCPU 时,cells 数组中的累加变量个数,便可以满足最大 NCPU 个线程同时互不干涉地执行累加操作

3、base

base 是一个比较特殊的累加变量
当线程执行 add() 函数时,首先尝试 CAS 更新 base(将新增值累加到 base上)

  • 如果成功:直接返回
  • 如果失败:执行分片累加的逻辑(将新增值累加到 cells 数组中)

在低并发的情况下,使用 base 可以有效避免执行复杂的分片累加逻辑

4、cellBusy

cellBusy 用来实现锁,类似 ReetrantLock 中的 state 字段
cellBusy 初始化为 0,多个线程通过 CAS 竞争更新 cellBusy,谁先将 cellBusy 设置为 1,谁就持有了这把锁,这把锁用来保证三个操作的线程安全性

  • 避免多个线程同时创建 cells 数组(Cell[] cells = new Cell[n])
  • 创建 cells 数组中的 Cell 对象(cells[i] = new Cell())
  • 对 cells 数组进行动态扩容

2.4、LongAdder 的累加过程

对核心成员变量有了简单了解之后,我们再来看下 LongAdder 的累加过程,对应的源码如下所示
为了展示基本实现原理,避免过多的实现细节的干扰,我对代码进行了稍许调整

// 位于 LongAdder.java
public void add(long x) {
Cell[] as;
long b, v;
int m;
Cell a;
if (!casBase(b = base, b + x)) {
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 || (a = as[getProbe() & m]) == null ||
!(uncontended = a.cas(v = a.value, v + x)))
longAccumulate(x, null, uncontended);
}
}

上述代码非常简洁,可读性有点差,我们对上述代码逻辑做了梳理,如下图所示

  • getProbe() 为哈希函数(此哈希函数的实现原理稍后讲解),返回当前线程对应的哈希值
    因为 cells 数组长度 n 为 2 的幂次方,所以 getProbe() & (n - 1) 就相当于 getProbe() % n(关于这一点,请参看第 13 节 HashMap 底层原理的讲解)
    因为位运算比取模运算效率要高,因此 getProbe() & (n - 1) 要比 getProbe() % n 的执行效率高
  • getProbe() & (n - 1) 得到的是当前线程应该更新的累加变量在 cells 数组中的下标
    也就是说,如果 getProbe() & (n - 1) 等于 k,那么当前线程会通过 CAS 将新增值 x 累加到 cells[k] 的 value 变量上

image

从以上代码逻辑,我们可以发现

  • 只有当线程执行 cas 成功更新 base,或者执行 cas 成功更新 cells 数组中的累加变量时,add() 函数才会直接返回
  • 否则线程会进入 Striped64 类中的 longAccumulate() 函数继续执行

进入 longAccumulate() 函数主要对应三种情况

  • cells 为空
  • 线程要更新的 cells 中的 Cell 对象为 null
  • cas 更新 cells 中的 Cell 对象的 value 值失败

longAccumulate() 函数的代码比较长,涉及比较多的实现细节,这里我们简单介绍一下它的核心逻辑,具体如下图所示
其中主要包含 4 部分逻辑:创建 cells 数组、创建 cells 数组中的 Cell 对象、cells 数组扩容、cas 执行 cells 数组上的累加操作
image

3、哈希优化

从上述代码逻辑中,我们可以发现,代码的执行过程,会频繁用到哈希函数(上图中出现 h 的地方)
因此哈希函数的执行效率严重影响 LongAdder 的执行效率,因此 LongAdder 对哈希函数进行了一些性能优化

当线程通过 cas 将新增值累加 base 失败时,线程会通过 cas 将新增值累加 cells 数组中,那么到底累加到 cells 数组中哪个 Cell 对象上呢?
前面提到,对应 Cell 对象的下标通过 getProbe() % n 公式来计算得到,n 表示 cells 数组的长度,getProbe() 是哈希函数
因为 n 为 2 的幂次方,因此 getProbe() % n 可以转化为 getProbe() & (n-1),以提高计算速度

除此之外,哈希函数计算得到的哈希值,会保存在线程对应的 Thread 对象的成员变量中,之后便可以一直重复使用
除非发生冲突,两个线程同时执行 cas 更新同一个 Cell 对象,执行 cas 失败的线程会重新生成新的哈希值,并同步更新到对应的 Thread 对象中

// 直接获取当前线程对应的 Thread 对象的 PROBE 成员变量值
static final int getProbe() {
return UNSAFE.getInt(Thread.currentThread(), PROBE);
}
// 基于老的哈希值 probe 重新计算新的哈希值, 并存储到当前线程对应的 Thread 对象的 PROBE 成员变量中
static final int advanceProbe(int probe) {
probe ^= probe << 13; // xorshift
probe ^= probe >>> 17;
probe ^= probe << 5;
UNSAFE.putInt(Thread.currentThread(), PROBE, probe);
return probe;
}

4、去伪共享

细心的读者可能已经发现,Cell 类定义的前面添加了 @Contended(竞争)注解,注解看起来虽小,但其隐藏了一个很重要的优化,那就是去伪共享
去伪共享是提高多线程并发执行效率的重要手段,不仅在 LongAdder 中用到,在 Disruptor 高性能消息队列中也有用到
在解释去伪共享之前,我们先来解释一下什么是伪共享

前面我们提到,CPU 读写缓存的最小单元是缓存行,不同 CPU 上的缓存行大小不同,可以为 32 字节、64 字节或 128 字节等,常见的大小为 64 字节
参照第 9 节中对象大小的计算公式,Cell 对象头占 12 字节,value 成员变量为 long 类型,占 8 字节
对象头与 value 成员变量之间有 4 字节对齐填充,因此一个 Cell 对象占 24 字节
假设一个缓存行大小为 64 字节,那么两个 Cell 对象就有可能存储在同一个缓存行中,如下图所示
image

因为数据是以缓存行为单位来读写的
所以当线程 t1 从内存中读取 cellA 到缓存中时,会顺带着读取 cellB,同理,当线程 t2 从内存中读取 cellB 到缓存中时,会顺带着读取 cellA
当线程 t1 更新 cellA 的值时,按理来说并不会影响线程 t2 对 cellB 的缓存
但是缓存中的数据是按照缓存行来读写的,因此线程 t1 会将整个缓存行设置为无效,这就会导致线程 t2 对 cellB 的缓存也会失效,需要重新从内存中读取
同理,线程 t2 更新 cellB 值时,也会导致线程 t1 对 cellA 的缓存失效,两个线程互相影响,导致缓存频繁失效

以上问题就叫做伪共享(false sharing)问题,为了解决伪共享问题,我们可以使用 @Contended 注解,这个注解既可以标记在类上,也可以标记在变量上

  • 标记在变量上会强制这个变量独占一个缓存行
  • 标记在类上会强制这个类的对象独占一个缓存行,不够一个缓存行的会做对齐填充
    Cell 类使用 @Contended 标记,两个 Cell 对象便不会存储在同一个缓存行中,因此也就不会出现伪共享的问题了,缓存不再频繁失效,执行效率变高

5、非准确求和

前面我们提到,LongAdder 中的 sum() 函数会累加 base 和 cells 中的 Cell 对象的 value 值,从而得到最终的累加值
但是这个值是不准确的,或者说不一致的,这是为什么呢?我们先来看下 sum() 函数的源码,如下所示

public long sum() {
Cell[] as = cells;
Cell a;
long sum = base;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}

从代码中我们可以发现,LongAdder 在执行 sum() 函数时并没有加锁,也就是说:在执行 sum() 函数的同时,其他线程可以同时执行 add() 函数
这就会导致出现这样的情况:前面添加的值没有算到累加值中,反倒是后面添加的值算到了累加值中,如下图所示
image

6、更多类

实际上除了 LongAdder 之外,JUC 还提供了另外三个功能类似的类:LongAccumulator、DoubleAdder、DoubleAccumulator
DoubleAdder 和 DoubleAccumulator 跟 LongAdder 和 LongAccumulator 的区别:仅仅只是处理的数据类型不同而已,因此我们重点看下 LongAccumulator

6.1、LongAccumulator

从前面的讲解我们可以发现,LongAdder 只能实现累加操作,而 LongAccumulator 却可以实现更加丰富的统计操作,比如求最大值
LongAccumulator 类的部分源码如下所示

@FunctionalInterface
public interface LongBinaryOperator {
long applyAsLong(long left, long right);
}
public class LongAccumulator extends Striped64 {
private final LongBinaryOperator function;
private final long identity;
public LongAccumulator(LongBinaryOperator accumulatorFunction, long identity) {
this.function = accumulatorFunction;
base = this.identity = identity;
}
// 相当于 LongAdder 中的 add()
public void accumulate(long x) {
Cell[] as;
long b, v, r;
int m;
Cell a;
if ((as = cells) != null ||
(r = function.applyAsLong(b = base, x)) != b && !casBase(b, r)) {
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[getProbe() & m]) == null ||
!(uncontended =
(r = function.applyAsLong(v = a.value, x)) == v ||
a.cas(v, r)))
longAccumulate(x, function, uncontended);
}
}
// 相当于 LongAdder 中的 sum()
public long get() {
Cell[] as = cells;
Cell a;
long result = base;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
result = function.applyAsLong(result, a.value);
}
}
return result;
}
}

从代码实现我们可以发现,LongAccumulator 的代码实现,跟 LongAdder 的代码实现非常相似,主要区别在于,LongAccumulator 支持不同的统计操作

6.2、示例代码

如下示例代码所示,我们通过定义实现了 LongBinaryOperator 接口的类 LongMax,然后通过构造函数传入 LongAccumulator 对象,便可以支持取最大值的操作

@FunctionalInterface
public interface LongBinaryOperator {
long applyAsLong(long left, long right);
}
public class Demo {
public static class LongMax implements LongBinaryOperator {
@Override
public long applyAsLong(long left, long right) {
return Math.max(left, right);
}
}
public static void main(String[] args) {
LongAccumulator lacc = new LongAccumulator(new LongMax(), Long.MIN_VALUE);
lacc.accumulate(10);
lacc.accumulate(-18);
lacc.accumulate(24);
System.out.println(lacc.get()); // 输出 24
}
}

7、课后思考题

如果我们希望 LongAdder 的 sum() 函数能给出准确、一致的累加和,该如何对 LongAdder 的代码进行改造?改造之后的代码对性能又有什么影响?
当线程调用 sum() 时,先通过 CAS 逐一获取每个 Cell 的操作权
等所有 Cell 的操作权都获取之后,再进行求和操作,这样就可以避免在遍历求和的过程中其他线程调用 add() 添加数据
当然这会导致 sum() 性能下降很多,add() 性能也会受到影响

posted @   lidongdongdong~  阅读(90)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开