压测数据:同样的计数,快了 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 QPS | LongAdder QPS | 倍数 |
|---|---|---|---|
| 1 | 4.2 亿 | 1.1 亿 | 0.26x |
| 4 | 1.8 亿 | 2.4 亿 | 1.3x |
| 16 | 2100 万 | 1.9 亿 | 9.0x |
| 64 | 820 万 | 1.6 亿 | 19.5x |
| 500 | 82 万 | 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 之后:
| 指标 | AtomicLong | LongAdder |
|---|---|---|
| 打点 QPS(500 并发) | 82 万 | 510 万 |
| 打点 CPU 占用 | 38% | 7% |
| 网关整体 QPS | 1.2 万 | 1.8 万 |
| 单次打点耗时 P99 | 820ns | 96ns |
网关整体 QPS 涨了 50%——打点这个动作原本占用了 38% 的 CPU,现在只占 7%,省出来的算力全给了业务请求。
小结
- LongAdder 的原理是热点分段:无竞争时写
base(等同 AtomicLong),有竞争时打散到cells数组,sum 时求和。cells 数组最大长度是 CPU 核数。 - 线程靠
getProbe()的哈希值定位 Cell,CAS 失败会 rehash 换一个,冲突严重时才扩容。 @Contended注解通过填充空白字节让 Cell 独占缓存行,避免伪共享。实测伪共享能让性能差 5 倍。JDK 8 里这个注解默认只对内部类生效。- LongAdder 的
sum()不是原子精确的,单线程下比 AtomicLong 慢,且不支持 CAS。需要精确值或 compareAndSet 的场景还是得用 AtomicLong。 - 它适合的场景很明确:高并发的计数统计、累加求和,允许读取时有微小误差。我们的监控打点、限流器计数都用这个。