【避坑指南】ConcurrentHashMap 并发操作的致命陷阱

注:本文是笔者在学习【极客时间】业务开发常见错误过程中,整理记载的个人学习和思考笔记

在高并发编程中,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";
}

关键优化点

  1. 加锁范围精准:仅对「计算 gap + putAll」的复合操作加锁,避免锁范围过大影响性能;
  2. 增加边界判断:gap ≤ 0 时不再执行 putAll,避免无效操作;
  3. 锁对象选择:直接使用 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 提供的原子方法:如 putIfAbsentcomputemerge 等,减少手动加锁;
  • 高并发场景下,可考虑分段锁/分片处理,降低锁竞争。

4. 替代方案(按需选择)

  • 如果业务允许最终一致性,可使用 AtomicLong 统计数量,单独维护 Map;
  • JDK 1.8+ 可使用 LongAdder 替代 AtomicLong 统计计数,性能更高;
  • 极端高并发场景,可考虑使用 Disruptor 等无锁框架。

五、总结

ConcurrentHashMap 是线程安全的,但它保护的是「单个方法」,而非「业务逻辑」。使用时必须牢记:

  1. 复合操作必须手动保证原子性:加锁或使用内置原子方法;
  2. 避免认知误区:线程安全容器 ≠ 线程安全业务逻辑;
  3. 锁范围越小越好:在保证线程安全的前提下,尽可能减少锁竞争。

希望本文能帮你避开 ConcurrentHashMap 的常见陷阱,写出更健壮的高并发代码!

相关推荐
未来之窗软件服务2 小时前
自己写算法(十)js加密UUID保护解密——东方仙盟化神期
java·javascript·算法·代码加密·东方仙盟算法
lang201509282 小时前
08 ByteBuddy 加载策略全解析:从“隔离”到“注入”,如何避开循环依赖的深坑?
java·byte buddy
广州服务器托管2 小时前
WIN11中将控制面板固定到开始菜单的方法
运维·开发语言·windows·计算机网络·可信计算技术
X在敲AI代码2 小时前
D32次 第2题 因子化简
开发语言·c++
沙漏无语2 小时前
(一)TiDB简介
java·开发语言·tidb
Chan162 小时前
LeetCode 热题 100 | 链表
java·数据结构·spring boot·算法·leetcode·链表·java-ee
weixin_704266052 小时前
[特殊字符] Spring IOC/DI 核心知识点 CSDN 风格总结
java·后端·spring
袋鼠云数栈2 小时前
构建金融级数据防线:数栈 DataAPI 的全生命周期管理实践
java·大数据·数据库·人工智能·api
小杍随笔2 小时前
【Rust `lib.rs` 使用方法:模块组织、API导出与最佳实践】
服务器·开发语言·rust