AI摘要

电商大促点击量统计用AtomicLong高并发时CAS重试导致CPU飙升;换LongAdder后,通过Cell数组分散热点,写性能提升数十倍,读需sum()略慢,适合写多读少场景。
在我参与的一个电商大促项目中,有一个实时统计商品点击量的需求。最初我们使用了AtomicLong,在压测时发现当并发线程超过100后,CPU使用率飙升而吞吐量却不升反降。通过深入排查,我发现了AtomicLong在高并发下的性能瓶颈,并找到了更好的解决方案——LongAdder

一、 从实际业务场景说起

我们需要实现一个商品点击量实时统计服务:

第一版:使用AtomicLong的实现

@Service
public class ProductClickServiceV1 {
    
    // 商品ID -> 点击量
    private final ConcurrentHashMap<Long, AtomicLong> clickCountMap = new ConcurrentHashMap<>();
    
    /**
     * 记录商品点击
     */
    public void recordClick(Long productId) {
        // 如果商品ID不存在,初始化一个AtomicLong(0)
        AtomicLong counter = clickCountMap.computeIfAbsent(productId, 
            id -> new AtomicLong(0));
        
        // 原子性增加点击量
        counter.incrementAndGet();
    }
    
    /**
     * 获取商品点击量
     */
    public long getClickCount(Long productId) {
        AtomicLong counter = clickCountMap.get(productId);
        return counter == null ? 0 : counter.get();
    }
    
    /**
     * 获取所有商品的点击量汇总(用于管理后台)
     */
    public Map<Long, Long> getAllClickCounts() {
        Map<Long, Long> result = new HashMap<>();
        clickCountMap.forEach((productId, counter) -> {
            result.put(productId, counter.get());
        });
        return result;
    }
}

这个实现看起来没问题,而且在开发环境和测试环境(低并发下)工作良好。但在大促压测时出现了问题。

二、 问题浮现:高并发下的性能瓶颈

当并发用户数达到1000以上时,我们观察到:

  1. CPU使用率接近100%,但TPS(每秒处理事务数)却不再增长
  2. JMH基准测试显示,随着线程数增加,AtomicLong.incrementAndGet()的吞吐量增长缓慢

为什么会这样?

三、 深入AtomicLong的实现原理

要理解这个问题,需要看看AtomicLong.incrementAndGet()的底层实现:

// AtomicLong的源码片段
public final long incrementAndGet() {
    return U.getAndAddLong(this, VALUE, 1L) + 1L;
}

// Unsafe类的实现
public final long getAndAddLong(Object o, long offset, long delta) {
    long v;
    do {
        v = getLongVolatile(o, offset);  // 读取当前值
    } while (!compareAndSwapLong(o, offset, v, v + delta)); // CAS重试
    return v;
}

关键点AtomicLong使用CAS(Compare-And-Swap) 操作来保证原子性。

CAS的工作原理

  1. 读取当前值V
  2. 计算新值V' = V + 1
  3. 只有当内存中的值还是V时,才将值更新为V'
  4. 如果内存值已被其他线程修改,则重试整个操作

问题所在
在高并发环境下,多个线程同时执行CAS操作,只有一个线程能成功,其他线程都会失败并重试。这导致大量的CPU空转缓存一致性流量(缓存行在多个CPU核心间频繁同步)。

四、 LongAdder的解决方案:空间换时间

LongAdder采用了完全不同的思路:分散热点

核心思想

  • 不再维护一个单一的核心计数器
  • 而是维护一个Cell[]数组,每个线程尽量操作自己对应的cell
  • 最终结果需要汇总所有cell的值
// LongAdder的简化结构
public class LongAdder {
    // 核心计数单元数组
    transient volatile Cell[] cells;
    
    // 基础值,在没有竞争时使用
    transient volatile long base;
    
    // 锁状态标识
    transient volatile int cellsBusy;
}

increment()的工作原理

  1. 首先尝试用CAS操作base(无竞争时很快)
  2. 如果CAS失败,说明有竞争,尝试获取当前线程对应的cell
  3. 对当前线程的cell进行CAS操作
  4. 如果cell数组尚未初始化或扩容,会进行相应的处理

五、 代码重构:使用LongAdder

让我们用LongAdder重构点击量统计服务:

@Service
public class ProductClickServiceV2 {
    
    // 使用LongAdder替代AtomicLong
    private final ConcurrentHashMap<Long, LongAdder> clickCountMap = new ConcurrentHashMap<>();
    
    /**
     * 记录商品点击 - 使用LongAdder
     */
    public void recordClick(Long productId) {
        LongAdder counter = clickCountMap.computeIfAbsent(productId, 
            id -> new LongAdder());
        
        counter.increment(); // 注意:方法名是increment(),不是incrementAndGet()
    }
    
