深度解析LongAdder

323 阅读11分钟

1. 初识Longadder

很多兄弟应该都是第一次见到这个组件,LongAdder 是 Java 并包 java.util.concurrent.atomic 下的一个类,主要用于在多线程环境下高效地进行长整型数值的累加操作,其中的架构设计和源码不可谓不精妙,我们先来看下它的官方注解

One or more variables that together maintain an initially zero long sum. When updates (method add) are contended across threads, the set of variables may grow dynamically to reduce contention. Method sum (or, equivalently, longValue) returns the current total combined across the variables maintaining the sum. This class is usually preferable to AtomicLong when multiple threads update a common sum that is used for purposes such as collecting statistics, not for fine-grained synchronization control. Under low update contention, the two classes have similar characteristics. But under high contention, expected throughput of this class is significantly higher, at the expense of higher space consumption. LongAdders can be used with a java.util.concurrent.ConcurrentHashMap to maintain a scalable frequency map (a form of histogram or multiset). For example, to add a count to a ConcurrentHashMap<String,LongAdder> freqs, initializing if not already present, you can use freqs.computeIfAbsent(k -> new LongAdder()).increment(); This class extends Number, but does not define methods such as equals, hashCode and compareTo because instances are expected to be mutated, and so are not useful as collection keys.

看不懂没关系我也看不懂 hhh

简单总结一下

  • LongAdder 由一个或多个变量维护初始为零的长整型总和,多线程更新有竞争时变量集合会动态增长,sum 方法返回总和,在多线程更新统计用公共总和场景通常优于 AtomicLong,高竞争下吞吐量更高但空间消耗大,可与 ConcurrentHashMap 构建频率映射,因实例会被修改未定义 equals 等集合键相关方法。

可以理解成

  • LongAdder 是个初始为零、用于多线程计数的 “智能账本”,有竞争时自动扩容减少冲突,统计总和快,高并发下比 AtomicLong 吞吐量高但更占空间,能与 ConcurrentHashMap 配合统计频次,因常被修改不适合作集合键。

也就是说它比AtomicLong并发下吞吐量更高,但是更占空间

下面深度解析他为何有这种优势,又为何有这种劣势

2. LongAdder的类图

image.png

LongAdder 自身未定义成员变量,其数值的更新与维护实际上由父类 Striped64 负责。

Striped64 借助两个核心成员变量对数值进行管理。一个是 base,另一个是 cells 数组,数组中的元素是 Striped64 内部类 Cell 的实例,Cell 结构简单,仅用于存储一个数值。

在使用 LongAdder 时,若不存在并发访问的情况,会直接采用 CAS(Compare-And-Swap)操作来更新 base 的值。而当面临并发访问时,程序会先定位到 cells 数组中的某个 Cell 元素,然后对该 Cell 元素所记录的 value 进行修改。

最终,LongAdder 所表示的数值是 base 的值与 cells 数组中所有 Cell 元素记录的 value 之和,即 value = base + sum(cells)

3. 核心方法深挖

3.1 add方法解析

直接上源码

public void add(long x) { 
// 定义局部变量 Cell[] as; 
long b, v; int m; Cell a; 
// 条件判断:如果 cells 数组不为空,或者尝试使用 CAS 操作更新 base 值失败 
if ((as = cells) != null || !casBase(b = base, b + x)) { 
// 标记是否没有发生竞争,初始设为 
true boolean uncontended = true; 
// 进一步判断以下几种情况:
// 1. cells 数组为空 
// 2. cells 数组长度为 0 
// 3. 根据线程的哈希值找到的 Cell 对象为空
// 4. 对找到的 Cell 对象进行 CAS 更新操作失败
if (as == null || (m = as.length - 1) < 0 || (a = as[getProbe() & m]) == null || !(uncontended = a.cas(v = a.value, v + x))) 
// 若满足上述任意一种情况,调用 longAccumulate 方法进行处理 
longAccumulate(x, null, uncontended); } }
  1. 尝试直接更新 base 值

    • 首先检查 cells 数组是否为空,如果不为空,则说明已经存在并发竞争,需要进一步处理。
    • 如果 cells 数组为空,尝试使用 CAS(Compare-And-Swap)操作将 base 值加上 x。如果 CAS 操作成功,说明没有发生并发竞争,更新完成,方法结束。
  2. 处理并发竞争情况

    • 如果 cells 数组不为空,或者 CAS 更新 base 值失败,说明存在并发竞争,需要进一步处理。
    • 尝试根据线程的哈希值找到 cells 数组中的一个 Cell 对象,并对其进行 CAS 更新操作。如果更新成功,说明该线程可以成功更新对应的 Cell 值,更新完成,方法结束。
  3. 调用 longAccumulate 方法

    • 如果 cells 数组为空、cells 数组长度为 0、根据线程的哈希值找到的 Cell 对象为空,或者对找到的 Cell 对象进行 CAS 更新操作失败,说明当前的并发情况比较复杂,需要调用 longAccumulate 方法进行更复杂的处理,包括初始化 cells 数组、扩容 cells 数组等操作。

