【java基础】并发编程注意事项

写在最前,如果你发现任何我写的不对的,请在评论中指出。
默认JDK8

  好久不见,带回一份最近开发遭遇到的奇奇怪怪的并发bug,不废话,看文。

使用ThreadLocal记得remove

  近期的一次开发中,需要我自制一份log的stater打包到公司的私库上给一些正在开发的小项目用,这是背景。
  我们知道,ThreadLocal适用于变量在线程间隔离,而在方法与类之间共享。所以我的想法是当一个请求过来时,就把此次方法请求的链路json化缓存在ThreadLocal中会是个比较合适的做法,但就是这样导致日志记录的信息出现了错乱


  看简化案例吧。
  使用springboot创建一个web应用,使用ThreadLocal存放一个Integer的来暂且代替此次在线程中保存的日志信息,这个值初始值是null。先从ThreadLocal中获取一次值,然后把外部传入的参数设置到ThreadLocal中,模拟日志收集的流程,随后再获取一次值(这里是保存到数据库),最后输出两次获取到值和线程名称。

// 错误演示。好同学不要学🙅
private ThreadLocal<Integer> currentLog = ThreadLocal.withInitial(() -> null);

 @GetMapping("/wrong")
    public Map wrong(@RequestParam("log") Integer log){
        String before = Thread.currentThread().getName() + ":" + currentLog.get();
        currentLog.set(log);
        String after = Thread.currentThread().getName() + ":" + currentLog.get();
        // 汇总输出两次查询结果
        Map result = new HashMap();
        result.put("before", before);
        result.put("after", after);
        return result;
    }

  按理来说,ThreadLocal第一次获取到值始终是null,但就如同结果所示,会有上次的请求数据。可能你现在还是null,为了快速浮现此次问题,我建议你在配置文件内配置一下tomcat的参数,把工作线程池最大值划到1,这样始终是同一个线程在处理请求:

server.tomcat.max-threads=1

  再次请求问题就会复现。那么问题是什么呢?为什么会出现这个问题呢?很简单,我们的程序是运行在tomcat中的,执行程序的线程是tomcat的工作线程,而tomcat的工作线程是基于线程池的,线程池是重用固定的几个线程!而我又遗忘了currentLog.remove(),就导致了这个bug

针对该bug:

  1. 平时我们可能没有多线程编程的需求,因为代码内没有显示的用到多线程就没有线程安全问题。
  2. 使用类似ThreadLocal工具来存放一些数据时,需要特别注意在代码运行完毕后,要及时的清空设置的数据。

注意并发工具的使用场景

  使用了线程安全的并发工具,并不代表解决了所有的线程安全问题。没有充分了解并发工具的特性,无法发挥它的威力。
  其实这个问题大伙应该都是知道,ConcurrentHashMap只能保证提供的原子性读写操作是线程安全的,也就是说用到了ConcurrentHashMap不代表对它的多个操作之间的状态是一致的,如果你没法确保有没有其他线程在操作它,是需要加锁的(当然单线程又没必要用到ConcurrentHashMap了)
  其次,ConcurrentHashMap的size、isEmpty和containsValues等聚合方法是中间状态,并发的情况下,这些返回值只能参考,不能够拿来做流程判断!
  这里我要提的不是开发的bug,而是对ConcurrentHashMap的应用不够熟练写出来的代码执行效率低下,我会贴出我的版本跟大佬的版本,大佬的版本执行效率会提升10倍

// 目的是使用map来统计key出现次数的场景,这个应该很常见

//循环次数
private static int LOOP_COUNT = 10000000;
//线程数量
private static int THREAD_COUNT = 10;
//元素数量
private static int ITEM_COUNT = 1000;
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()
	//获得一个随机的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;
}

// 大佬版本
 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);
        
        return freqs.entrySet().stream()
                .collect(Collectors.toMap(
                        e -> e.getKey(),
                        e -> e.getValue().longValue()
                ));
    }

  大佬的代码优秀在两点:

  • 使用 ConcurrentHashMap 的原子性方法 computeIfAbsent 来做复合逻辑操作,判断 Key 是否存在 Value,如果不存在则把 Lambda 表达式运行后的结果放入 Map 作为 Value,也就是新创建一个 LongAdder 对象,最后返回 Value。
  • 由于 computeIfAbsent 方法返回的 Value 是 LongAdder,是一个线程安全的累加器, 因此可以直接调用其 increment 方法进行累加。

  这里再提一嘴CopyOnWriteArrayList,它目前在我眼里只适合读多写少的并发场景,如果用在读写均衡或者大量写操作的场景下,性能低下。

会不会死锁

  这是第二个bug,主要是业务逻辑涉及了多把锁,产生了死锁问题。是这样的:用户下单的操作需要锁定订单中多个商品的库存,拿到所有商品的锁之后进行下单扣减库存的操作,全部操作完之后再释放所有的锁。结果测试的时候,下单失败的概率很高,于是我又跑去复现测试的操作,排查之后是死锁的问题。
  原因也很简单,扣件商品库存的顺序不同,释放的锁时机不对,并发场景下多个线程可能相互持有部分商品的锁,死锁。
死锁现场
  这里不提供代码,提供日志,显然是出现了死锁,线程4在等待的锁被线程3持有,线程3等待的另一把锁被线程4持有。
  最后解决方案也很简单,为购物车中的商品排序,让所有线程都是按照一定顺序来执行的就没有问题了。

线程池的注意事项

  这个实际上读过《阿里巴巴开发守则》的同学都知道,不要去用exectors来显式的创建线程池。需要你按照需求来分配核心线程数、最大线程池数等,要为自定义线程池指定有意义的名称、要对线程池做监控。
  会出现的问题《阿里巴巴开发守则》解释的很清晰,我就在这里补充一下线程池的默认工作原则:

  1. 不会初始化corePoolSIze, 有任务来才创建
  2. 当核心线程满了不会立即扩容线程池,而是积压工作队列中
  3. 当工作队列满了后才扩容线程池, 一直到线程个数达到max
  4. 如果都满了还有任务进来,则会执行拒绝策略处理
  5. 当线程数大于核心线程数时,线程等待 keepAliveTime 后还是没有任务需要处理的话, 收缩线程到核心线程数

  实际上这样的工作顺序也并不是太好,我们可以去修改它的工作顺序,这里大佬给了我两个思路:
1. 重写队列当offer方法,造成队列已满当现象
2. 自定义的拒绝策略处理程序, 这时候才是真正插入队列

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值