先说结论
使用CountDownLatch
时,调用countDown()
方法释放锁的逻辑必须写在fiinally代码块中。如下图所示。
背景
最近在使用CountDownLatch
聚合统计数据时遇到一个很奇怪的问题,前端请求后端一个接口,接口一直没有响应,数据量也不是很大,正常情况下
1-2s后端一定会返回数据给前端了。这个现象在测试环境没看到,到了生产环境就出问题了,最后重新检查了代码,发现CountDownLatch
使用上存在问题。
场景分析
需求是这样的:对两项数据做数据统计,最后汇总,返回给前端。于是想到了使用CountDownLatch,阻塞主线程,开启两个子线程分别去做数据查询,子线程将查询结果放入一个共享的Map中,两个子线程释放共享锁(CountDownLatch底层实现共享锁实现)后,唤醒主线程,主线程执行后续逻辑。
实际的业务场景,抽象成下面的demo实现
package com.company.review;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
/**
* CountDownLatch使用踩坑——多线程聚合统计指标
* @Author: Alan
* @Date: 2022/11/26 23:02
*/
@Slf4j
public class CountDownLatchDemo {
//用于聚合所有的统计指标
private static Map<String,Object> map = new ConcurrentHashMap();
//创建计数器,这里需要统计2个指标
private static CountDownLatch countDownLatch = new CountDownLatch(2);
public static void main(String[] args) {
//记录开始时间
long startTime = System.currentTimeMillis();
Thread countUserThread = new Thread(() -> {
try {
log.info("正在统计新增用户数量");
Thread.sleep(3000);//任务执行需要3秒
map.put("userNumber", 100);//保存结果值
log.info("统计新增用户数量完毕");
countDownLatch.countDown();//标记已经完成一个任务
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread countOrderThread = new Thread(() -> {
try {
log.info("正在统计订单数量");
log.info("模拟统计订单数量时抛出异常{}",1/0);
map.put("countOrder", 20);//保存结果值
log.info("统计订单数量完毕");
countDownLatch.countDown();//标记已经完成一个任务
} catch (Exception e) {
e.printStackTrace();
}
});
//启动子线程执行任务
countUserThread.start();
countOrderThread.start();
try {
log.info("---主线程被阻塞----");
//主线程等待所有统计指标执行完毕
countDownLatch.await();
log.info("---主线程被唤醒----");
long endTime = System.currentTimeMillis();//记录结束时间
log.info("------统计指标全部完成--------");
log.info("统计结果为:" + map);
log.info("任务总执行时间为" + (endTime - startTime) + "ms");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
结果输出:
如上面的输出可以看出,线程0正常执行了,线程1抛出了一个异常,主线程被阻塞后就再没有被唤醒(这也就是背景中说的,前端一直没有接收到响应结果的原因,说白了就是主线程一直处于阻塞状态,没有往后执行)。
到底是什么原因导致主线程一直处于阻塞状态呢?这就得盘盘countdownLatch
的底层实现了。CountDownLatch
是AQS包下的一个工具类,其本质是一个共享锁,我们创建CountDownLatch
时在构造方法中传入的数字即是共享锁的个数,每次调用countDown()
方法时,会释放一个共享锁,直到所有锁释放时,会调用unpark方法唤醒正在同步队列中阻塞的线程。也就是说,如果共享锁一直没有释放完,那么就永远都不会调用unpark()方法唤醒同步队列中阻塞的线程。
回到上面的例子,线程0执行完业务逻辑,调用countDown()
方法正常释放锁,线程1执行业务逻辑发生异常,无法调用countDown()
方法正常释放锁,所以处于同步队列中的线程主线程一直没有被唤醒。
解决方式
解决方案其实很简单,正如上面分析的,线程1是因为发生了异常就没有执行countDown()
方法释放锁,如果不发生异常,其实它还是会释放锁的,所以我们将释放锁的逻辑放到一个finally代码块
中即可,这样无论如何,锁都会被释放,抛出的异常也可以被后续的程序所感知到。这样就不至于像我在背景介绍中所说的那样,前端一直无法获取到后端结果,后端一直待响应状态。
正确的使用姿势
package com.company.review;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
/**
* CountDownLatch使用踩坑——多线程聚合统计指标
* @Author: Alan
* @Date: 2022/11/26 23:02
*/
@Slf4j
public class CountDownLatchDemo {
//用于聚合所有的统计指标
private static Map<String,Object> map = new ConcurrentHashMap();
//创建计数器,这里需要统计2个指标
private static CountDownLatch countDownLatch = new CountDownLatch(2);
public static void main(String[] args) {
//记录开始时间
long startTime = System.currentTimeMillis();
Thread countUserThread = new Thread(() -> {
try {
log.info("正在统计新增用户数量");
Thread.sleep(3000);//任务执行需要3秒
map.put("userNumber", 100);//保存结果值
log.info("统计新增用户数量完毕");
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
countDownLatch.countDown();//标记已经完成一个任务
}
});
Thread countOrderThread = new Thread(() -> {
try {
log.info("正在统计订单数量");
log.info("模拟统计订单数量时抛出异常{}",1/0);
map.put("countOrder", 20);//保存结果值
log.info("统计订单数量完毕");
} catch (Exception e) {
e.printStackTrace();
}finally {
countDownLatch.countDown();//标记已经完成一个任务
}
});
//启动子线程执行任务
countUserThread.start();
countOrderThread.start();
try {
log.info("---主线程被阻塞----");
//主线程等待所有统计指标执行完毕
countDownLatch.await();
log.info("---主线程被唤醒----");
long endTime = System.currentTimeMillis();//记录结束时间
log.info("------统计指标全部完成--------");
log.info("统计结果为:" + map);
log.info("任务总执行时间为" + (endTime - startTime) + "ms");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
注意看每个线程都是在finally代码块中对countDown()
方法进行的调用,保证程序一定会执行释放锁的逻辑。