1. 引言
在 Java 应用中,常要处理高并发下的计数操作,比如统计请求量、日志统计、监控指标等。传统的 AtomicLong 通过 CAS 方式确保并发安全,但在高并发场景下性能急剧下降。
Java 8 引入了 LongAdder,通过热点分散和分段结构维持性能稳定,是实现高性能计数类的重要创新。本文将深入解析该类的设计和实现,并提供实际应用指引。
2. LongAdder 的设计初衷与核心概念
2.1 设计初衷
AtomicLong 采用了 CAS (Compare-And-Swap) 操作来确保并发安全,但所有线程都对同一个 value 进行操作,导致性能瓶颈。
LongAdder 的设计初衷是"换空间换时间":
-
将一个值分散到多个 cell 中存储,避免多个线程争夺同一个内存地址
-
操作时随机选择 cell 进行 CAS 操作
-
统计时将全部值相加
2.2 核心概念
-
Base: 初始值的基础系数
-
Cells: 分散数组,作为热点分散的存储单元
-
Cell: 内部静态类,包装 long 值,并通过 Unsafe 提供 CAS 功能
-
Hashing: 通过 ThreadLocalRandom 维护分散性
3. 基本使用示例
import java.util.concurrent.atomic.LongAdder;
public class CounterDemo {
private static final LongAdder counter = new LongAdder();
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
counter.increment();
}
};
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(task);
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("Final count: " + counter.sum());
}
}
运行结果将显示正确的计数结果:Final count: 1000000
4. 源码深度解析
为了深入理解 LongAdder
如何在高并发场景下实现优越性能,本章将从类结构、核心字段、关键方法入手,逐步剖析其源码实现。我们不仅展示源码片段,还对每一段逻辑进行详细解释,力求做到真正的源码级掌握。
4.1 类结构概览
LongAdder
继承自抽象类 Striped64
,其本质是通过分段(striped)的方式,减轻多个线程对同一个变量的竞争压力。
public class LongAdder extends Striped64 {
public void add(long x) { ... }
public void increment() { add(1L); }
public void decrement() { add(-1L); }
public long sum() { ... }
public void reset() { ... }
public long sumThenReset() { ... }
}
我们可以看到 LongAdder
提供了多个方法,便于执行递增、递减、求和和重置操作。
其父类 Striped64
提供了两个关键字段:
-
volatile long base
: 基础计数值。 -
volatile Cell[] cells
: 分段数组,数组中的每一个Cell
用来分担高并发下的更新压力。
4.2 分段机制与核心思想
核心思想是将原本集中在一个变量(如 AtomicLong.value
)上的竞争,分散到多个 Cell
上。
-
初始情况下,线程尝试更新
base
字段。 -
如果更新
base
失败(意味着竞争严重),则初始化cells
数组。 -
每个线程根据其唯一的哈希值(probe)选择一个
Cell
更新。 -
多线程更新分布在不同的
Cell
上,显著降低了 CAS 冲突率。
4.3 构造器行为
public LongAdder() {}
构造方法非常简洁,不初始化任何内部结构。原因在于:
-
base
默认就是 0,无需初始化。 -
cells
数组采用懒加载策略,仅在并发冲突时才创建。
这意味着 LongAdder 的资源开销是动态触发的,对单线程使用非常友好。
4.4 add() 方法详解
核心的 add(long x)
方法是实现计数操作的关键,它的源码如下:
public void add(long x) {
Cell[] cs; long b, v; int m; Cell c;
if ((cs = cells) != null || !casBase(b = base, b + x)) {
boolean uncontended = true;
if (cs == null || (m = cs.length - 1) < 0 || (c = cs[getProbe() & m]) == null ||
!(uncontended = c.cas(v = c.value, v + x)))
longAccumulate(x, null, uncontended);
}
}
我们逐行解析:
-
Cell[] cs; long b, v; int m; Cell c;
- 定义变量,用于存储当前
cells
数组、base 值、Cell 等中间结果。
- 定义变量,用于存储当前
-
if ((cs = cells) != null || !casBase(b = base, b + x))
- 如果
cells
数组已经存在(说明并发量大),或者base
CAS 更新失败,则进入分段处理逻辑。
- 如果
-
boolean uncontended = true;
- 标记当前线程是否成功地独占了对应的
Cell
。
- 标记当前线程是否成功地独占了对应的
-
if (cs == null || ... || !(uncontended = c.cas(...)))
-
检查
cells
是否为空,数组大小是否有效,对应的Cell
是否存在,CAS 是否成功。 -
若任一条件失败,调用
longAccumulate()
方法处理并发冲突和扩容。
-
4.5 CAS 与 Unsafe 实现
LongAdder
的原子操作并未使用 java.util.concurrent.atomic
提供的封装类,而是直接调用底层的 sun.misc.Unsafe
实现 CAS。
casBase 方法
protected final boolean casBase(long cmp, long val) {
return UNSAFE.compareAndSwapLong(this, BASE, cmp, val);
}
-
比较当前对象的
base
字段是否等于cmp
,如果是则设为val
。 -
BASE
是base
字段在内存中的偏移量,通过静态代码块初始化。
Cell.cas 方法
static final class Cell {
volatile long value;
Cell(long x) { value = x; }
final boolean cas(long cmp, long val) {
return UNSAFE.compareAndSwapLong(this, VALUE, cmp, val);
}
}
-
每个
Cell
实际上是一个封装了long
值的原子变量。 -
也使用
Unsafe
进行 CAS 更新。
4.6 哈希分散策略(getProbe 和 advanceProbe)
getProbe()
获取当前线程的"探针值",用于定位到 cells
中的槽位:
static final int getProbe() {
return UNSAFE.getInt(Thread.currentThread(), PROBE);
}
-
PROBE
是线程本地哈希值的偏移量。 -
如果发生哈希冲突,会调用
advanceProbe()
生成新的哈希值。
该策略确保不同线程尽可能命中不同的 Cell
,减少热点重叠。
4.7 longAccumulate() 的核心职责
当 add()
中的尝试全部失败,进入 longAccumulate()
方法:
final void longAccumulate(long x, LongBinaryOperator fn, boolean wasUncontended) {
// 实现极为复杂,包含:
// 1. 初始化 cells 数组
// 2. 尝试占用 cell
// 3. CAS 失败时判断是否需要扩容
// 4. 如果扩容失败,则重试或反复调整 probe
// 最终确保某个 cell 或 base 成功被更新
}
这是整个 LongAdder
并发控制的核心实现,具备如下能力:
-
分段数组的懒初始化
-
冲突检测与槽位迁移
-
分段数组的动态扩容(每次翻倍)
-
不断重试直到成功更新某个槽位
5. 与 AtomicLong / LongAccumulator 的对比分析
在本章中,我们将深入比较 LongAdder
、AtomicLong
和 LongAccumulator
三个类,从它们的设计理念、实现结构、典型用法和性能表现等多个维度进行全面剖析,帮助开发者选择最适合自己业务场景的并发计数工具。
5.1 设计目的与适用场景
类名 | 设计初衷 | 适用场景 |
---|---|---|
AtomicLong | 通用型原子计数器,线程安全但容易产生争抢 | 并发量较低或写操作不频繁 |
LongAdder | 针对高并发优化,弱一致性计数器 | 高并发下计数统计,指标收集等 |
LongAccumulator | 可指定累加规则的扩展版本,功能更灵活 | 复杂累加逻辑(如求最值) |
5.2 类结构与接口对比
|----------------|------------|-----------|-----------------|
| 方法名 | AtomicLong | LongAdder | LongAccumulator |
| get() / sum() | ✔ | ✔ | ✔ |
| increment() | ✔ | ✔ | ✖ |
| add(x) | ✔ | ✔ | ✔ |
| reset() | ✖ | ✔ | ✔ |
| sumThenReset() | ✖ | ✔ | ✔ |
| 自定义运算逻辑 | ✖ | ✖ | ✔ |
可见,AtomicLong
接口最简单,而 LongAdder
更适合计数类需求,LongAccumulator
则支持通用型聚合操作。
5.3 使用示例对比
AtomicLong 示例
AtomicLong atomicLong = new AtomicLong();
atomicLong.incrementAndGet();
System.out.println(atomicLong.get());
LongAdder 示例
LongAdder adder = new LongAdder();
adder.increment();
System.out.println(adder.sum());
LongAccumulator 示例
LongAccumulator max = new LongAccumulator(Long::max, Long.MIN_VALUE);
max.accumulate(10);
max.accumulate(20);
System.out.println(max.get()); // 输出 20
5.4 性能实测对比
我们设计如下性能对比测试,统计多线程下 1 亿次递增操作的耗时:
public class CounterBenchmark {
static final int THREADS = 20;
static final int TASKS_PER_THREAD = 5_000_000;
public static void main(String[] args) throws InterruptedException {
benchmark("AtomicLong", () -> {
AtomicLong counter = new AtomicLong();
runThreads(() -> {
for (int i = 0; i < TASKS_PER_THREAD; i++) {
counter.incrementAndGet();
}
});
});
benchmark("LongAdder", () -> {
LongAdder counter = new LongAdder();
runThreads(() -> {
for (int i = 0; i < TASKS_PER_THREAD; i++) {
counter.increment();
}
});
});
}
static void runThreads(Runnable task) throws InterruptedException {
Thread[] threads = new Thread[THREADS];
for (int i = 0; i < THREADS; i++) {
threads[i] = new Thread(task);
threads[i].start();
}
for (Thread thread : threads) thread.join();
}
static void benchmark(String label, Runnable task) throws InterruptedException {
long start = System.currentTimeMillis();
task.run();
long duration = System.currentTimeMillis() - start;
System.out.println(label + " time: " + duration + " ms");
}
}
运行结果(大致):
AtomicLong time: 2200 ms
LongAdder time: 480 ms
说明 LongAdder
在高并发写操作下的性能显著优于 AtomicLong
。
5.5 一致性语义差异
-
AtomicLong.get()
始终返回最新的值,具备强一致性。 -
LongAdder.sum()
可能存在轻微误差(最终一致性),因为它需要将所有Cell
的值汇总。 -
LongAccumulator.get()
同样基于分段聚合,非瞬时一致。
5.6 内存占用差异
|-----------------|--------|------------|
| 类名 | 是否分段数组 | 内存占用趋势 |
| AtomicLong | ✖ | 常量级 |
| LongAdder | ✔ | 线程数越多,内存越大 |
| LongAccumulator | ✔ | 同上 |
这意味着 LongAdder
和 LongAccumulator
是以"换空间为时间"的策略。
5.7 线程安全与并发吞吐对比
|-------|------------|-----------|-----------------|
| 指标 | AtomicLong | LongAdder | LongAccumulator |
| 并发写性能 | ❌ | ✅ | ✅ |
| 一致性强度 | ✅ | ❌ | ❌ |
| 可扩展性 | ❌ | ✅ | ✅ |
| 内存可控性 | ✅ | ❌ | ❌ |
小结
-
使用
AtomicLong
的情况:更新频率低、对一致性要求极高。 -
使用
LongAdder
的情况:统计类操作、高并发、对实时性要求不高。 -
使用
LongAccumulator
的情况:聚合非加法逻辑(如最大值、乘积等)。
6. 常见问题与解决方案
虽然 LongAdder
在高并发场景下表现出色,但在实际开发中仍然可能遇到一些令人困惑的问题或陷阱。本章将列举和解析若干典型问题,并提供解决建议,帮助开发者更稳健地使用该类。
6.1 sum() 返回不准确?
问题描述
不少开发者在线上使用 LongAdder
后发现,sum()
返回的结果和实际期望值有出入,尤其是在频繁并发更新时。
原因分析
这是由于 LongAdder.sum()
方法会将 base
和 cells[]
中所有槽位的值进行累加,但由于没有加锁,可能在合并过程中有线程正在更新某个 Cell
,导致结果略有误差。
示例重现
LongAdder adder = new LongAdder();
for (int i = 0; i < 1000; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
adder.increment();
}
}).start();
}
Thread.sleep(1000);
System.out.println("Sum: " + adder.sum()); // 不一定是 1000000
解决建议
-
如果对准确性要求极高,应在统计窗口内使用同步方法控制更新节奏。
-
或使用
sumThenReset()
并搭配锁机制使用,避免读写冲突。
6.2 重置失败或出现负数?
问题描述
在调用 reset()
后再次使用 LongAdder
,发现计数值出现负数或重置失败。
原因分析
reset()
和并发写操作之间没有强同步,可能在 reset 过程中某些线程仍在写入 base
或 Cell
,导致重置无效。
示例
LongAdder adder = new LongAdder();
ExecutorService pool = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
pool.execute(() -> {
adder.increment();
adder.reset();
});
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.SECONDS);
System.out.println(adder.sum()); // 有可能 < 0
解决建议
-
reset()
仅适用于无并发写入时。 -
多线程场景推荐使用
sumThenReset()
并在外部加锁以保证一致性。
6.3 内存占用增长?
问题描述
在某些长期运行的应用中发现 LongAdder
占用内存不断增长,甚至触发 GC 问题。
原因分析
cells[]
容器是懒初始化且动态扩容的,每当线程竞争激烈且 CAS 失败频繁,就可能触发扩容,最终形成大量 Cell
槽位。
扩容后不会自动收缩,导致空间一直占用。
解决建议
-
若并发度固定,建议限制线程数量。
-
不复用的
LongAdder
可通过sumThenReset()
定期清理状态。 -
定期重建对象释放内存也是一种折中方案。
6.4 不适合强一致性场景
问题描述
某些系统中使用 LongAdder
统计计数值后直接用于业务判断,如用户限流、访问控制等,结果出现不一致或误判。
原因分析
LongAdder.sum()
结果存在弱一致性风险,不适合作为高安全等级的判断依据。
解决建议
-
对强一致性场景应改用
AtomicLong
或基于锁的计数器。 -
LongAdder
更适合作为指标采样或趋势分析的工具。
6.5 无法序列化?
问题描述
尝试将 LongAdder
序列化用于持久化或远程传输时报错:NotSerializableException
。
原因分析
LongAdder
和其父类 Striped64
并未实现 Serializable
接口,内部的 Cell
也不可序列化。
解决建议
-
若需持久化,仅序列化其
sum()
值。 -
或将统计逻辑与存储逻辑解耦,只记录结果。
小结
问题类别 | 根因 | 建议做法 |
---|---|---|
sum() 不准确 | 弱一致性合并 | 结合锁或 sumThenReset 控制周期 |
reset 异常 | 缺乏并发同步 | 尽量只在无写入时调用 reset |
内存增长 | cells 懒加载 + 扩容无回收 | 控制并发、定期清空或重建实例 |
强一致性误用 | sum 非强一致 | 改用 AtomicLong 或同步方案 |
无法序列化 | 未实现 Serializable 接口 | 仅存 sum 结果或自定义序列化逻辑 |
7. 性能调优建议
尽管 LongAdder
在默认配置下已具备极强的并发性能,但在真实项目中,合理的参数配置和场景适配仍能进一步提升其表现,尤其是在高吞吐、低延迟和资源敏感型系统中。本章将围绕以下几个方面展开探讨:
-
线程数量与
cells[]
容量关系 -
JVM 参数配置影响
-
热点线程绑定与 NUMA 架构优化
-
与线程池搭配的优化技巧
-
对比实测及调优示例
7.1 cells[] 容量与并发度匹配
LongAdder
的核心优化机制之一在于通过 cells[]
数组分散写热点,每个线程在写入时尽量避免与其他线程竞争同一个槽位。但这个数组容量是懒初始化且扩容触发的,因此并发线程数高于 cells.length
时才会触发新的扩容。
建议策略
-
若并发线程数已知或固定(如线程池),建议提前并发"预热",让
cells[]
尽早扩容至合理容量,减少运行时竞争。for (int i = 0; i < poolSize; i++) {
new Thread(() -> adder.increment()).start();
} -
在服务初始化阶段执行一次并发写操作预热。
7.2 JVM 参数调优建议
某些 JVM 参数会对并发类库中的锁竞争、内存布局、线程调度产生间接影响,以下是部分建议配置:
参数 | 含义 | 推荐配置(仅供参考) |
---|---|---|
-XX:+UseNUMA |
启用 NUMA 感知 | 开启(对多 CPU 核心有益) |
-XX:+UseBiasedLocking |
使用偏向锁,减少无竞争场景的开销 | Java 8 默认开启 |
-XX:+AlwaysPreTouch |
启动时预触所有页,优化内存访问 | 对低延迟系统建议开启 |
-XX:ParallelGCThreads |
GC 并行线程数 | 设置为 CPU 核心数 |
7.3 与线程池搭配的性能建议
使用 LongAdder
时,如果结合线程池执行统计类任务,应注意以下几点:
-
避免线程池大小远大于 CPU 核数,否则线程频繁切换反而导致竞争加剧。
-
若任务短小频繁,可考虑
ForkJoinPool
的commonPool
,其内部也使用ThreadLocal
进行线程槽绑定优化。
示例
ExecutorService pool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
LongAdder adder = new LongAdder();
for (int i = 0; i < 1000; i++) {
pool.submit(() -> adder.increment());
}
7.4 避免不必要的 sum 操作
虽然 sum()
是无锁操作,但它遍历所有 Cell
进行汇总,如果频繁调用,会导致缓存失效和 CPU cache miss。
优化建议
-
设置统计周期(如 1 秒)进行采样式汇总。
-
配合定时任务批量读取。
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(() -> {
System.out.println("count: " + adder.sumThenReset());
}, 0, 1, TimeUnit.SECONDS);
7.5 多核绑定与 NUMA 结构优化
在多 CPU NUMA 系统中,不合理的线程调度可能导致远程内存访问,性能降低。
优化措施
-
使用操作系统层面的 CPU 亲和性绑定(taskset / numactl)将热点线程固定至同一 NUMA 节点。
-
使用线程池绑定核心的方式进行调度(如自定义
ThreadFactory
)。
7.6 性能实测与对比
我们做一个带与不带优化的对比,场景为:20 个线程递增 5000 万次。
未优化
LongAdder time: 680ms
预热 + 限定线程池 + sum 周期采样
LongAdder time: 460ms
说明合理的参数设置和线程管理策略可以进一步提升 LongAdder 在高并发下的表现。
小结
|-----------|-----------------------|
| 优化点 | 建议手段 |
| 初始化扩容 | 并发预热 |
| JVM 参数调整 | 开启 NUMA、合理线程绑定 |
| 线程池设置 | 限制线程数与核心数相当,避免上下文切换过多 |
| 汇总频率控制 | 使用定时采样方式避免频繁 sum 操作 |
| NUMA 架构优化 | 固定线程亲和性、避免远程访问 |