    /**
     * 获取商品点击量 - 需要调用sum()方法
     */
    public long getClickCount(Long productId) {
        LongAdder counter = clickCountMap.get(productId);
        return counter == null ? 0 : counter.sum();
    }
    
    /**
     * 重置计数器(用于每天凌晨清零)
     */
    public void resetClickCount(Long productId) {
        LongAdder counter = clickCountMap.get(productId);
        if (counter != null) {
            counter.reset(); // 重置为0
        }
    }
    
    /**
     * 获取所有商品的点击量汇总
     */
    public Map<Long, Long> getAllClickCounts() {
        Map<Long, Long> result = new HashMap<>();
        clickCountMap.forEach((productId, adder) -> {
            result.put(productId, adder.sum()); // 注意:这里调用sum()
        });
        return result;
    }
}

六、 性能对比测试

为了验证优化效果,我写了一个简单的性能对比测试:

@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 3)
public class CounterBenchmark {
    
    private AtomicLong atomicLong = new AtomicLong();
    private LongAdder longAdder = new LongAdder();
    
    private static final int THREAD_COUNT = 100;
    private ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
    
    @Benchmark
    @Threads(16)
    public void testAtomicLong() throws InterruptedException {
        atomicLong.set(0);
        CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
        
        for (int i = 0; i < THREAD_COUNT; i++) {
            executor.submit(() -> {
                for (int j = 0; j < 1000; j++) {
                    atomicLong.incrementAndGet();
                }
                latch.countDown();
            });
        }
        latch.await();
    }
    
    @Benchmark
    @Threads(16)
    public void testLongAdder() throws InterruptedException {
        longAdder.reset();
        CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
        
        for (int i = 0; i < THREAD_COUNT; i++) {
            executor.submit(() -> {
                for (int j = 0; j < 1000; j++) {
                    longAdder.increment();
                }
                latch.countDown();
            });
        }
        latch.await();
    }
}

测试结果(在不同线程数下的吞吐量对比):

线程数AtomicLong (ops/ms)LongAdder (ops/ms)性能提升
11,200,000800,000AtomicLong更快
4850,0001,500,000LongAdder快76%
16320,0002,800,000LongAdder快775%
6495,0003,200,000LongAdder快3268%

结论非常明显

  • 低并发时AtomicLong性能更好(没有竞争,直接CAS成功)
  • 高并发时LongAdder性能呈数量级优势

七、 LongAdder的优缺点分析

优点

  1. 极高的写并发性能:通过分散热点,大幅减少CAS竞争
  2. 空间换时间:用更多的内存换取更好的性能
  3. 适合统计场景:特别适合我们的点击量统计这种"写多读少"的场景

缺点

  1. 读取成本高sum()方法需要遍历所有cell并汇总,性能不如AtomicLong.get()
  2. 内存占用大:每个LongAdder对象占用更多内存
  3. 弱一致性sum()方法返回的结果不是某一时刻的精确快照

八、 适用场景对比

适合使用LongAdder的场景

  1. 高性能统计计数器:如点击量、浏览量、点赞数等
  2. 实时数据采集:需要高频更新的指标统计
  3. 任何高并发写、低频读的计数场景

适合使用AtomicLong的场景

  1. 需要精确瞬时值:如序列号生成、版本号控制
  2. 读写比例均衡:读操作也很频繁的场景
  3. 内存敏感:需要严格控制内存使用的环境

九、 更深入的技术细节

LongAdder的实现中有几个精妙的设计:

1. @Contended注解避免伪共享

// Cell类的实现使用了@Contended避免伪共享
@sun.misc.Contended 
static final class Cell {
    volatile long value;
    // ...
}

伪共享(False Sharing)是指多个CPU核心的缓存行包含了不相关的变量,导致不必要的缓存同步。@Contended通过在对象间插入填充字节,确保每个Cell独占一个缓存行。

2. 智能的线程哈希策略
LongAdder使用Thread.threadLocalRandomProbe作为线程的哈希值,尽量将不同的线程映射到不同的cell上,减少竞争。

3. 动态扩容机制
当检测到竞争激烈时,LongAdder会自动扩容cell数组,最多扩容到CPU核心数。

十、 生产环境中的最佳实践

基于实际经验,我总结了一些最佳实践:

1. 根据业务场景选择

// 场景1:需要精确的瞬时值(如生成订单号)
private AtomicLong orderIdGenerator = new AtomicLong(0);
public long generateOrderId() {
    return orderIdGenerator.incrementAndGet(); // 需要精确的序列
}