3.2 longAccumulate方法解析


final void longAccumulate(long x, LongBinaryOperator fn,
boolean wasUncontended) {

        int h;
// 如果线程的探针哈希值为 0,进行初始化
        if ((h = getProbe()) == 0) {
// 强制初始化 ThreadLocalRandom,获取新的探针哈希值
            ThreadLocalRandom.current();
            h = getProbe();
// 标记为未发生竞争
            wasUncontended = true;
        }
// 标记是否发生碰撞,初始为 false
        boolean collide = false;
// 无限循环,直到操作成功
        for (;;) {
            Cell[] as; Cell a; int n; long v;
// 情况 1:cells 数组已经初始化
            if ((as = cells) != null && (n = as.length) > 0) {
// 1.1 如果根据线程哈希值找到的 Cell 为空
                if ((a = as[(n - 1) & h]) == null) {
                    if (cellsBusy == 0) {
                       // 乐观地创建一个新的 Cell
                        Cell r = new Cell(x);
                           // 尝试获取锁
                        if (cellsBusy == 0 && casCellsBusy()) {
                            boolean created = false;
                            try {
                                Cell[] rs; int m, j;
             // 双重检查,确保在加锁期间 cells 数组未变化且目标位置为空
                                if ((rs = cells) != null &&
                                        (m = rs.length) > 0 &&
                                        rs[j = (m - 1) & h] == null) {
                                    rs[j] = r;
                                    created = true;
                                }
                            } finally {
                              // 释放锁
                                cellsBusy = 0;
                            }
                            if (created)
                            // 创建成功,退出循环
                                break;
                           // 目标位置已被其他线程占用,继续循环
                            continue;
                        }
                    }
                         // 标记未发生碰撞
                    collide = false;
                }
                       // 1.2 如果之前的操作已经知道 CAS 会失败
                else if (!wasUncontended)
                     // 重新哈希后继续尝试
                    wasUncontended = true;
                    // 1.3 尝试使用 CAS 更新 Cell 的值
                else if (a.cas(v = a.value, ((fn == null) ? v + x :
                        fn.applyAsLong(v, x))))
                 // 更新成功,退出循环
                    break;
           // 1.4 如果 cells 数组已经达到最大容量(CPU 核心数),或者 cells 数组已经被其他线程修改
                else if (n >= NCPU || cells != as)
                  // 标记未发生碰撞
                    collide = false;
                // 1.5 如果之前没有发生碰撞
                else if (!collide)
                 // 标记发生碰撞
                    collide = true;
              // 1.6 尝试获取锁并进行扩容
                else if (cellsBusy == 0 && casCellsBusy()) {
                    try {
             // 双重检查,确保在加锁期间 cells 数组未变化
                        if (cells == as) {
            // 扩容为原来的 2 倍
                            Cell[] rs = new Cell[n << 1];
                            for (int i = 0; i < n; ++i)
                                rs[i] = as[i];
                            cells = rs;
                        }
                    } finally {
          // 释放锁
                        cellsBusy = 0;
                    }
// 标记未发生碰撞
                    collide = false;
// 扩容后重新尝试
                    continue;
                }
// 重新计算线程的探针哈希值
                h = advanceProbe(h);
            }
// 情况 2:cells 数组未初始化,尝试初始化
            else if (cellsBusy == 0 && cells == as && casCellsBusy()) {
                boolean init = false;
                try {
                    if (cells == as) {
               // 初始化 cells 数组,长度为 2
                        Cell[] rs = new Cell[2];
                        rs[h & 1] = new Cell(x);
                        cells = rs;
                        init = true;
                    }
                } finally {
                // 释放锁
                    cellsBusy = 0;
                }
                if (init)
             // 初始化成功,退出循环
                    break;
            }
// 情况 3:如果初始化失败,尝试直接更新 base 值
            else if (casBase(v = base, ((fn == null) ? v + x :
                    fn.applyAsLong(v, x))))
// 更新成功,退出循环
                break;
        }
    }


真源码可真够长的,但是原理不难分析我们画个图先来理解一下

image.png

这是Longadder的逻辑图,在线程竞争的时候我们对cell数组进行修改变更,而longAccumulate中的主要流程是这样的:

  1. 初始化线程探针哈希值:如果线程的探针哈希值为 0,初始化 ThreadLocalRandom 并重新获取哈希值。

  2. 进入无限循环处理不同情况

    • cells 数组已初始化

      • 若根据线程哈希值找到的 Cell 为空,尝试创建新的 Cell 并放入该位置。
      • 若之前操作已知 CAS 失败,重新哈希后继续尝试。
      • 尝试使用 CAS 更新 Cell 的值,更新成功则退出循环。
      • 若 cells 数组达到最大容量或已被修改,标记未发生碰撞。
      • 若之前未发生碰撞,标记发生碰撞。
      • 若发生碰撞且未加锁,尝试获取锁并将 cells 数组扩容为原来的 2 倍。
      • 若以上都不满足,重新计算线程的探针哈希值。
    • cells 数组未初始化:尝试获取锁并初始化 cells 数组,长度为 2。

    • 初始化失败:尝试直接使用 CAS 更新 base 值,更新成功则退出循环。

