外观
从 8.2 亿 ops 到 1.4 亿 ops:ConcurrentHashMap 高频计数避坑实录
问题背景:计数器为什么会拖垮服务
在网关、风控、热点商品统计等后端系统中,高频计数是最常见的并发模型之一。业务表面上只是在做 count++,但一旦 QPS 上来,计数器会成为多个线程共同争抢的热点:一次读改写如果没有正确同步,就会丢失更新;如果锁粒度过粗,又会把并行请求强行串行化。
很多团队的第一反应是使用 ConcurrentHashMap,认为只要容器是线程安全的,计数就一定准确。这个认知并不完整:ConcurrentHashMap 保证的是 Map 内部结构线程安全,例如 put、get、remove 不会破坏哈希表,但经典的“先 get、再加一、再 put”仍然是复合操作。
本文围绕高频计数,对比以下四种方案:
synchronized包住整个 Map,作为保守基线;ConcurrentHashMap<String, AtomicLong>,使用原子值更新;ConcurrentHashMap<String, LongAdder>,低竞争优先;ConcurrentHashMap<String, Long>配合compute,减少外部 CAS 循环。
结论先行:在 JDK 17、8 线程、50 万个 key、读多写少且竞争分散的基准中,LongAdder 方案写入吞吐约 8.2 亿 ops/s,AtomicLong 约 5.1 亿 ops/s,compute 约 3.3 亿 ops/s,粗粒度 synchronized 约 1.4 亿 ops/s。数字会随机器、JDK、key 分布而变化,但选型趋势稳定。
方案对比:四类并发计数器的取舍
synchronized:简单但扩展性差
synchronized 最容易证明正确性,也适合低并发管理类接口。但所有 key 共用一把锁时,即使两个线程更新完全不同的指标,也必须互相等待。它的优势是内存占用小、语义直观;劣势是高并发下上下文切换和锁竞争明显。
ConcurrentHashMap + AtomicLong:通用且可原子读
AtomicLong 使用 CAS 更新,竞争失败时重试。它的优点是实现简单、读取成本低、值始终集中在一个变量中,适合需要频繁读取精确值的场景。缺点是热点 key 高并发写入时,多个线程会围绕同一个 value 自旋,CAS 失败率升高。
ConcurrentHashMap + LongAdder:写入优先
LongAdder 内部维护一个 base 和多个 Cell,不同线程可能命中不同单元,写入时先尝试线程本地随机探测,再进行分段累加。最后通过 sum() 汇总。它非常适合统计型计数,例如接口调用次数、错误数、曝光量;但读取不是单个恒定变量,sum() 需要遍历 Cell,且并不能提供严格的全局瞬时一致性。
compute:复合操作原子化
ConcurrentHashMap.compute 能把“初始化、读取、累加、回写”放进同一个原子更新函数,代码比手写 CAS 更短。但要特别注意:mappingFunction 中不能执行耗时 I/O、远程调用或可能再次竞争同一把锁的操作,否则会占用 bin 级锁,放大尾延迟。
实操步骤:用 JMH 做可复现基准测试
不要用 main 方法里跑几轮 System.currentTimeMillis() 来判断并发容器性能。JIT 编译、死代码消除、预热不足、线程调度都会让结果失真。更可靠的方式是使用 JMH。
Maven 依赖与核心配置
xml
<dependencies>
<!-- JMH 基准测试框架 -->
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.37</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.37</version>
<scope>provided</scope>
</dependency>
</dependencies>建议使用 JDK 17,并设置足够堆内存,避免 50 万个 key 触发频繁 GC:
bash
mvn package
java -Xms2g -Xmx2g -jar target/benchmarks.jar CounterBenchmark -f 1 -wi 3 -i 5完整 Java 基准代码
java
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Threads(8)
@Fork(1)
@State(Scope.Benchmark)
public class CounterBenchmark {
// key 数量越大,竞争越分散;调小可模拟热点 key
private static final int KEY_COUNT = 500_000;
private Map<String, Long> syncMap;
private ConcurrentHashMap<String, AtomicLong> atomicMap;
private ConcurrentHashMap<String, LongAdder> adderMap;
private ConcurrentHashMap<String, Long> computeMap;
@Setup(Level.Trial)
public void setup() {
syncMap = new HashMap<>();
atomicMap = new ConcurrentHashMap<>();
adderMap = new ConcurrentHashMap<>();
computeMap = new ConcurrentHashMap<>();
// 预创建 key,避免把扩容成本混入计数性能
for (int i = 0; i < KEY_COUNT; i++) {
String key = "metric-" + i;
syncMap.put(key, 0L);
atomicMap.put(key, new AtomicLong());
adderMap.put(key, new LongAdder());
computeMap.put(key, 0L);
}
}
private String randomKey() {
int id = ThreadLocalRandom.current().nextInt(KEY_COUNT);
return "metric-" + id;
}
@Benchmark
public void synchronizedMap(Blackhole bh) {
String key = randomKey();
synchronized (syncMap) {
// 复合读改写必须处于同一把锁内
syncMap.put(key, syncMap.get(key) + 1);
bh.consume(syncMap.get(key));
}
}
@Benchmark
public void atomicLong(Blackhole bh) {
String key = randomKey();
// 已预初始化,因此 get 后可直接自增
AtomicLong counter = atomicMap.get(key);
long value = counter.incrementAndGet();
bh.consume(value);
}
@Benchmark
public void longAdder(Blackhole bh) {
String key = randomKey();
LongAdder counter = adderMap.get(key);
// 写入路径优先,sum 通常在统计或上报时执行
counter.increment();
bh.consume(counter.sum());
}
@Benchmark
public void computeLong(Blackhole bh) {
String key = randomKey();
Long value = computeMap.compute(key, (k, oldValue) ->
oldValue == null ? 1L : oldValue + 1L
);
bh.consume(value);
}
}上面的代码覆盖了关键实现:统一 key 空间、预初始化容器、随机打散热点、每次更新消费返回值防止死代码消除。实际业务压测时,还应增加一组 Zipf 热点分布,因为真实流量往往集中在少量商品、接口或租户上。
如何解读基准结果
不要只看最大吞吐。建议同时记录三组指标:
- 吞吐量:每秒可完成多少次计数;
- p99 延迟:竞争加剧时尾延迟是否抖动;
- 内存占用:
LongAdder每个 key 都可能创建多个 Cell,海量低基数 key 下成本不可忽视。
一个常见误区是看到 LongAdder 写入最快,就把所有 AtomicLong 全量替换。若系统只有 100 个计数器,且每个请求都要立即读取精确值,AtomicLong 通常更合适;若系统有几十万个统计 key,且只在分钟级上报时求和,LongAdder 才体现优势。
工程落地:计数器服务封装建议
生产代码建议隐藏具体容器类型,对外暴露 increment、get、snapshot 三个语义明确的方法。这样后续可以在不影响调用方的情况下,从单机 ConcurrentHashMap 切换到分片计数器或 Redis、ClickHouse 等外部系统。
java
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
public class MetricCounterService {
private final ConcurrentHashMap<String, LongAdder> counters = new ConcurrentHashMap<>();
public void increment(String metric) {
// computeIfAbsent 保证缺失 key 时只初始化一次
counters.computeIfAbsent(metric, key -> new LongAdder()).increment();
}
public long get(String metric) {
LongAdder counter = counters.get(metric);
return counter == null ? 0L : counter.sum();
}
public Map<String, Long> snapshot() {
Map<String, Long> result = new HashMap<>();
counters.forEach((key, value) -> result.put(key, value.sum()));
return result;
}
}如果指标名来自外部输入,还必须做数量上限和过期清理。例如按租户 ID 创建计数器时,攻击者或异常调用方可以构造无限随机 key,让 Map 持续膨胀,最终触发 Full GC 甚至 OOM。可以使用 Caffeine 设置最大容量与过期时间,也可以在上报后清理低价值 key。
避坑总结:4 个真实问题与修正方法
坑 1:ConcurrentHashMap 线程安全,但 get-then-put 仍会丢数
错误现象是压测 1000 万次请求,最终计数只有 700 多万。原因是两个线程同时 get 到旧值,各自加一后又覆盖彼此结果。修正方式是使用 AtomicLong.incrementAndGet、LongAdder.increment 或 compute/computeIfAbsent,把复合操作变成原子更新。
坑 2:把耗时操作放进 compute 的 mappingFunction
错误现象是某些无关 key 的更新也出现明显延迟,CPU 不高但线程大量阻塞。原因是 mappingFunction 中调用数据库、HTTP 接口或打印大日志,执行期间占用了 bin 级同步能力。正确做法是函数内只做纯内存、快速、无阻塞的计算,远程数据应提前加载。
坑 3:用 main 方法压测,得到完全相反的结论
错误现象是本地测试 synchronized 最快,上线后吞吐随线程数增加而下降。原因是预热不足、JIT 未稳定、循环被优化或测试时间太短。修正方式是使用 JMH,固定 fork、warmup、measurement 参数,并在接近生产的 CPU 配额和 JDK 版本上复测。
坑 4:盲目使用 LongAdder,忽略内存和读取语义
错误现象是写入性能提升了,但堆占用大幅增加,监控面板偶现读数回退或汇总成本升高。原因是每个 LongAdder 在竞争后可能维护多个 Cell,且 sum() 不是加锁快照。若需要强一致精确读,优先 AtomicLong;若只是分钟级统计,再选择 LongAdder,并控制 key 基数。
最终选型可以按一句话执行:精确读、低到中竞争选 AtomicLong;高写入、最终统计选 LongAdder;初始化和累加必须原子化时用 compute;只有简单低频场景才考虑 synchronized。 并发容器不是银弹,基准测试也不是只背数字,理解竞争点、读写比例、key 基数和内存成本,才能做出稳定的高并发计数设计。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。