42、累加器
前面几节中我们讲到:锁、自旋 + 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 将多个累加值相加,便可以得到最终的累加值
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 变量上
从以上代码逻辑,我们可以发现
- 只有当线程执行 cas 成功更新 base,或者执行 cas 成功更新 cells 数组中的累加变量时,add() 函数才会直接返回
- 否则线程会进入 Striped64 类中的 longAccumulate() 函数继续执行
进入 longAccumulate() 函数主要对应三种情况
- cells 为空
- 线程要更新的 cells 中的 Cell 对象为 null
- cas 更新 cells 中的 Cell 对象的 value 值失败
longAccumulate() 函数的代码比较长,涉及比较多的实现细节,这里我们简单介绍一下它的核心逻辑,具体如下图所示
其中主要包含 4 部分逻辑:创建 cells 数组、创建 cells 数组中的 Cell 对象、cells 数组扩容、cas 执行 cells 数组上的累加操作
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 对象就有可能存储在同一个缓存行中,如下图所示
因为数据是以缓存行为单位来读写的
所以当线程 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() 函数
这就会导致出现这样的情况:前面添加的值没有算到累加值中,反倒是后面添加的值算到了累加值中,如下图所示
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() 性能也会受到影响
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17489272.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步