Java常见编程错误:并发工具类库

为了⽅便开发者进⾏多线程编程,现代编程语⾔会提供各种并发⼯具类。但如果我们没有充分了解它们的使⽤场景、解决的问题,以及最佳实践的话,盲⽬使⽤就可能会导致⼀些坑,⼩则损失性能,⼤则⽆法确保多线程情况下业务逻辑的正确性。

⼀般⽽⾔并发⼯具包括同步器和容器两⼤类,业务代码中使⽤并发容器的情况会多⼀些。

线程重⽤导致BUG

场景:在⽣产上遇到⼀个诡异的问题,有时获取到的⽤⼾信息是别⼈的,使⽤ThreadLocal来缓存获取到的⽤⼾信息。

ThreadLocal适⽤于变量在线程间隔离,⽽在⽅法或类间共享的场景。如果⽤⼾信息的获取⽐较昂贵(⽐如从数据库查询⽤⼾信息),那么在ThreadLocal中缓存数据是⽐较合适的做法。

案例:使⽤Spring Boot创建⼀个Web应⽤程序,使⽤ThreadLocal存放⼀个Integer的值,来暂且代表需要在线程中保存的⽤⼾信息,这个值初始是null。在业务逻辑中,先从ThreadLocal获取⼀次值,然后把外部传⼊的参数设置到ThreadLocal中,来模拟从当前上下⽂获取到⽤⼾信息的逻辑,随后再获取⼀次值,最后输出两次获得的值和线程名称。

private ThreadLocal<Integer> 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;
}

按理说,在设置⽤⼾信息之前第⼀次获取的值始终应该是null,但我们要意识到,程序运⾏在Tomcat中, 执⾏程序的线程是Tomcat的⼯作线程,⽽Tomcat的⼯作线程是基于线程池的。

线程池会重⽤固定的⼏个线程,⼀旦线程重⽤,那么很可能⾸次从ThreadLocal获取的值是之前其他⽤⼾的请求遗留的值。这时,ThreadLocal中的⽤⼾信息就是其他⽤⼾的信息。

为了更快地重现这个问题,在配置⽂件中设置⼀下Tomcat的参数,把⼯作线程池最⼤线程数设置为1,这 样始终是同⼀个线程在处理请求:

server.tomcat.max-threads=1

运⾏程序后先让⽤⼾1来请求接⼝,可以看到第⼀和第⼆次获取到⽤⼾ID分别是null和1,符合预期:

 

随后⽤⼾2来请求接⼝,这次就出现了Bug,第⼀和第⼆次获取到⽤⼾ID分别是1和2,显然第⼀次获取到了⽤⼾1的信息,原因就是Tomcat的线程池重⽤了线程。从图中可以看到,两次请求的线程都是同⼀个线程: http-nio-8080-exec-1。

 

在写业务代码时,⾸先要理解代码会跑在什么线程上:

  • 在Tomcat这种Web服务器下跑的业务代码,本来就运⾏在⼀个多线程环境(否则接⼝也不可能⽀持这么⾼ 的并发),并不能认为没有显式开启多线程就不会有线程安全问题。
  • 因为线程的创建⽐较昂贵,所以Web服务器往往会使⽤线程池来处理请求,这就意味着线程会被重⽤。使⽤类似ThreadLocal⼯具来存放⼀些数据时,需要特别注意在代码运⾏完后,显式地去清空设置的数据。如果在代码中使⽤了⾃定义的线程池,也同样会遇到这个问题。

修正这段代码的⽅案是,在代码的finally代码块中,显式清除ThreadLocal中的数据。

@GetMapping("right")
public Map right(@RequestParam("userId") Integer userId) {
    String before = Thread.currentThread().getName() + ":" + currentUser.get();
    currentUser.set(userId);
    try {
        String after = Thread.currentThread().getName() + ":" + currentUser.get();
        Map result = new HashMap();
        result.put("before", before);
        result.put("after", after);
        return result;
    } finally {
        //在finally代码块中删除ThreadLocal中的数据,确保数据不串
        currentUser.remove();
    }
}

重新运⾏程序可以验证,再也不会出现第⼀次查询⽤⼾信息查询到之前⽤⼾请求的Bug:

 

