Java DoubleAdder 为什么在高速并发下比 AtomicLong 吞吐量更高
在处理高并发计数场景时,选择正确的工具至关重要。AtomicLong 是 JDK 1.5 引入的经典并发计数器,而 DoubleAdder(及其兄弟 LongAdder)在 JDK 1.8 中被引入,旨在解决前者在高争用下的性能瓶颈。本文将直接剖析其设计原理,并给出明确的实操指导。
第一步:理解核心问题——单点锁与分段锁
首先,你需要明白两者性能差异的根本原因在于其内部数据结构的设计哲学。
-
描述
AtomicLong的工作机制。它内部维护一个volatile long value。当多个线程同时尝试更新时,它们都围绕这同一个内存地址(变量)进行操作。更新过程通常是一个 CAS(Compare-And-Swap) 循环:- 线程A读取当前值
current。 - 计算新值
current + delta。 - 尝试将
value从current原子地 更新为new。如果更新失败(说明其他线程抢先修改了),则重试整个过程。 - 关键点:所有线程都在竞争同一个“锁”(即
value变量),在高并发下会产生大量CAS冲突和自旋等待,导致吞吐量下降。
- 线程A读取当前值
-
描述
DoubleAdder的工作机制。它采用了一种名为“分段”(Striped)的策略来减少竞争。- 它内部维护一个
volatile long base值和一个Cell[]数组(每个Cell内部也是一个volatile long value)。 - 当线程尝试调用
add(double x)方法时,它会尝试将x加到base上(通过CAS)。 - 如果这次CAS成功,则直接返回,过程快速。
- 如果CAS失败(说明
base端有竞争),该线程会计算一个哈希值,用它来定位到Cell[]数组中的一个具体Cell,然后将x加到这个Cell的value上(同样使用CAS,但此时竞争范围缩小到了单个Cell)。 - 关键点:线程间的冲突被分散到了
base和多个Cell上。高并发时,线程更可能分配到不同的Cell,从而并行地完成更新,极大减少了整体竞争。最终的总和需要通过sum()方法汇总(base+ 所有Cell.value),这是一个“最终一致性”的操作。
- 它内部维护一个
第二步:通过代码直观感受差异
理论需要实践验证。下面我们分别编写使用 AtomicLong 和 LongAdder(与 DoubleAdder 原理相同,用于计数更直观)的示例。
- 编写基础计数任务。
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
public class CounterBenchmark {
// 模拟共享资源:两个计数器
private static final AtomicLong atomicCounter = new AtomicLong(0);
private static final LongAdder longAdder = new LongAdder();
// 一个需要高并发执行的计数任务
public static void incrementAtomic() {
atomicCounter.incrementAndGet();
}
public static void incrementAdder() {
longAdder.increment();
}
}
- 创建高并发测试场景。我们创建一个测试类,模拟大量线程同时执行计数操作。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class Main {
public static void main(String[] args) throws InterruptedException {
final int threadCount = 64; // 并发线程数
final int incrementsPerThread = 100_000; // 每个线程递增次数
// 测试 AtomicLong
testCounter(threadCount, incrementsPerThread, "AtomicLong", CounterBenchmark::incrementAtomic);
System.out.println("AtomicLong final value: " + CounterBenchmark.atomicCounter.get());
// 测试 LongAdder
testCounter(threadCount, incrementsPerThread, "LongAdder", CounterBenchmark::incrementAdder);
System.out.println("LongAdder final value: " + CounterBenchmark.longAdder.sum());
}
private static void testCounter(int threads, int iterations, String name, Runnable task) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(threads);
long startTime = System.currentTimeMillis();
for (int i = 0; i < threads; i++) {
executor.submit(() -> {
for (int j = 0; j < iterations; j++) {
task.run();
}
});
}
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
long endTime = System.currentTimeMillis();
System.out.printf("%s: %d operations in %d ms%n", name, (long) threads * iterations, (endTime - startTime));
}
}
- 运行并观察结果。在大多数机器上,当线程数 (
threadCount) 较高时,你会观察到LongAdder的执行时间显著低于AtomicLong,但最终的sum()值与AtomicLong的get()值相同。这直接证明了在高争用下,DoubleAdder/LongAdder提供了更高的吞吐量。
第三步:明确使用场景与选择策略
理解了原理和差异后,你需要知道在何种情况下选择哪种工具。
-
优先选择
AtomicLong的场景:- 需要严格的、实时的全局一致读。例如,当你需要随时通过
get()方法获取一个精确的、线程安全的计数值时,AtomicLong的get()性能极好,只是简单地读取一个volatile变量。 - 并发线程数很少。在低争用环境下,
DoubleAdder维护Cell数组的额外开销可能使其性能不如简单的AtomicLong。 - 内存空间极度敏感。
DoubleAdder会为每个线程(或哈希桶)分配一个Cell对象,占用更多内存。
- 需要严格的、实时的全局一致读。例如,当你需要随时通过
-
优先选择
DoubleAdder或LongAdder的场景:- 高并发写入为主的计数或统计场景。这是
DoubleAdder的核心设计目标。例如,用于统计网站的页面点击量、API 调用次数、批量任务的处理数量等。调用add()方法的频率远高于 读取sum()的频率。 - 可以容忍最终一致性读取。在计数过程中,
sum()返回的值可能是一个近似值(因为汇总时各Cell的值可能还在变化),但对于统计监控类应用,这个精度通常足够。 - 追求更高的写入吞吐量。当系统面临每秒数十万甚至上百万的计数更新时,
DoubleAdder是更优的选择。
- 高并发写入为主的计数或统计场景。这是
第四步:掌握关键方法与注意事项
在使用 DoubleAdder 时,牢记以下几点以确保正确性。
-
正确初始化。直接 创建 一个
DoubleAdder对象。DoubleAdder adder = new DoubleAdder(); -
执行累加。在并发环境中 调用
add(double x)方法。// 模拟多线程调用 adder.add(1.0); // 累加1.0 adder.add(2.5); // 累加2.5 -
获取近似总值。调用
sum()方法获取总和。请注意:此方法返回的是调用瞬间的一个快照,可能不是完全精确的实时值,但在统计上是合理的。double total = adder.sum(); // 返回约等于 3.5 -
重要注意事项:
DoubleAdder没有提供compareAndSet()或decrement()等原子操作的组合方法。它设计用于简单的加法累加场景。如果需要复杂的原子更新逻辑(如“当值为X时,更新为Y”),你仍然需要使用AtomicLong或锁。- 不要 混用
add()和reset()方法,除非你完全理解这会导致计数器丢失在reset()调用之后、reset()之前的所有累积值。reset()仅适用于你明确知道需要清零的场景。
最终结论
DoubleAdder 通过“空间换时间”和“化整为零”的分段策略,将单个热点变量的争用压力分散到多个内部变量上,从而在高并发写入场景下实现了远超 AtomicLong 的吞吐量。选择哪个工具,最终取决于你的应用是更看重实时精确读,还是更看重极限写入性能。对于绝大多数面向吞吐量的计数统计需求,DoubleAdder(或 LongAdder)是更优解。

暂无评论,快来抢沙发吧!