Java业务代码常见错误避坑指南-Day1

582 阅读6分钟

Java业务代码常见错误

  • 并发工具类库,线程就彻底安全了么?
  • 思维扩展

声明有的例子是我在工作中审核团队人员做代码复查时发现的,有的例子是看过的书籍或文章觉得比较典型的例子,不是所有的例子都是原创。

接下来进入正题,咱们依次分析上面业务代码中长犯的错误,从知识点介绍到代码分析,到思考总结

1、并发工具类库,线程就彻底安全了么?

并发是我们业务开发过程中无处不在的,既然存在并发场景,那么我就经常会用到,并发工具类库,而这并发工具类库细化出来可以分为并发同步器、并发容器两大类,我们经常使用更过的是容器类,例如:ConcurrentHashMap、CopyOnWriteArrayList、ThreadLocal等,接下来会针对于线程安全相关分析业务开发中可能会犯的错误,以及我们应该如何思考线程安全问题?

  • 没意识到线程重用导致:业务数据丢失或错乱的BUG
  1. 线程重用,使用ThreadLocal存储用户信息,导致了用户信息获取不到或者用户信息获取错乱 `

    private static final ThreadLocal currentUser = ThreadLocal.withInitial(() -> null);

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

` 首先为什么会出现获取不到,获取错乱等等问题呢? 因为使用的是Tomcat,Tomcat内部维护的是一个线程池,每次请求后都是通过NIO最后转发到内部的线程池中,由线程池中的线程来处理请求,那么势必就会出现线程的重用,那这个问题就势必存在

  • ThreadLocal是Thread中维护的(threadLocals),正因为它存在于线程中,所以实现了线程间的隔离,同时还能再方法或者类之间进行数据的共享。

那么如果修改这个问题,我们需要在使用完ThreadLocal后,清空掉

  1. 不理解什么是并发场景,以及线程重用:我会以我在工作过程中代码复查中发现的问题为例,简单描述一下场景,业务上有一个功能,页面上上传一部分数据,需要后端先校验数据的规范性,如果规范才能保存数据,不规范则提示哪块不规范。基于这个场景团队的开发人员实现了功能,伪代码如下:
//临时数据存储
private List<UserDO> tempData = new ArrayList<>();

@PostMapping("verify")
public  Object verify(@RequestParam("file")MultipartFile file) throws InterruptedException {
    // 获取文件,读取文件中的数据
    
    // 对数据进行校验
    
    // 如果检验成功,将数据存储到tempData中
    
    // 校验失败则返回校验结果

    return "校验的结果";
}

@GetMapping("save")
public  Object save(@RequestParam("file")MultipartFile file) throws InterruptedException {
    
    // 如果请求过来,判断tempData中是否存在数据
    
    // 如果存在,则直接保存tempdata数据到数据库
    
    // 然后清空数据库
    
    return "保存的結果";
}

这个问题非常大,因为只要存在并发使用,这个代码就会出现数据丢失错乱的问题,同时还使用的是线程不安全的集合框架,就是不安全上的不安全,接下来分析一下问题产生的原因,如何分析程序问题。

1. 共享变量的合理使用:如果我们使用了共享变量,也就是对于所有的线程均可见的对象,那么我们就需要考虑,对这部分数据进行CRUD的时候,是否会存在数据的错乱丢失等等问题

2. 线程池的理解:线程池是对于线程的重用,那么在对线程池使用ThreadLocal等线程私有的变量时,需要合理的回收掉数据,保证线程重用不存在垃圾数据

3. 集合框架使用的是否合理:并发场景下,需要考虑合理使用JUC一套线程安全的集合框架

4. 结合业务场景:有并发场景就考虑,有些场景就不存在并发场景,或者实现上也没有数据共享修改等等问题,那么自然也就不需要考虑了

  • 使用了ConcurrentHashMap就线程安全了? ConcurrentHashMap是高性能的线程安全的哈希表容器,其中使用的人可能会想,只要使用了ConurrentHashMap就线程安全了,然而并不是,某些使用不当的场景下,就会存在线程不安全的情况,因为ConcurrentHashMap只能提供原子性读写操作是线程安全的

下面的例子是有一个含 900 个元素的 Map,现在再补充 100 个元素进去,这个补充操作由 10 个线程并发进行。代码如下:

`

//线程个数
private static int THREAD_COUNT = 10;
//总元素数量
private static 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";
}

` 你会发现结果并不能如我们所愿,原因正是获取size()方法和后续的putAll()方法不是原子性操作,导致的出现了线程安全的问题。 现在我们回到ConcurrentHashMap,我们尤其需要注意ConcurrentHashMap堆外提供的方法或能力的限制(其实不止是ConcurrentHashMap还有其他的很多工具类,我们在使用之前都需要搞清楚它堆外提供的方法或能力

  1. 上述结论

    1. 使用了ConcurrentHashMap不代表对它的多个操作之间的状态是一致的,是没有其他县城操作它的,如果需要的话可以加锁,但是最好要控制锁的粒度尽可能要小
    2. 如size、isEmpty等聚合方法,在并发状态下,可能反应的是中间状态,而不是最终的状态
    3. putAll方法不保证原子性
  2. 没有充分发挥并发工具的特性的情况 简单描述一下场景,使用ConcurrentHashMap统计Key出现次数的场景,还是同上面的代码逻辑一样的实现方式,因为我们吸取了线程不安全,所以考虑加锁处理。代码如下:

    //循环次数 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提供的复合原子方法 computeIfAbsent,来替代加锁的逻辑,那可能会更好的提升性能。代码如下:

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);
                //利用computeIfAbsent()方法来实例化LongAdder,然后利用LongAdder来进行线程安全计数
                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())
            );
}

你可以简单分别测试一下性能,你会发现,性能上还是有很多提升的。 所以如何在保证线程安全之下,将程序的性能提高的最大化呢?这就需要你认真分析工具类的使用了,合理的利用方法的特性。

  • 你正确使用CopyOnWriteArrayList了么? 在Java中CopyOnWriteArrayList虽然是线程安全的ArrayList,但是我们也不是什么场景都不看,上来就用它,为什么这么说?因为它的实现原理是每次修改数据时都会复制一份数据出来,所以适合读多写少的无锁读取数据的场景
  1. 如果你使用了它,但是数据又频繁变动,如果数据量此刻还比较大或者未来数据存在爆发性增长的话,这性能会非常差,因为要复制同时内存可能也会出现问题,触发频繁的GC,导致性能越来越差

思维扩展

  • 首先针对于Spring系列的框架,我们需要清楚框架的原理,都清楚写接口,Controller等等,那么你清楚类是怎么注册的么(如何控制单例、原型,哪些场景需要切换等等),你清楚了这些,才能在开发中游刃有余
  • 哪些是共享变量,方法里面的变量是安全的么?(当然是安全的,因为Java虚拟机会为每一个方法创建栈帧,而栈帧是线程私有的,所以不存在线程安全)
  • 使用工具类之前,先了解使用的方法的特性,然后结合业务场景进行合理的使用,本帖子只是对几个例子进行详细介绍,其他的其实是同理的