Java并发编程中的LongAdder/DoubleAdder:高并发下的计数器优化
大家好,今天我们来聊聊Java并发编程中一个非常重要的优化技巧:利用LongAdder和DoubleAdder来解决高并发下的计数器性能瓶颈问题。
计数器的基本问题与传统解决方案
在并发编程中,计数器是一个非常常见的需求。例如,统计网站的访问量、记录任务的执行次数等等。最简单的实现方式就是使用一个int或long类型的变量,然后用 ++ 或 -- 操作来进行增减。
public class SimpleCounter {
private long counter = 0;
public void increment() {
counter++;
}
public long getCounter() {
return counter;
}
}
但是,在多线程环境下,直接使用这种方式存在严重的并发问题。多个线程同时访问和修改counter变量会导致数据竞争,最终结果可能是不准确的。为了解决这个问题,我们通常会使用锁机制来保证线程安全。
public class SynchronizedCounter {
private long counter = 0;
public synchronized void increment() {
counter++;
}
public long getCounter() {
return counter;
}
}
或者使用AtomicLong:
import java.util.concurrent.atomic.AtomicLong;
public class AtomicCounter {
private AtomicLong counter = new AtomicLong(0);
public void increment() {
counter.incrementAndGet();
}
public long getCounter() {
return counter.get();
}
}
synchronized 关键字和 AtomicLong 都可以保证线程安全,但它们在高并发场景下会成为性能瓶颈。synchronized 会导致线程阻塞,而 AtomicLong 使用 CAS (Compare and Swap) 操作,在高竞争情况下会频繁重试,消耗大量的 CPU 资源。
LongAdder的原理:分段锁的思想
LongAdder 是一种更高效的计数器实现,它通过分段锁的思想来减少竞争。LongAdder 内部维护了一个 Cell 数组,每个 Cell 相当于一个独立的计数器。当多个线程并发更新计数器时,它们会被分配到不同的 Cell 上进行操作,从而降低了单个共享变量的竞争程度。
LongAdder 的核心思想是将一个全局的计数器分解成多个局部的计数器(Cell),每个线程可以访问其中一个局部的计数器,最后将所有局部计数器的值加起来,得到全局计数器的值。
以下是 LongAdder 的大致工作流程:
- 初始化:
LongAdder内部维护一个Cell数组,初始状态下数组为空。 - 更新: 当一个线程需要增加计数器时,它首先会尝试直接更新
base变量。如果更新失败(通常是因为与其他线程发生了竞争),则会选择一个Cell并尝试更新该Cell。选择Cell的方式通常是基于线程的哈希值,以尽量保证线程能够均匀地分散到不同的Cell上。 - 扩容: 如果多个线程竞争同一个
Cell,导致更新失败的次数超过一定阈值,LongAdder会尝试对Cell数组进行扩容,以进一步降低竞争。 - 求和: 当需要获取计数器的总值时,
LongAdder会将base变量的值以及所有Cell的值加起来,得到最终的结果。
LongAdder的代码示例
下面是一个使用 LongAdder 的例子:
import java.util.concurrent.atomic.LongAdder;
public class LongAdderCounter {
private LongAdder adder = new LongAdder();
public void increment() {
adder.increment();
}
public long getCounter() {
return adder.sum();
}
public static void main(String[] args) throws InterruptedException {
LongAdderCounter counter = new LongAdderCounter();
int numThreads = 10;
int iterations = 100000;
Thread[] threads = new Thread[numThreads];
for (int i = 0; i < numThreads; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < iterations; j++) {
counter.increment();
}
});
threads[i].start();
}
for (int i = 0; i < numThreads; i++) {
threads[i].join();
}
System.out.println("Counter value: " + counter.getCounter());
System.out.println("Expected value: " + (long)numThreads * iterations);
}
}
在这个例子中,我们创建了一个 LongAdderCounter 类,它使用 LongAdder 来实现计数器。多个线程并发地调用 increment() 方法来增加计数器的值。最后,我们调用 getCounter() 方法来获取计数器的总值。
DoubleAdder 的应用
DoubleAdder 与 LongAdder 的原理类似,只不过它是用来处理 double 类型的计数器。在需要高精度浮点数计数的情况下,可以使用 DoubleAdder 来替代 AtomicDouble,以提高性能。
import java.util.concurrent.atomic.DoubleAdder;
public class DoubleAdderExample {
public static void main(String[] args) throws InterruptedException {
DoubleAdder adder = new DoubleAdder();
// 模拟并发更新
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
adder.add(0.1); // 每次增加 0.1
}
});
threads[i].start();
}
// 等待所有线程完成
for (Thread thread : threads) {
thread.join();
}
// 打印最终结果
System.out.println("Final sum: " + adder.sum());
}
}
LongAdder/DoubleAdder 与 AtomicLong/AtomicDouble 的比较
| 特性 | AtomicLong/AtomicDouble |
LongAdder/DoubleAdder |
|---|---|---|
| 线程安全 | 是 | 是 |
| 实现机制 | CAS 操作 | 分段锁 + CAS 操作 |
| 竞争激烈程度 | 高 | 低 |
| 性能 | 低 | 高 |
| 适用场景 | 低并发场景 | 高并发场景 |
| 内存占用 | 小 | 大 |
| 是否能获取中间值 | 可以 | 只能获取近似值 |
从上表可以看出,LongAdder/DoubleAdder 在高并发场景下具有更高的性能,但它会占用更多的内存。此外,LongAdder/DoubleAdder 只能获取计数器的近似值,因为它在求和的过程中可能存在其他线程正在更新计数器的情况。
深入理解Cell数组和Striped64
LongAdder 和 DoubleAdder 的核心实现都依赖于 Striped64 类。Striped64 维护了一个 Cell 数组,每个 Cell 包含一个 long 或 double 类型的 value 值。当多个线程并发更新计数器时,它们会被分配到不同的 Cell 上进行操作,从而降低了单个共享变量的竞争程度。
Striped64 的设计目标是在高并发情况下提供高性能的计数器,它通过以下几种方式来实现:
- 分段锁: 将一个全局的计数器分解成多个局部的计数器(Cell),每个线程可以访问其中一个局部的计数器,从而降低了锁的竞争。
- CAS 操作: 使用 CAS (Compare and Swap) 操作来更新
Cell中的值,避免了使用锁带来的开销。 - 动态扩容: 当多个线程竞争同一个
Cell,导致更新失败的次数超过一定阈值,Striped64会尝试对Cell数组进行扩容,以进一步降低竞争。 - 伪共享避免:
Cell内部使用了@sun.misc.Contended注解来避免伪共享问题,提高缓存利用率。
伪共享问题和@sun.misc.Contended注解
伪共享是指多个线程同时修改位于同一个缓存行的不同变量时,会导致缓存失效,从而降低性能。为了避免伪共享问题,Striped64 使用了 @sun.misc.Contended 注解来填充 Cell 对象,使其占据一个独立的缓存行。
@sun.misc.Contended 是一个 JVM 内部注解,用于告诉 JVM 在对象前后填充额外的空间,以避免伪共享问题。使用 @sun.misc.Contended 注解可以显著提高并发程序的性能,尤其是在多核 CPU 上。
注意: @sun.misc.Contended 注解默认情况下是不生效的,需要在 JVM 启动时添加 -XX:-RestrictContended 参数才能启用。
适用场景和注意事项
LongAdder 和 DoubleAdder 适用于以下场景:
- 高并发环境: 当多个线程需要频繁地更新计数器时,使用
LongAdder和DoubleAdder可以提高性能。 - 对精度要求不高:
LongAdder和DoubleAdder只能获取计数器的近似值,因此不适用于对精度要求非常高的场景。 - 内存占用可接受:
LongAdder和DoubleAdder会占用更多的内存,因此需要在内存资源有限的情况下谨慎使用。
在使用 LongAdder 和 DoubleAdder 时,需要注意以下几点:
- 初始化:
LongAdder和DoubleAdder的初始值为 0。 - 求和: 调用
sum()方法可以获取计数器的总值。 - 重置: 调用
reset()方法可以将计数器重置为 0。 - 性能测试: 在实际应用中,需要进行性能测试,以验证
LongAdder和DoubleAdder是否能够带来性能提升。
性能测试和分析
为了更好地理解 LongAdder 的性能优势,我们可以进行一些简单的性能测试,将 LongAdder 与 AtomicLong 进行比较。
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
public class CounterBenchmark {
private static final int NUM_THREADS = 10;
private static final int NUM_ITERATIONS = 1000000;
public static void main(String[] args) throws InterruptedException {
System.out.println("Starting benchmark...");
// AtomicLong benchmark
AtomicLong atomicCounter = new AtomicLong(0);
long atomicTime = benchmarkAtomicLong(atomicCounter);
System.out.println("AtomicLong time: " + atomicTime + " ms");
// LongAdder benchmark
LongAdder adderCounter = new LongAdder();
long adderTime = benchmarkLongAdder(adderCounter);
System.out.println("LongAdder time: " + adderTime + " ms");
System.out.println("Benchmark finished.");
}
private static long benchmarkAtomicLong(AtomicLong counter) throws InterruptedException {
Thread[] threads = new Thread[NUM_THREADS];
long startTime = System.currentTimeMillis();
for (int i = 0; i < NUM_THREADS; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < NUM_ITERATIONS; j++) {
counter.incrementAndGet();
}
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
long endTime = System.currentTimeMillis();
return endTime - startTime;
}
private static long benchmarkLongAdder(LongAdder counter) throws InterruptedException {
Thread[] threads = new Thread[NUM_THREADS];
long startTime = System.currentTimeMillis();
for (int i = 0; i < NUM_THREADS; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < NUM_ITERATIONS; j++) {
counter.increment();
}
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
long endTime = System.currentTimeMillis();
return endTime - startTime;
}
}
通过运行这个基准测试,我们可以观察到在高并发情况下,LongAdder 的性能明显优于 AtomicLong。这是因为 LongAdder 使用了分段锁的思想,降低了竞争程度。
总结
LongAdder 和 DoubleAdder 是 Java 并发编程中非常有用的工具,它们通过分段锁的思想来优化高并发下的计数器性能。在高并发场景下,使用 LongAdder 和 DoubleAdder 可以显著提高程序的性能,但需要注意它们会占用更多的内存,并且只能获取计数器的近似值。 在选择使用 LongAdder/DoubleAdder 或 AtomicLong/AtomicDouble 时,需要根据具体的应用场景和性能需求进行权衡。
关于高并发计数器优化
LongAdder 和 DoubleAdder 提供了高并发场景下的计数器优化方案,它们通过分段锁和 CAS 操作降低了竞争,提高了性能。
如何选择合适的计数器实现
在选择计数器实现时,需要根据具体的应用场景和性能需求进行权衡,考虑并发程度、精度要求和内存占用等因素。