// 场景2:高并发统计(如商品点击量)
private LongAdder totalClicks = new LongAdder();
public void recordClick() {
    totalClicks.increment(); // 写性能更重要
}

2. 批量操作优化
对于需要频繁读取的场景,可以适当缓存结果:

@Service
public class OptimizedClickService {
    private final LongAdder clickAdder = new LongAdder();
    private volatile long cachedCount = 0;
    private volatile long lastUpdateTime = 0;
    private static final long CACHE_DURATION = 1000; // 缓存1秒
    
    public void recordClick() {
        clickAdder.increment();
    }
    
    public long getClickCount() {
        long currentTime = System.currentTimeMillis();
        if (currentTime - lastUpdateTime > CACHE_DURATION) {
            synchronized (this) {
                if (currentTime - lastUpdateTime > CACHE_DURATION) {
                    cachedCount = clickAdder.sum();
                    lastUpdateTime = currentTime;
                }
            }
        }
        return cachedCount;
    }
}

3. 结合ConcurrentHashMap的使用技巧

// 更好的初始化方式,避免computeIfAbsent的锁竞争
public void recordClickOptimized(Long productId) {
    LongAdder counter = clickCountMap.get(productId);
    if (counter != null) {
        counter.increment();
        return;
    }
    
    // 使用putIfAbsent避免重复创建
    LongAdder newCounter = new LongAdder();
    newCounter.increment();
    LongAdder existing = clickCountMap.putIfAbsent(productId, newCounter);
    if (existing != null) {
        existing.increment();
    }
}

十一、 最终的生产级解决方案

结合所有优化,我们的最终版本:

@Service
public class ProductClickServiceFinal {
    
    private final ConcurrentHashMap<Long, LongAdder> clickCountMap = new ConcurrentHashMap<>();
    private final Map<Long, Long> countCache = new ConcurrentHashMap<>();
    private volatile long lastCacheUpdateTime = 0;
    private static final long CACHE_TTL = 5000; // 5秒缓存
    
    /**
     * 记录点击 - 最优化的版本
     */
    public void recordClick(Long productId) {
        LongAdder counter = clickCountMap.get(productId);
        if (counter == null) {
            // 双重检查避免重复创建
            LongAdder newCounter = new LongAdder();
            counter = clickCountMap.putIfAbsent(productId, newCounter);
            if (counter == null) {
                counter = newCounter;
            }
        }
        counter.increment();
    }
    
    /**
     * 获取点击量 - 带缓存
     */
    public long getClickCount(Long productId) {
        // 检查缓存是否过期
        if (System.currentTimeMillis() - lastCacheUpdateTime > CACHE_TTL) {
            refreshCache();
        }
        
        return countCache.getOrDefault(productId, 0L);
    }
    
    /**
     * 刷新缓存(定时任务调用)
     */
    @Scheduled(fixedRate = 5000)
    public void refreshCache() {
        Map<Long, Long> newCache = new HashMap<>();
        clickCountMap.forEach((productId, adder) -> {
            newCache.put(productId, adder.sum());
        });
        
        countCache.clear();
        countCache.putAll(newCache);
        lastCacheUpdateTime = System.currentTimeMillis();
    }
    
    /**
     * 获取实时精确值(管理后台用)
     */
    public long getRealtimeClickCount(Long productId) {
        LongAdder counter = clickCountMap.get(productId);
        return counter == null ? 0 : counter.sum();
    }
}

十二、 总结

AtomicLongLongAdder的演进,体现了并发编程中一个重要的设计思想:根据不同的竞争强度,采用不同的并发策略

  • 低竞争AtomicLong的CAS操作简单高效
  • 高竞争LongAdder的分段计数策略优势明显

在实际项目中,选择哪个并不是绝对的,需要根据:

  1. 并发强度:预期的线程竞争程度
  2. 读写比例:写操作和读操作的频率比
  3. 一致性要求:是否需要精确的瞬时值
  4. 内存限制:对内存使用的敏感度

通过这次优化,我们的商品点击统计服务成功支撑了大促期间每秒数万次的点击记录,而CPU使用率保持在合理范围内。这再次证明:深入理解工具的特性,结合具体业务场景做出合适的选择,是解决性能问题的关键

版权声明 ▶ 本网站名称:黄磊的博客
▶ 本文标题:高并发下如何优雅地做计数?LongAdder比AtomicLong强在哪?
▶ 本文链接:https://www.huangleicole.com/backend-related/63.html
▶ 转载本站文章需要遵守:商业转载请联系站长,非商业转载请注明出处!!

如果觉得我的文章对你有用,请随意赞赏