Administrator
发布于 2020-11-13 / 3316 阅读
43

LongAdder 为什么比 AtomicLong 快

压测数据:同样的计数,快了 6 倍

十一月中旬做网关的埋点统计改造,需要统计每个接口的调用次数和总耗时。我一开始用的 AtomicLong

private final Map<String, AtomicLong> counters = new ConcurrentHashMap<>();

public void record(String api, long costMs) {
    counters.computeIfAbsent(api, k -> new AtomicLong()).incrementAndGet();
}

压测 500 并发(模拟 500 个 tomcat 线程同时打点),QPS 卡在 82 万,CPU 打满。换成 LongAdder 之后:

并发线程AtomicLong QPSLongAdder QPS倍数
14.2 亿1.1 亿0.26x
41.8 亿2.4 亿1.3x
162100 万1.9 亿9.0x
64820 万1.6 亿19.5x
50082 万510 万6.2x

有意思的是第一行:单线程下 LongAdder 反而慢 4 倍。这个反直觉的数据,正好能说明它的实现原理。

AtomicLong 慢在哪:所有线程抢同一个变量

AtomicLong.incrementAndGet() 的核心是一个 CAS 循环:

// java.util.concurrent.atomic.AtomicLong (JDK 8)
public final long incrementAndGet() {
    return unsafe.getAndAddLong(this, valueOffset, 1L) + 1L;
}

// sun.misc.Unsafe
public final long getAndAddLong(Object o, long offset, long delta) {
    long v;
    do {
        v = this.getLongVolatile(o, offset);
    } while (!this.compareAndSwapLong(o, offset, v, v + delta));
    return v;
}

只有一个 value 变量,所有线程都要对它 CAS。并发高的时候,同一时刻只有一个线程能成功,其他全部失败重试。上图里 500 并发时 QPS 只有 82 万,意味着平均每个 increment 要重试 600 多次

这里有个硬件层面的细节:现代 CPU 的 CAS 是锁定缓存行的(缓存一致性协议 MESI)。失败重试不只是"白跑一趟",还会让这个缓存行在多个核之间来回跳转。每次跳转都要走 L3 缓存或者内存,几十到几百个时钟周期。并发越高,争用越激烈,效率越低——这就是"阿姆达尔定律"最直观的体现。

LongAdder 的思路:把热点拆开

核心思想一句话:一个变量抢不过来,就分成多个变量,每个线程更新自己的,最后求和

看 JDK 8 里 LongAdder 的核心结构(继承自 Striped64):

// java.util.concurrent.atomic.Striped64
abstract class Striped64 extends Number {
    /** base 值,没有竞争时直接用它,相当于退化成 AtomicLong */
    transient volatile long base;

    /** 分段数组,有竞争时每个线程打散到不同的 Cell */
    transient volatile Cell[] cells;

    /** cells 正在扩容或初始化时为 1,用 CAS 争用 */
    transient volatile int cellsBusy;

    @sun.misc.Contended
    static final class Cell {
        volatile long value;
        Cell(long x) { value = x; }
        final boolean cas(long cmp, long val) {
            return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val);
        }
    }
}

三个字段各有分工:

  • base:没有竞争时,所有更新都走它,行为跟 AtomicLong 一模一样(一次 CAS)。
  • cells:一旦发生 CAS 竞争,就初始化这个数组,把更新分散到不同的 Cell 上。
  • cellsBusy:一个自旋锁(0 或 1),保护 cells 的初始化和扩容。

add(long x) 的逻辑(简化版):

public void add(long x) {
    Cell[] as; long b, v; int m; Cell a;
    if ((as = cells) != null || !casBase(b = base, b + x)) {
        // 分支一:cells 已初始化,或者对 base 的 CAS 失败了(有竞争)
        boolean uncontended = true;
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[getProbe() & m]) == null ||
            !(uncontended = a.cas(v = a.value, v + x)))
            // 走到这说明:cells 没初始化、或者命中的 Cell 为空、或者 CAS 又失败了
            longAccumulate(x, null, uncontended);
    }
}

读取的时候把所有值加起来:

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

线程怎么找到自己的 Cell

getProbe(),它是每个线程的一个随机数(存在 Thread 对象的 threadLocalRandomProbe 字段里)。用 probe & (cells.length - 1) 定位到数组下标。