3.3 并发场景下比AtomicLong的优势

以AtomicLong的getAndAdd方法做一下对比。下面是这个方法的源码:

/**
 * Atomically adds the given value to the current value.
 *
 * @param delta the value to add
 * @return the previous value
 */
public final long getAndAdd(long delta) {
    return unsafe.getAndAddLong(this, valueOffset, delta);
}

public final long getAndAddLong(Object var1, long var2, long var4) {
    long var6;
    do {
        var6 = this.getLongVolatile(var1, var2);
    } while(!this.compareAndSwapLong(var1, var2, var6, var6 + var4));

    return var6;
}

AtomicLong的实现是通过无限循环cas的方式更新当前维护的值,可以想象,在并发足够大的情况下,cas的失败率会很高,这里循环次数剧增,造成CPU使用率飙高。

通过上文分析LongAdder.add(int x)的原理,LongAdder先尝试一次cas更新,如果失败会转而通过Cell[]的方式更新值,如果计算index的方式足够散列,那么在并发量大的情况下,多个线程定位到同一个cell的概率也就越低,这有点类似于分段锁的意思。

由此也可以分析出另一点,在并发量不大的情况下,二者的性能是没有多大差异的。

3.4 性能实验到底谁性能更好

基于Countdownlatch我写了一个性能测试方法,对比QPS为5 和 100 下这两者性能差距有多大

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;

public class LongAdderVsAtomicLong {
    private static final int INCREMENT_TIMES = 1000000;

    public static void main(String[] args) throws InterruptedException {
        // 并发量为 10 的测试
        test(5);
        // 并发量大于 1000 的测试
        test(100);
    }

    private static void test(int threadCount) throws InterruptedException {
        System.out.println("并发量: " + threadCount);

        // 测试 AtomicLong
        long atomicLongStartTime = System.currentTimeMillis();
        testAtomicLong(threadCount);
        long atomicLongEndTime = System.currentTimeMillis();
        System.out.println("AtomicLong 累加耗时: " + (atomicLongEndTime - atomicLongStartTime) + " 毫秒");

        // 测试 LongAdder
        long longAdderStartTime = System.currentTimeMillis();
        testLongAdder(threadCount);
        long longAdderEndTime = System.currentTimeMillis();
        System.out.println("LongAdder 累加耗时: " + (longAdderEndTime - longAdderStartTime) + " 毫秒");

        System.out.println();
    }

    private static void testAtomicLong(int threadCount) throws InterruptedException {
        AtomicLong atomicLong = new AtomicLong(0);
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                for (int j = 0; j < INCREMENT_TIMES; j++) {
                    atomicLong.incrementAndGet();
                }
                latch.countDown();
            });
        }

        latch.await();
        executorService.shutdown();
    }

    private static void testLongAdder(int threadCount) throws InterruptedException {
        LongAdder longAdder = new LongAdder();
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                for (int j = 0; j < INCREMENT_TIMES; j++) {
                    longAdder.increment();
                }
                latch.countDown();
            });
        }

        latch.await();
        executorService.shutdown();
    }
}

image.png

实验结果也不意外,在QPS为5的时候性能差距只为0.5s,而QPS为100的时候性能差距能来到惊人的2s,看来在高并发场景下AtomicLong确实有非常惊人的优势呀

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

相比之前的长篇大论,sum方法显得柔和很多哈,其实就是 base + sum(cells)

由此引申我们想到另一个问题

为啥在并发情况下sum的值不精确?

由上述源码可以发现,sum执行时,并没有限制对base和cells的更新。也就是说,对LongAdder的最后一次更新not happens-before 最近的一次读取。

首先,最终返回的sum局部变量,初始被复制为base,而最终返回时,很可能base已经被更新了,而此时局部变量sum不会更新,造成不一致。

其次,这里对cell的读取也无法保证是最后一次写入的值。

所以,sum方法在没有并发的情况下,可以获得正确的结果。

4. 总结

在高并发环境中,LongAdder 在数据累增与求和操作上展现出无可比拟的性能优势。不过,鱼与熊掌不可兼得,它牺牲了一定的数据一致性,仅能保证最终结果的正确性。因此,当业务场景对数据实时准确性要求不高,且并发量较大时,LongAdder 是理想之选;反之,若需保证数据在任何时刻都精准无误,AtomicLong 则更为合适。由此可见,技术选型需紧密贴合具体业务场景。

这一抉择与分布式系统中最终一致性和强一致性的权衡颇为相似。在分布式系统里,多数场景借助 MQ 等中间件实现最终一致性便已足够,无需像关系型数据库那样追求强一致性。毕竟,在很多情况下,业务更看重系统的高并发处理能力和整体性能,而非时刻保持数据的绝对一致。