Java避坑案例 - ConcurrentHashMap 的使用陷阱

news/2024/10/16 19:19:55 标签: java, Concurrent, HashMap

文章目录

  • Pre
  • 概述
  • 场景
  • 问题复现
    • 现象
  • 问题分析
  • 解决方案:加锁版本
    • Fix
    • 效果
  • 改进与更优方案:使用原子操作避免加锁
    • 使用 `computeIfAbsent` 等原子方法
    • 单线程执行填充操作
  • 总结

在这里插入图片描述

Pre

J.U.C Review - 并发容器集合解析

Java Review - 并发组件ConcurrentHashMap使用时的注意事项及源码分析


概述

JDK 1.5 后推出的 ConcurrentHashMap,是一个高性能的线程安全的哈希表容器。“线程安全”这四个字特别容易让人误解,因为 ConcurrentHashMap 只能保证提供的原子性读写操作是线程安全的.

我们通常误以为使用了 ConcurrentHashMap 后,就不需要担心线程安全问题。然而ConcurrentHashMap 只能保证单个操作(如 putget)的线程安全,无法确保多个操作(如 sizeputAll)之间的一致性。


场景

有一个含 900 个元素的 Map,现在再补充 100 个元素进去,这个补充操作由 10 个线程并发进行。


问题复现

java">private static int THREAD_COUNT = 10;
private static int ITEM_COUNT = 1000;

private ConcurrentHashMap<String, Long> getData(int count) {
    return LongStream.rangeClosed(1, count)
        .boxed()
        .collect(Collectors.toConcurrentMap(
            i -> UUID.randomUUID().toString(),
            i -> i,
            (o1, o2) -> o1,
            ConcurrentHashMap::new
        ));
}

@GetMapping("wrongVersion")
public String wrongVersion() throws InterruptedException {
    // 初始化900个元素
    ConcurrentHashMap<String, Long> concurrentHashMap = getData(ITEM_COUNT - 100);
    log.info("init size: {}", concurrentHashMap.size());

    // 使用 ForkJoinPool 进行并发操作
    ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
    forkJoinPool.execute(() -> IntStream.rangeClosed(1, 10).parallel().forEach(i -> {
        // 查询缺少的元素数量
        int gap = ITEM_COUNT - concurrentHashMap.size();
        log.info("gap size: {}", gap);

        // 补充缺少的元素
        concurrentHashMap.putAll(getData(gap));
    }));

    forkJoinPool.shutdown();
    forkJoinPool.awaitTermination(1, TimeUnit.HOURS);

    // 输出最终大小
    log.info("finish size: {}", concurrentHashMap.size());
    return "OK";
}

开发人员误以为使用了 ConcurrentHashMap 就不会有线程安全问题,于是不加思索地写出了下面的代码:在每一个线程的代码逻辑中先通过 size 方法拿到当前元素数量,计算 ConcurrentHashMap 目前还需要补充多少元素,并在日志中输出了这个值,然后通过
putAll 方法把缺少的元素添加进去。

现象

  1. 初始大小:900
  2. 多个线程执行时计算到的差值(gap)不一致,有的线程甚至发现了负值。
  3. 最终 ConcurrentHashMap 的大小不是预期的 1000。

问题分析

  1. ConcurrentHashMap 的误用
    虽然 ConcurrentHashMap 能保证基本的线程安全,但无法假设多个方法调用之间的一致性。例如,当一个线程通过 size() 获取元素个数时,另一个线程可能已经向 Map 中插入了新元素。这会导致不同线程看到的数据不一致。

  2. 非原子性操作的问题

    • size() 返回的是 ConcurrentHashMap 的瞬时快照,但在并发情况下,它可能只是一个中间状态,不能用于精确控制逻辑。
    • putAll() 并不是原子操作,如果在它执行的过程中其他线程也在修改 Map,就会出现预期外的结果。

解决方案:加锁版本

为了解决上述问题,我们可以在 补充元素的逻辑 上加锁,确保这部分逻辑的原子性。

Fix

java">@GetMapping("rightVersion")
public String rightVersion() throws InterruptedException {
    // 初始化900个元素
    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: {}", gap);

            // 补充缺少的元素
            concurrentHashMap.putAll(getData(gap));
        }
    }));

    forkJoinPool.shutdown();
    forkJoinPool.awaitTermination(1, TimeUnit.HOURS);

    log.info("finish size: {}", concurrentHashMap.size());
    return "OK";
}

效果

  1. 只有一个线程会发现需要补充 100 个元素,其他线程看到的 gap 为 0。
  2. 最终 ConcurrentHashMap 的大小为 1000,符合预期。

改进与更优方案:使用原子操作避免加锁

虽然加锁能解决问题,但这样会牺牲并发性能。我们可以通过一些更优的方案来避免锁的使用:

使用 computeIfAbsent 等原子方法

ConcurrentHashMap 提供了如 computeIfAbsentmerge 等原子操作,我们可以利用这些方法简化并发逻辑。


单线程执行填充操作

由于填充操作涉及多个步骤,不易实现原子性。可以考虑将所有填充操作交给一个线程来完成,从而避免并发问题。

