告别面试哑火:一个亿小目标带你从入门到精通搞定并发计数
面试被问原理答不上来,这种尴尬你经历过吗?
当面试官追问“如何准确统计一个亿次请求”时,你只记得 count++,却卡在高并发下的数据丢失上,这直接暴露了基础不牢。
想从入门到精通搞定这类高频考点,光背八股文没用,必须动手跑通一个亿级别的真实场景。
项目目标与痛点拆解
很多初学者以为,统计数量就是简单的加法。但在高并发环境下,这种想法是致命的。
我们要实现的目标很明确:在多线程环境下,准确无误地统计从 0 到 100,000,000 的每一次自增操作。
为什么定这个数?因为 1 亿是一个量级分界线。
低于 1 万,普通变量勉强够用;低于 100 万,原子类开始显现优势;一旦超过 100 万,普通的原子操作会因为缓存行竞争(Cache Line Contention)导致性能断崖式下跌。
这时候,就需要引入分段计数思想。这也是大厂面试中区分“背题选手”和“实战选手”的关键分水岭。
如果只会用 AtomicInteger,面试官会觉得你停留在初级水平。
如果能结合 LongAdder 或者自研的分段锁策略,并解释清楚背后的硬件原理,你就跨过了“入门”的门槛,真正触及了“精通”的边界。
我们的项目将基于 Java 17 实现,理由很简单:它是目前企业级开发的主流版本,且包含了许多性能优化的新特性。
目录结构设计
为了保持代码的可维护性和可扩展性,我们采用标准的 Maven 项目结构。
不要把所有代码都塞在一个 Main 类里,那是玩具代码,不是工程代码。
billions-counter/
├── pom.xml
├── src/
│ └── main/
│ └── java/
│ └── com/
│ └── example/
│ └── counter/
│ ├── Main.java
│ ├── strategies/
│ │ ├── SimpleCounter.java
│ │ ├── AtomicCounter.java
│ │ └── SegmentedCounter.java
│ └── utils/
│ └── BenchmarkUtil.javastrategies 包是核心,里面存放三种不同的计数策略。
utils 包用于封装通用的基准测试工具,比如启动线程池、等待线程结束、计算耗时等。
这种结构的好处是,后续如果想增加新的策略(比如基于 Redis 的分布式计数),只需新增一个实现类,无需修改主流程代码。
这符合开闭原则,也是代码工程中化的基础。
在 pom.xml 中,我们不需要引入复杂的第三方库。
Java 标准库中的 java.util.concurrent 包已经足够强大。
唯一需要注意的是,确保编译级别设置为 Java 17,以支持最新的语法特性。
核心代码实现
1. 朴素实现:为什么它必挂?
先看最基础的 SimpleCounter。
package com.example.counter.strategies;public class SimpleCounter {private long count = 0;public void increment() {count++;}public long getCount() {return count;}
}这段代码看起来毫无问题,但在并发环境下,count++ 并非原子操作。
它被编译成三条字节码指令:读取 count 值、加 1、写回 count。
如果线程 A 读取了值,线程 B 也读取了同样的值,两者各自加 1 后写回,最终结果只增加了 1,而不是 2。
这就是经典的“丢失更新”问题。
在单线程测试中,它永远正确。但在高并发下,误差会非常大。
2. 原子实现:锁的代价
接下来看 AtomicCounter。
package com.example.counter.strategies;import java.util.concurrent.atomic.AtomicLong;public class AtomicCounter {private final AtomicLong counter = new AtomicLong(0);public void increment() {counter.incrementAndGet();}public long getCount() {return counter.get();}
}AtomicLong 使用了 CAS(Compare-And-Swap)机制。
底层依赖 CPU 的原子指令,保证操作的原子性。
但这带来了新的问题:自旋锁。
当多个线程竞争同一个内存地址时,如果 CAS 失败,线程不会阻塞,而是不断重试(自旋)。
在高并发场景下,这种自旋会消耗大量的 CPU 周期。
根据 Oracle 官方开发者文档的建议,在高竞争场景下,AtomicLong 的性能会显著低于预期。
3. 分段实现:真正的性能王者
为了解决竞争问题,我们引入 SegmentedCounter。
核心思想:化整为零。
不把所有请求都打到同一个计数器上,而是分散到多个独立的计数器上。
最后统计时,再将各个分段的结果累加。
package com.example.counter.strategies;import java.util.concurrent.atomic.LongAdder;public class SegmentedCounter {// 分段数量,通常为 CPU 核心数的倍数private static final int SEGMENT_COUNT = 32;private final LongAdder[] segments = new LongAdder[SEGMENT_COUNT];public SegmentedCounter() {for (int i = 0; i SEGMENT_COUNT; i++) {segments[i] = new LongAdder();}}public void increment() {// 使用线程 ID 的哈希值决定落到哪个分段int index = (int) (Thread.currentThread().getId() % SEGMENT_COUNT);segments[index].increment();}public long getCount() {long sum = 0;for (LongAdder segment : segments) {sum += segment.sum();}return sum;}
}这里我们直接使用了 JDK 提供的 LongAdder。
为什么不用 AtomicLong?
LongAdder 内部就是采用了分段技术(Cell 数组)。
它的设计哲学是:牺牲实时性,换取吞吐量。
在 increment() 时,它并不保证立即看到最新值,但在高并发下,它的性能远超 AtomicLong。
对于“统计一个亿”这种场景,我们更关心最终结果的准确性,而不是每次调用后立即读取最新值。
4. 基准测试工具
为了量化对比,我们编写一个测试工具。
package com.example.counter.utils;import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;public class BenchmarkUtil {public static void runBenchmark(Runnable task, int threadCount, long iterationsPerThread) throws InterruptedException {ExecutorService executor = Executors.newFixedThreadPool(threadCount);AtomicBoolean done = new AtomicBoolean(false);long startTime = System.nanoTime();for (int i = 0; i threadCount; i++) {executor.submit(() - {for (long j = 0; j iterationsPerThread; j++) {task.run();}});}// 等待所有任务完成executor.shutdown();while (!executor.awaitTermination(1, TimeUnit.HOURS)) {// 超时处理}long endTime = System.nanoTime();double durationMs = (endTime - startTime) / 1_000_000.0;System.out.printf(耗时: %.2f 毫秒%n, durationMs);}
}注意:这里使用了 awaitTermination 来确保所有线程执行完毕后再计算耗时。
这是性能测试中最容易出错的地方之一。
运行与测试
现在,我们来跑一下这三个策略。
假设我们启动 8 个线程,每个线程执行 12,500,000 次自增,总计 1 亿次。
测试 1:SimpleCounter
SimpleCounter simple = new SimpleCounter();
BenchmarkUtil.runBenchmark(simple::increment, 8, 12_500_000);
System.out.println(结果: + simple.getCount());结果预测:耗时极短,但结果远小于 1 亿。
实际运行结果可能在 8,000 万到 9,500 万之间波动,具体取决于 CPU 调度和竞争激烈程度。
结论:数据丢失严重,不可用于生产环境。
测试 2:AtomicCounter
AtomicCounter atomic = new AtomicCounter();
BenchmarkUtil.runBenchmark(atomic::increment, 8, 12_500_000);
System.out.println(结果: + atomic.getCount());结果预测:结果精确为 100,000,000。
耗时:在高并发下,耗时较长。在 8 核机器上,可能需要 500-800 毫秒。
原因:CAS 失败率高,导致大量自旋等待。
测试 3:SegmentedCounter
SegmentedCounter segmented = new SegmentedCounter();
BenchmarkUtil.runBenchmark(segmented::increment, 8, 12_500_000);
System.out.println(结果: + segmented.getCount());结果预测:结果精确为 100,000,000。
耗时:显著降低。在相同环境下,耗时可能在 100-200 毫秒之间。
性能提升:相比 AtomicCounter,吞吐量提升 3-5 倍。
这就是分段计数的威力。
它通过减少锁竞争(实际上是减少内存地址的写冲突),极大地提高了并发处理能力。
优化扩展与避坑指南
在实际工程中,还有几个关键点需要注意。
1. 分段数量的选择
在 SegmentedCounter 中,我们将分段数设为 32。
这个值不是随便定的。
建议参考 JDK 开发者文档 中关于 LongAdder 的说明:Cell 数组的大小通常会根据 CPU 核心数动态调整。
一般建议分段数为 CPU 核心数的 2-4 倍。
如果分段太少,竞争依然存在;如果分段太多,最后汇总时的遍历成本会增加。
对于单核机器,分段数设为 1 即可;对于 8 核机器,32 是一个不错的起点。
2. 读取一致性问题
LongAdder 的 sum() 方法并不保证强一致性。
如果在统计过程中,其他线程仍在写入,sum() 返回的值可能不是最新的。
在“统计一个亿”这种场景下,通常会在所有写入操作结束后再调用 sum(),因此这个问题影响不大。
但如果需要实时监控计数,就需要权衡实时性和性能。
3. 内存屏障
AtomicLong 和 LongAdder 内部都使用了 volatile 语义或内存屏障。
这意味着,它们不仅保证了原子性,还保证了可见性。
不要试图通过手动添加 synchronized 来“优化”这些类,那只会适得其反。
4. 跨语言对比
如果你熟悉 Go 语言,会发现 sync/atomic 包中的 AddInt64 行为类似 AtomicLong。
但 Go 1.21 之后,引入了更高效的 atomic.Int64 类型,其底层实现也借鉴了分段思想。
不同语言在并发原语上的演进方向是一致的:从粗粒度锁到细粒度原子操作,再到无锁或分段无锁结构。
小结
从 SimpleCounter 的丢数据,到 AtomicCounter 的性能瓶颈,再到 SegmentedCounter 的高吞吐,我们完成了一个从入门到精通的认知闭环。
面试中,如果只答出 AtomicLong,说明你只知道“是什么”。
如果能解释清楚“为什么 LongAdder 在高并发下更快”,并画出分段计数的内存模型,说明你理解了“为什么”。
这种深度,才是企业级开发所看重的。
技术没有银弹,只有最适合场景的方案。
在低并发、强一致性要求高的场景,AtomicLong 依然是首选。
在高并发、最终一致性可接受的场景,LongAdder 或自研分段计数器才是正解。
记住,性能优化的核心永远是:减少竞争,降低延迟,提高吞吐。
这个“一个亿小目标”的项目,看似简单,实则涵盖了并发编程的核心难点。
建议你亲自跑一遍代码,修改线程数和分段数,观察性能变化。
纸上得来终觉浅,绝知此事要躬行。
还有什么不懂的?评论区留言挨个回。
