注:本文是笔者在学习【极客时间】业务开发常见错误过程中,整理记载的个人学习和思考笔记
在高并发编程中,ConcurrentHashMap 因线程安全特性被广泛使用,但很多开发者误以为「用了 ConcurrentHashMap 就万事大吉」,却忽略了复合操作的原子性问题。本文通过一个真实的代码案例,拆解 ConcurrentHashMap 的常见误用场景,并给出正确的解决方案。
一、案例场景:并发填充 ConcurrentHashMap
先看这段 Spring Boot 控制器代码,核心逻辑是:初始化一个包含 900 个元素的 ConcurrentHashMap,然后启动 10 个线程并发补充元素,目标是让最终元素总数达到 1000。
1. 问题代码(wrong 接口)
java
@GetMapping("wrong/concurrenthashmap")
public String wrong() throws InterruptedException {
// 初始化900个元素
ConcurrentHashMap<String, Long> concurrentHashMap = getData(ITEM_COUNT - 100);
log.info("init size:{}", concurrentHashMap.size());
ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
// 10个线程并发补充元素
forkJoinPool.execute(() -> IntStream.rangeClosed(1, 10).parallel().forEach(i -> {
// 致命问题:计算缺口 + 补充元素 非原子操作
int gap = ITEM_COUNT - concurrentHashMap.size();
log.info("线程{} - gap size:{}", Thread.currentThread().getName(), gap);
concurrentHashMap.putAll(getData(gap));
}));
forkJoinPool.shutdown();
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
log.info("finish size:{}", concurrentHashMap.size()); // 结果远大于1000
return "OK";
}
2. 运行结果
预期最终 size=1000,但实际运行结果往往是 1500+、1800+ 甚至更高,完全超出预期。
二、问题根源:复合操作缺乏原子性
ConcurrentHashMap 仅保证单个方法 (如 put、get、size)的线程安全,但多个方法组合的复合操作不具备原子性。这个案例中,核心问题出在两步操作:
1. 非原子的「计算缺口 + 补充元素」
java
// 步骤1:计算需要补充的元素数量
int gap = ITEM_COUNT - concurrentHashMap.size();
// 步骤2:补充gap个元素
concurrentHashMap.putAll(getData(gap));
- 线程 A 执行
gap = 1000 - 900 = 100,准备补充 100 个元素; - 线程 B 同时执行
gap = 1000 - 900 = 100,也准备补充 100 个元素; - 线程 A 先完成 putAll,Map 大小变为 1000;
- 线程 B 仍按之前计算的 gap=100 执行 putAll,最终 Map 大小变为 1100;
- 10 个线程重复此过程,最终 size 远大于 1000。
2. 对 ConcurrentHashMap 的认知误区
很多开发者误以为:
「ConcurrentHashMap 是线程安全的,所以所有操作都安全」
但事实是:
- ConcurrentHashMap 仅保证单个方法调用的原子性(如 put 时不会出现数据覆盖);
- 跨方法的复合操作(如「读 size → 计算 → 写数据」)必须手动保证原子性。
三、正确解决方案(correct 接口)
核心思路:给「计算缺口 + 补充元素」的复合操作加锁,确保同一时间只有一个线程执行该逻辑。
java
@GetMapping("correct/concurrenthashmap")
public String correct() throws InterruptedException {
ConcurrentHashMap<String, Long> concurrentHashMap = getData(ITEM_COUNT - 100);
log.info("init size:{}", concurrentHashMap.size());
ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
forkJoinPool.execute(() -> IntStream.rangeClosed(1, 10).parallel().forEach(i -> {
// 加锁保证复合操作的原子性
synchronized (concurrentHashMap) {
int gap = ITEM_COUNT - concurrentHashMap.size();
log.info("线程{} - gap size:{}", Thread.currentThread().getName(), gap);
// 增加边界判断:避免gap为负数时添加空数据
if (gap > 0) {
concurrentHashMap.putAll(getData(gap));
}
}
}));
forkJoinPool.shutdown();
forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
log.info("finish size:{}", concurrentHashMap.size()); // 稳定为1000
return "OK";
}
关键优化点
- 加锁范围精准:仅对「计算 gap + putAll」的复合操作加锁,避免锁范围过大影响性能;
- 增加边界判断:gap ≤ 0 时不再执行 putAll,避免无效操作;
- 锁对象选择:直接使用 ConcurrentHashMap 实例作为锁对象(也可自定义专用锁)。
四、ConcurrentHashMap 避坑指南
1. 核心原则:区分「单个操作」和「复合操作」
| 操作类型 | 是否线程安全 | 示例 |
|---|---|---|
| 单个方法调用 | 安全 | map.put(k, v)、map.get(k) |
| 复合操作 | 不安全 | 先 get 再 put、先 size 再 put |
2. 常见误用场景 & 解决方案
| 误用场景 | 错误原因 | 正确方案 |
|---|---|---|
| 先判断 key 是否存在,再 put 数据 | 判断和 put 非原子操作 | 使用 putIfAbsent(k, v) 方法 |
| 先读 size,再根据 size 写数据 | 读和写非原子操作 | 加锁(synchronized/Lock) |
| 循环遍历 Map 同时修改元素 | 遍历和修改竞态条件 | 使用迭代器的原子操作,或加锁遍历 |
| 多个 put 操作需保证整体成功 | 单个 put 安全但整体不安全 | 加锁或使用事务(如数据库兜底) |
3. 性能优化建议
- 加锁时最小化锁范围:仅锁定复合操作的核心逻辑,避免整个方法加锁;
- 优先使用 ConcurrentHashMap 提供的原子方法:如
putIfAbsent、compute、merge等,减少手动加锁; - 高并发场景下,可考虑分段锁/分片处理,降低锁竞争。
4. 替代方案(按需选择)
- 如果业务允许最终一致性,可使用 AtomicLong 统计数量,单独维护 Map;
- JDK 1.8+ 可使用
LongAdder替代 AtomicLong 统计计数,性能更高; - 极端高并发场景,可考虑使用 Disruptor 等无锁框架。
五、总结
ConcurrentHashMap 是线程安全的,但它保护的是「单个方法」,而非「业务逻辑」。使用时必须牢记:
- 复合操作必须手动保证原子性:加锁或使用内置原子方法;
- 避免认知误区:线程安全容器 ≠ 线程安全业务逻辑;
- 锁范围越小越好:在保证线程安全的前提下,尽可能减少锁竞争。
希望本文能帮你避开 ConcurrentHashMap 的常见陷阱,写出更健壮的高并发代码!