java">@GetMapping("betterVersion")
public String betterVersion() throws InterruptedException {
    ConcurrentHashMap<String, Long> concurrentHashMap = getData(ITEM_COUNT - 100);
    log.info("init size: {}", concurrentHashMap.size());

    ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
    // 使用单线程来执行填充操作,避免并发问题
    forkJoinPool.submit(() -> {
        int gap = ITEM_COUNT - concurrentHashMap.size();
        log.info("gap size: {}", gap);
        concurrentHashMap.putAll(getData(gap));
    }).get(); // 等待任务完成

    forkJoinPool.shutdown();
    forkJoinPool.awaitTermination(1, TimeUnit.HOURS);

    log.info("finish size: {}", concurrentHashMap.size());
    return "OK";
}

总结

使用并发工具类的注意事项

  1. 多操作间状态一致性问题
    ConcurrentHashMap 能保证单次操作的线程安全,但无法保证多个操作之间的一致性。

  2. 避免使用快照方法控制流程
    在并发情况下,size() 等方法只能用作参考,不能用于控制流程逻辑。

  3. 尽量使用原子操作
    使用 computeIfAbsentmerge 等方法,可以在一定程度上避免显式加锁。

  4. 不要滥用锁
    在高并发场景下,频繁加锁会严重影响性能,合理设计并发逻辑才能充分发挥 ConcurrentHashMap 的性能优势。

教训: 误以为使用了并发工具就可以解决一切线程安全问题,期望通过把线程不安全的类替换为线程安全的类来一键解决问题。比如,认为使用了 ConcurrentHashMap 就可以解决线程安全问题,没对复合逻辑加锁导致业务逻辑错误。如果希望在一整段业务逻辑中,对容器的操作都保持整体一致性的话,需要加锁处理


在这里插入图片描述


http://www.niftyadmin.cn/n/5708413.html

相关文章

java-重要知识01

1. 各个语言的擅长点 C&#xff1a;几乎其他语言的全部功能 速度快 C 速度快 JAVA 大型web开发&#xff0c;手机安卓 本来有桌面开发&#xff0c;后来被C#挖人 GO 大型web开发 C# 中小型web&#xff0c;桌面程序开发 Python 数学处理&#xff0c;中小型网站 性…

【linux】Microsoft Edge 的 Bookmarks 文件存储位置

在 Linux 系统中&#xff0c;Microsoft Edge 的书签&#xff08;Bookmarks&#xff09;文件存储在用户的配置目录下。具体路径通常如下&#xff1a; ~/.config/microsoft-edge/Default/Bookmarks说明&#xff1a; 路径解释&#xff1a; ~ 表示当前用户的主目录。.config 是一个…

【开源】第三期:数字货币程序化交易终端开源

关于初衷&#xff1a; 这篇文章&#xff0c;其实应该在六年前发出来&#xff0c;但是受制于各种杂事和生活琐事&#xff0c;一直拖到现在&#xff0c;想必有朋友看到在"终端"那期里&#xff0c;聊到的数字货币交易的实践&#xff0c;那个时候遍地都是数字货币交易所&…

如何查看GB28181流媒体平台LiveGBS对GB28181视频数据的统计信息

LiveGBS流媒体平台GB/T28181常见问题-如何快速查看推流上来的摄像头并停止摄像头推流&#xff1f; 1、负载信息2、负载信息说明3、会话列表查看3.1、会话列表 4、停止会话5、搭建GB28181视频直播平台 1、负载信息 实时展示直播、回放、播放、录像、H265、级联等使用数目 2、负…

00 springboot项目创建

我们创建SpringBoot项目有两种方式: Spring Initializr spring initerzie 方式创建: 启动类, 依赖 生成,但是需要网络maven的方式 maven方式创建: 启动类, 依赖, 这些都需要手动编写,但是不需要网络 springboot系列&#xff0c;最近持续更新中&#xff0c;如需要请关注 如果…

网络安全 IP地址防泄漏指南

IP地址作为每个上网人的“门牌标识号”&#xff0c;如果产生泄露&#xff0c;可能会导致个人行踪曝光、数据被窃取甚至遭受网络攻击&#xff0c;要防止IP地址不被窃取&#xff0c;我们可以尝试以下方法&#xff1a; 利用专用网络加强隐私保护 通过加密在公共网络上创建一条安全…

Springboot 整合 Java DL4J 实现农产品质量检测系统

&#x1f9d1; 博主简介&#xff1a;历代文学网&#xff08;PC端可以访问&#xff1a;https://literature.sinhy.com/#/literature?__c1000&#xff0c;移动端可微信小程序搜索“历代文学”&#xff09;总架构师&#xff0c;15年工作经验&#xff0c;精通Java编程&#xff0c;…

SpringBoot日常:封装redission starter组件

文章目录 逻辑实现POM.xmlRedissionConfigRedissionPropertiesRedissionUtilsspring.factories 功能测试application.yml配置POM.xmlTestController运行测试 本章内容主要介绍如何通过封装相关的redission连接配置和工具类&#xff0c;最终完成一个通用的redission starter。并…