【java基础】Java开发中使用并发工具的常见错误

若你发现博客内容有误,请及时在评论中指出

起头,不要盲目的使用并发工具类,小则损失性能,大则导致业务逻辑错误

1. 及时清理 ThreadLocal 绑定的数据

  ThreadLocal 是适用于变量在线程间隔离,而在类或者方法间不隔离的工具类,一般可以用在某种获取比较昂贵的数据上。
  来看一个具体的案例。
  使用 SpringBoot 构建一个 web 应用,我们使用 ThreadLocal 存储一个 Integer 值作为用户信息,首先我先从外部获取一次用户信息的值,然后再把获取到的值存入到 ThreadLocal 中去,来模拟从当前上下文获取到用户信息的逻辑,随后再获取一次值,最后输出两次获得的值和线程名称。
  为了更快的重现问题,我们还需要把 tomcat 的配置线程数调整为 1:

server.tomcat.max-threads=1
@RequestMapping("/wrong")
public Map<String, String> wrong(@RequestParam("userId") Integer userId) {
    //设置用户信息之前先查询一次ThreadLocal中的用户信息
    String before = Thread.currentThread().getName() + ":" + CURRENT_USER.get();
    //设置用户信息到ThreadLocal
    CURRENT_USER.set(userId);
    //设置用户信息之后再查询一次ThreadLocal中的用户信息
    String after = Thread.currentThread().getName() + ":" + CURRENT_USER.get();
    //汇总输出两次查询结果
    Map<String, String> map = new HashMap<>();
    map.put("before", before);
    map.put("after", after);
    return map;
}

  按理来说,第一次值应该始终为 null,但是经过测试,第一次总是会携带上一次的 userId 的值,很明显不符合预期。
  那么问题时怎么出现的呢?有使用 ThreadLocal 比较熟练的同学就会指出是我没有在使用完 ThreadLocal 后做remove操作,诚然,这也是比较关键的一点。但是这里最大的问题是:我们需要思考我们的程序是跑在一个怎样的环境上? SpringBoot 为我们配置了内嵌的 Tomcat, 而 Tomcat 实际上就是一个线程池,所以每次来调用 handler 的时候,线程是会被重复利用的,也就会出现上面的问题。
  在 Tomcat 这种 Web 服务器下跑的业务代码,本来就运行在一个多线程环境(否则接口也不可能支持这么高的并发),并不能认为没有显式开启多线程就不会有线程安全问题。
  了解到这些问题后,代码就变得好修改起来:

@RequestMapping("/right")
public Map<String, String> right(@RequestParam("userId") Integer userId) {
     try {
         //设置用户信息之前先查询一次ThreadLocal中的用户信息
         String before = Thread.currentThread().getName() + ":" + CURRENT_USER.get();
         //设置用户信息到ThreadLocal
         CURRENT_USER.set(userId);
         //设置用户信息之后再查询一次ThreadLocal中的用户信息
         String after = Thread.currentThread().getName() + ":" + CURRENT_USER.get();
         //汇总输出两次查询结果
         Map<String, String> map = new HashMap<>();
         map.put("before", before);
         map.put("after", after);
         return map;
     } finally {
         CURRENT_USER.remove();
     }
 }

2. 使用了线程安全工具并不代表解决所有线程安全问题,

  ConcurrentHashMap 是一个高性能的线程安全的容器,但并不代表你使用了它就能确切的保证线程的安全性。
  来看一个案例。
  默认给出一个 900 个元素的 Map, 现在需要再补充100个元素进去,这个过程由 10 个线程并发进行。在每一个线程的代码逻辑都是 size() 获取当前元素数量,然后补充对应的元素个数。


//线程个数
private static final int THREAD_COUNT = 10;
//总元素数量
private static final int ITEM_COUNT = 1000;

