Java并发编程中的LongAdder/DoubleAdder:高并发下的计数器优化

Java并发编程中的LongAdder/DoubleAdder:高并发下的计数器优化

大家好,今天我们来聊聊Java并发编程中一个非常重要的优化技巧:利用LongAdderDoubleAdder来解决高并发下的计数器性能瓶颈问题。

计数器的基本问题与传统解决方案

在并发编程中,计数器是一个非常常见的需求。例如,统计网站的访问量、记录任务的执行次数等等。最简单的实现方式就是使用一个intlong类型的变量,然后用 ++-- 操作来进行增减。

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 的大致工作流程:

  1. 初始化: LongAdder 内部维护一个 Cell 数组,初始状态下数组为空。
  2. 更新: 当一个线程需要增加计数器时,它首先会尝试直接更新 base 变量。如果更新失败(通常是因为与其他线程发生了竞争),则会选择一个 Cell 并尝试更新该 Cell。选择 Cell 的方式通常是基于线程的哈希值,以尽量保证线程能够均匀地分散到不同的 Cell 上。
  3. 扩容: 如果多个线程竞争同一个 Cell,导致更新失败的次数超过一定阈值,LongAdder 会尝试对 Cell 数组进行扩容,以进一步降低竞争。
  4. 求和: 当需要获取计数器的总值时,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 的应用

DoubleAdderLongAdder 的原理类似,只不过它是用来处理 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

LongAdderDoubleAdder 的核心实现都依赖于 Striped64 类。Striped64 维护了一个 Cell 数组,每个 Cell 包含一个 longdouble 类型的 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 参数才能启用。

适用场景和注意事项

LongAdderDoubleAdder 适用于以下场景:

  • 高并发环境: 当多个线程需要频繁地更新计数器时,使用 LongAdderDoubleAdder 可以提高性能。
  • 对精度要求不高: LongAdderDoubleAdder 只能获取计数器的近似值,因此不适用于对精度要求非常高的场景。
  • 内存占用可接受: LongAdderDoubleAdder 会占用更多的内存,因此需要在内存资源有限的情况下谨慎使用。

在使用 LongAdderDoubleAdder 时,需要注意以下几点:

  • 初始化: LongAdderDoubleAdder 的初始值为 0。
  • 求和: 调用 sum() 方法可以获取计数器的总值。
  • 重置: 调用 reset() 方法可以将计数器重置为 0。
  • 性能测试: 在实际应用中,需要进行性能测试,以验证 LongAdderDoubleAdder 是否能够带来性能提升。

性能测试和分析

为了更好地理解 LongAdder 的性能优势,我们可以进行一些简单的性能测试,将 LongAdderAtomicLong 进行比较。

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 使用了分段锁的思想,降低了竞争程度。

总结

LongAdderDoubleAdder 是 Java 并发编程中非常有用的工具,它们通过分段锁的思想来优化高并发下的计数器性能。在高并发场景下,使用 LongAdderDoubleAdder 可以显著提高程序的性能,但需要注意它们会占用更多的内存,并且只能获取计数器的近似值。 在选择使用 LongAdder/DoubleAdderAtomicLong/AtomicDouble 时,需要根据具体的应用场景和性能需求进行权衡。

关于高并发计数器优化

LongAdderDoubleAdder 提供了高并发场景下的计数器优化方案,它们通过分段锁和 CAS 操作降低了竞争,提高了性能。

如何选择合适的计数器实现

在选择计数器实现时,需要根据具体的应用场景和性能需求进行权衡,考虑并发程度、精度要求和内存占用等因素。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注