ThreadLocal是利⽤独占资源的⽅式,来解决线程安全问题,如果确实需要有资源在线程之前共享,可能就需要⽤到线程安全的容器了。

使⽤了线程安全的并发⼯具,并不代表解决了所有线程安全问题

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

场景:

有⼀个含900个元素的Map,现在再补充 100个元素进去,这个补充操作由10个线程并发进⾏。开发⼈员误以为使⽤了ConcurrentHashMap就不会有线程安全问题,在每⼀个线程的代码逻辑中先通过size⽅法拿到当前元素数量,计算ConcurrentHashMap⽬前还需要补充多少元素,并在⽇志中输出了这个值,然后通过 putAll⽅法把缺少的元素添加进去。

为⽅便观察问题,输出了这个Map⼀开始和最后的元素个数。

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

    //帮助⽅法,⽤来获得⼀个指定元素数量模拟数据的ConcurrentHashMap
    private static 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));


    }

    public static void main(String[] args) throws InterruptedException {
        ConcurrentHashMap<String, Long> concurrentHashMap = getData(ITEM_COUNT - 100);
        //初始900个元素
        System.out.println(("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();
            System.out.println("gap size: " + gap);
            //补充元素
            concurrentHashMap.putAll(getData(gap));
        }));
        //等待所有任务完成
        forkJoinPool.shutdown();
        forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
        //最后元素个数会是1000吗?
        System.out.println("finish size: " + concurrentHashMap.size());
        System.out.println("OK");
    }

举⼀个形象的例⼦。ConcurrentHashMap就像是⼀个⼤篮⼦,现在这个篮⼦⾥有 900个桔⼦,我们期望把这个篮⼦装满1000个桔⼦,也就是再装100个桔⼦。有10个⼯⼈来⼲这件事⼉,⼤ 家先后到岗后会计算还需要补多少个桔⼦进去,最后把桔⼦装⼊篮⼦。 ConcurrentHashMap这个篮⼦本⾝,可以确保多个⼯⼈在装东西进去时,不会相互影响⼲扰,但⽆法确保 ⼯⼈A看到还需要装100个桔⼦但是还未装的时候,⼯⼈B就看不到篮⼦中的桔⼦数量。更值得注意的是,往这个篮⼦装100个桔⼦的操作不是原⼦性的,在别⼈看来可能会有⼀个瞬间篮⼦⾥有964个桔⼦,还需要 补36个桔⼦。

 

ConcurrentHashMap对外提供的⽅法或能⼒的限制:

  • 使⽤了ConcurrentHashMap,不代表对它的多个操作之间的状态是⼀致的,是没有其他线程在操作它 的,如果需要确保需要⼿动加锁。
  • 诸如size、isEmpty和containsValue等聚合⽅法,在并发情况下可能会反映ConcurrentHashMap的中间 状态。因此在并发情况下,这些⽅法的返回值只能⽤作参考,⽽不能⽤于流程控制。显然,利⽤size⽅法 计算差异值,是⼀个流程控制。
  • 诸如putAll这样的聚合⽅法也不能确保原⼦性,在putAll的过程中去获取数据可能会获取到部分数据。

修改⽅案:整段逻辑加锁即可

 

ConcurrentHashMap<String, Long> concurrentHashMap = getData(ITEM_COUNT - 100);
        System.out.println(("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();
                System.out.println("gap size: " + gap);
                concurrentHashMap.putAll(getData(gap));
            }
        }));
        forkJoinPool.shutdown();
        forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
        System.out.println("finish size: " + concurrentHashMap.size());
        System.out.println("OK");

 

重新调⽤接⼝,程序的⽇志输出结果符合预期:

没有充分了解并发⼯具的特性,从⽽⽆法发挥其威⼒

场景:

使⽤Map来统计Key出现次数

  1. 使⽤ConcurrentHashMap来统计,Key的范围是10。
  2. 使⽤最多10个并发,循环操作1000万次,每次操作累加随机的Key。
  3. 如果Key不存在的话,⾸次设置值为1

 

posted @ 2020-04-25 22:14  liekkas01  阅读(231)  评论(0编辑  收藏  举报