//帮助方法,用来获得一个指定元素数量模拟数据的ConcurrentHashMap
private ConcurrentHashMap<String, Long> getData(int count) {
    return LongStream.rangeClosed(1, count)
            .boxed()
            .collect(Collectors.toConcurrentMap(i -> UUID.randomUUID().toString(), Function.identity(),
                    (o1, o2) -> o1, ConcurrentHashMap::new));
}

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

    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);
    //最后元素个数会是1000吗?
    log.info("finish size:{}", concurrentHashMap.size());
    return "OK";
}

  然后查看我们的日志记录,很明显的发现不对,最后竟然读出复数来了。
在这里插入图片描述
  ConcurrentHashMap 本身是有能力保证多个线程间工作不会相互影响,但是如果是根据诸如 size、isEmpty 和 containsValue 等聚合方法,在并发情况下可能会反映 ConcurrentHashMap 的中间状态,不能作为流程判断,只能是参考。使用了 ConcurrentHashMap,不代表对它的多个操作之间的状态是一致的,是没有其他线程在操作它的,如果需要确保需要手动加锁。


@GetMapping("right")
public String right() 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 -> {
        //下面的这段复合逻辑需要锁一下这个ConcurrentHashMap
        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";
}

3.不了解并发工具的特性

  你可能觉得这样还不如直接用 HashMap 呢, 上面的内容只是解释一些 ConcurrentHashMap 的一些不适当使用方式。想要正确的使用 ConcurrentHashMap 就需要同学们掌握它的 api。
  那么来看案例
  在程序中为某一个 key 计数是非常常见的场景,那么:

  • 使用 ConcurrentHashMap 来统计, Key 的范围是10
  • 使用最多 10 个并发,循环操作 1000 万次,每次操作累加随机的 key
  • 如果 key 不存在,首次设置值就为 1

  代码如下:

// 错误示范

//循环次数
private static int LOOP_COUNT = 10000000;
//线程数量
private static int THREAD_COUNT = 10;
//元素数量
private static int ITEM_COUNT = 10;
private Map<String, Long> normaluse() throws InterruptedException {
    ConcurrentHashMap<String, Long> freqs = new ConcurrentHashMap<>(ITEM_COUNT);
    ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
    forkJoinPool.execute(() -> IntStream.rangeClosed(1, LOOP_COUNT).parallel().forEach(i -> {
        //获得一个随机的Key
        String key = "item" + ThreadLocalRandom.current().nextInt(ITEM_COUNT);
                synchronized (freqs) {      
                    if (freqs.containsKey(key)) {
                        //Key存在则+1
                        freqs.put(key, freqs.get(key) + 1);
                    } else {
                        //Key不存在则初始化为1
                        freqs.put(key, 1L);
                    }
                }
            }
    ));
    forkJoinPool.shutdown();
    forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
    return freqs;
}

  虽然我们吸取了上面的教训加了锁,虽然说这段代码在功能是没有任何问题的,但是无法充分发挥 ConcurrentHashMap 的威力,可以改进为以下代码:


private Map<String, Long> gooduse() throws InterruptedException {
    ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>(ITEM_COUNT);
    ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
    forkJoinPool.execute(() -> IntStream.rangeClosed(1, LOOP_COUNT).parallel().forEach(i -> {
        		String key = "item" + ThreadLocalRandom.current().nextInt(ITEM_COUNT);
                freqs.computeIfAbsent(key, k -> new LongAdder()).increment();
            }
    ));
    forkJoinPool.shutdown();
    forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
    //因为我们的Value是LongAdder而不是Long,所以需要做一次转换才能返回
    return freqs.entrySet().stream()
            .collect(Collectors.toMap(
                    e -> e.getKey(),
                    e -> e.getValue().longValue())
            );
}

  这段代码的巧妙之处就在于使用 ConcurrentHashMap 的原子性方法 computelfAbsent 来做符合逻辑操作,判断 key 是否存在 value, 不存在就新创建 LongAdder 对象, 该对象是一个线程安全的累加器,因此可以直接调用 increment 方法进行累加。
  这样不仅让代码更加简洁,并且可以对比以下运行性能,以我机子来测试,性能提升了10倍,同学们可以在自己的电脑上进行测试。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值