// java.util.concurrent.atomic.Striped64
static final int getProbe() {
    return UNSAFE.getInt(Thread.currentThread(), PROBE);
}

如果 CAS 又失败了(说明这个 Cell 也被别的线程占了),longAccumulate 会做两件事:rehash 这个线程的 probe 值(换个 Cell 试试),以及在冲突严重时扩容 cells 数组(长度翻倍,最大到 CPU 核数)。

// Striped64.longAccumulate 里的扩容逻辑(简化)
else if (cellsBusy == 0 && casCellsBusy()) {
    try {
        if (cells == as) {
            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;
}

注意 cells 数组最大长度是 NCPU(CPU 核数),这是有道理的——超过核数之后,再多的 Cell 也没法真正并行,只会增加 sum() 的遍历成本

@Contended 和伪共享

Cell 类的定义,上面有个注解:

@sun.misc.Contended
static final class Cell {
    volatile long value;
    ...
}

这是整个 LongAdder 里最容易被忽略、但很关键的一处设计。

什么是伪共享

CPU 的缓存是以缓存行(cache line)为单位管理的,x86 上通常 64 字节。一个 Cell 对象里只有一个 long value(8 字节),加上对象头 16 字节,总共 24 字节。相邻的多个 Cell 会被塞进同一个缓存行

缓存行(64 字节):
+----------+----------+----------+----------+
| Cell[0]  | Cell[1]  | Cell[2]  | 空闲      |
| 24 bytes | 24 bytes | 16 bytes |          |
+----------+----------+----------+----------+

现在问题来了:线程 A 在核 0 上更新 Cell[0],线程 B 在核 1 上更新 Cell[1]。它俩改的是不同的对象,理论上没有冲突。但因为它们在同一个缓存行里,MESI 协议会认为这个缓存行被修改了,于是让对方核上的缓存行失效,强制重新从内存加载。

结果就是:明明拆成了多个 Cell,却因为缓存行争用,退化成了跟单个变量差不多的性能。这就是伪共享(false sharing)——逻辑上不共享,物理上共享了。

@Contended 怎么解决

@Contended 注解的作用是让 JVM 在这个字段/类前后自动填充空白字节,使它独占一个缓存行。JDK 8 的实现是在对象前后各填充 128 字节(-XX:ContendedPaddingWidth,默认 128,比缓存行 64 大,是考虑到了相邻扇区预取)。

填充后:
+----------+--------+----------+--------+----------+
| 填充128B | Cell[0]| 填充128B | Cell[1]| 填充128B |
+----------+--------+----------+--------+----------+
   ↑ 各占独立缓存行,互不干扰

代价是内存占用暴涨。一个 Cell 从 24 字节变成 152 字节左右。但 cells 数组最多 NCPU 个,8 核机器也就 1216 字节,完全可以接受。

有一点要注意:@Contended 默认只对 JDK 内部类生效。这是因为它位于 sun.misc 包,而且 JVM 有检查。应用代码里想用的话,需要加启动参数:

-XX:-RestrictContended

不过实际项目里别手搓这个,直接用 LongAdder 就好。

验证一下伪共享的影响

我写了个 benchmark 对比,用 JMH 跑:

@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
@Fork(1)
@Threads(8)
public class FalseSharingBench {

    /** 8 个线程各改一个槽,槽之间紧密排列(有伪共享) */
    private final long[] packed = new long[8];

    /** 8 个线程各改一个槽,槽之间相隔 16 个 long(128 字节,无伪共享) */
    private final long[] padded = new long[8 * 16];

    @Benchmark
    public void packedInc() {
        packed[ThreadId.get() % 8]++;
    }

    @Benchmark
    public void paddedInc() {
        padded[(ThreadId.get() % 8) * 16]++;
    }
}
Benchmark                     Mode  Cnt     Score     Error   Units
FalseSharingBench.packedInc  thrpt    5   182.441 ±  12.331  ops/us
FalseSharingBench.paddedInc  thrpt    5   941.228 ±  41.902  ops/us

差 5.2 倍。这就是 @Contended 存在的意义。

什么时候不该用 LongAdder

LongAdder 不是 AtomicLong 的全面替代,它有明确的适用边界。

不适用一:需要精确读取的场景

sum() 方法遍历 cells 数组求和,这个过程中没有加锁,其他线程可能正在修改某个 Cell。所以 sum() 返回的是"某个时刻的近似值",不是原子的精确值。

LongAdder adder = new LongAdder();
// 并发环境下:
adder.increment();     // 线程 A
adder.increment();     // 线程 B
adder.sum();           // 可能返回 0、1 或 2,取决于遍历到每个 Cell 的时机

如果业务要求"读到的值必须是精确的"(比如做库存扣减的判断、做序列号的生成),必须用 AtomicLong 或者加锁。我们的网关埋点统计允许误差,用 LongAdder 完全没问题。

不适用二:单线程或低并发

从上面的表格看,单线程下 LongAdder 比 AtomicLong 慢 4 倍。原因是它要走 add() 的分支判断、读 probe、访问数组,比一次简单的 CAS 复杂得多。并发低于 4 个线程时,AtomicLong 更快

不适用三:需要 CAS 语义

LongAdder 没有 compareAndSet 方法。如果你需要的是"检查当前值,满足条件才更新"这种原子逻辑,只能用 AtomicLong

// LongAdder 做不到这个
AtomicLong max = new AtomicLong();
public void update(long newValue) {
    while (true) {
        long cur = max.get();
        if (newValue <= cur) break;
        if (max.compareAndSet(cur, newValue)) break;
    }
}

这种情况下如果想分段,JDK 8 提供了 LongAccumulator,它可以自定义累加函数,但仍然不支持 CAS。

我们的实际用法

网关的埋点统计最终改成了这样:

public class ApiMetrics {

    private final Map<String, Metrics> counters = new ConcurrentHashMap<>();

    static class Metrics {
        final LongAdder count = new LongAdder();
        final LongAdder totalCost = new LongAdder();
        final LongAdder errorCount = new LongAdder();

        long avgCost() {
            long c = count.sum();
            return c == 0 ? 0 : totalCost.sum() / c;
        }
    }

    public void record(String api, long costMs, boolean success) {
        Metrics m = counters.computeIfAbsent(api, k -> new Metrics());
        m.count.increment();
        m.totalCost.add(costMs);
        if (!success) m.errorCount.increment();
    }

    /** 每 10 秒上报一次,重置用 sumThenReset 而不是 reset */
    @Scheduled(fixedRate = 10000)
    public void report() {
        counters.forEach((api, m) -> {
            long count = m.count.sumThenReset();
            long total = m.totalCost.sumThenReset();
            long error = m.errorCount.sumThenReset();
            if (count > 0) {
                registry.gauge("api.qps", api, count / 10.0);
                registry.gauge("api.avgCost", api, total / (double) count);
                registry.gauge("api.errorRate", api, error / (double) count);
            }
        });
    }
}

这里有个细节:上报用的是 sumThenReset() 而不是先 sum()reset()。后者是两步操作,中间会有新增的计数被清掉。sumThenReset() 内部也是遍历求和并逐个清零,不是原子的,但比两步分开好。

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

改造效果

网关的埋点打点从 AtomicLong 换成 LongAdder 之后:

指标AtomicLongLongAdder
打点 QPS(500 并发)82 万510 万
打点 CPU 占用38%7%
网关整体 QPS1.2 万1.8 万
单次打点耗时 P99820ns96ns

网关整体 QPS 涨了 50%——打点这个动作原本占用了 38% 的 CPU,现在只占 7%,省出来的算力全给了业务请求。

小结

  1. LongAdder 的原理是热点分段:无竞争时写 base(等同 AtomicLong),有竞争时打散到 cells 数组,sum 时求和。cells 数组最大长度是 CPU 核数。
  2. 线程靠 getProbe() 的哈希值定位 Cell,CAS 失败会 rehash 换一个,冲突严重时才扩容。
  3. @Contended 注解通过填充空白字节让 Cell 独占缓存行,避免伪共享。实测伪共享能让性能差 5 倍。JDK 8 里这个注解默认只对内部类生效。
  4. LongAdder 的 sum() 不是原子精确的,单线程下比 AtomicLong 慢,且不支持 CAS。需要精确值或 compareAndSet 的场景还是得用 AtomicLong。
  5. 它适合的场景很明确:高并发的计数统计、累加求和,允许读取时有微小误差。我们的监控打点、限流器计数都用这个。

参考