找回密码
 立即注册
首页 业界区 业界 CountdownLatch使用不当导致的线程卡死

CountdownLatch使用不当导致的线程卡死

魄柜 2025-6-6 14:09:17
问题现象

今天有其他项目遇到了一个问题,找了很久没有找到原因,于是找到我帮忙看下。他们使用了Spring Scheduling开发了一个定时任务,然后每天早上的8点会执行一次。在DEV环境的时候是正常运行并且测试通过的,但是在发布到UAT环境之后发现了一个问题,这个定时任务只会在服务发布后触发一次,然后就再也不会调度了。
排查过程

基本检查

首先是对线上环境的配置进行了检查,首先是定时任务的配置是否有问题。

  • 是否开启了定时任务
  1. @EnableScheduling
  2. @SpringBootApplication(exclude = {
  3.         RedisAutoConfiguration.class, RedissonAutoConfiguration.class,
  4.         SecurityAutoConfiguration.class,
  5.         ManagementWebSecurityAutoConfiguration.class})
  6. public class FrontProviderApplication {
  7.    
  8. }
复制代码
首先确定是开启了调度任务的(实际上这个肯定是没问题的,因为在这个问题中,定时任务是触发了的,只不过只触发了一次)

  • 调度配置是否正确,是不是配置成了一次性任务。
    1. @Scheduled(cron = "30 10 8 * * ?")
    2.     public void setCollectData() throws InterruptedException {
    3.         log.info("history=开始处理历史数据");
    4.         historyCollectComponent.setCollectData();
    5.         log.info("history=处理历史数据结束");
    6. }
    复制代码
    1.png

    经过检查也没有发现问题。
日志排查

配置如果没有问题的话,只能通过日志看下"案发现场"有没有什么线索。不出意料,日志自然也是平平无奇。也没有发现有明显的异常。
日志中出现了调度任务一开始就打印的日志,但是没有结束的日志。中间也没有发现异常。
  1. history=开始处理历史数据
复制代码
复现

排查到这儿,可以确认的是调度任务触发了,但是一直没有结束,可能是线程一直在等待。于是我准备复现下这个问题,所以把定时任务的频率调整为5分钟执行一次。这个的前提是我检查了定时任务的逻辑,也跟项目的开发同事确认了。这个定时任务是幂等的。所以我暂时调整为5分钟一次,并不会有什么问题
调整为5分钟一次后,发现定时任务竟然又正常了,5分钟一次有序进行。那这就有点伤脑筋了。这个问题暂时无法复现。
所以比较简单的办法就是,在今晚做一次服务重启,然后等待明天早上的8点,利用arthas监控一下定时任务方法的执行,看下是什么问题。
代码逻辑筛查

这么奇怪的问题,要等到第二天早上才能找出原因,着实有点难受。所以我带着好奇心,耐着性子捋了下整体的代码逻辑。这个定时任务的逻辑还是比较复杂的。但是其中核心的逻辑框架大概如下:
  1. @Component
  2. public class SpringCountDownLatchScheduler {
  3.     @Scheduled(cron = "0/15 * * * * ? ")
  4.     public void setCollectData() throws InterruptedException {
  5.         System.out.println("history=开始处理历史数据");
  6.         serviceA();
  7.         System.out.println("history=处理历史数据结束");
  8.     }
  9.     private void serviceA() throws InterruptedException {
  10.         CountDownLatch countDownLatch = new CountDownLatch(2);
  11.         serviceB(countDownLatch);
  12.         countDownLatch.await();
  13.     }
  14.     private void serviceB(CountDownLatch countDownLatch) {
  15.         new Thread() {
  16.             @Override
  17.             public void run() {
  18.                 System.out.println("子线程" + Thread.currentThread().getName() + "正在执行");
  19.                 try {
  20.                     Thread.sleep(3000);
  21.                 } catch (InterruptedException e) {
  22.                     e.printStackTrace();
  23.                 }
  24.                 System.out.println("子线程" + Thread.currentThread().getName() + "执行完毕");
  25.                 countDownLatch.countDown();
  26.             }
  27.         }.start();
  28.         new Thread() {
  29.             @Override
  30.             public void run() {
  31.                 System.out.println("子线程" + Thread.currentThread().getName() + "正在执行");
  32.                 try {
  33.                     Thread.sleep(3000);
  34.                 } catch (InterruptedException e) {
  35.                     e.printStackTrace();
  36.                 }
  37.                 System.out.println("子线程" + Thread.currentThread().getName() + "执行完毕");
  38.                 countDownLatch.countDown();
  39.             }
  40.         }.start();
  41.     }
  42. }
复制代码
在serviceA()方法中使用了CountDownLatch,计数器是2。然后启动两个子线程完成一系列逻辑,然后通过CountDownLatch的await()方法等待两个线程执行完,然后继续执行后续的逻辑。整个定时任务15s执行一次。
不知道大家有没有发现这个结构的问题,我也是愣了一会儿才发现不对劲。如果子线程在countDown()之前发生了异常呢?那主线程不就一直在等待了吗?于是我快速验证了一下:
  1. @Component
  2. public class SpringCountDownLatchScheduler {
  3.     @Scheduled(cron = "0/15 * * * * ? ")
  4.     public void setCollectData() throws InterruptedException {
  5.         System.out.println("history=开始处理历史数据");
  6.         serviceA();
  7.         System.out.println("history=处理历史数据结束");
  8.     }
  9.     private void serviceA() throws InterruptedException {
  10.         CountDownLatch countDownLatch = new CountDownLatch(2);
  11.         serviceB(countDownLatch);
  12.         countDownLatch.await();
  13.     }
  14.     private void serviceB(CountDownLatch countDownLatch) {
  15.         new Thread() {
  16.             @Override
  17.             public void run() {
  18.                 System.out.println("子线程" + Thread.currentThread().getName() + "正在执行");
  19.                 try {
  20.                     Thread.sleep(3000);
  21.                 } catch (InterruptedException e) {
  22.                     e.printStackTrace();
  23.                 }
  24.                 if (Boolean.TRUE) {
  25.                     throw new RuntimeException("我是异常");
  26.                 }
  27.                 System.out.println("子线程" + Thread.currentThread().getName() + "执行完毕");
  28.                 countDownLatch.countDown();
  29.             }
  30.         }.start();
  31.         new Thread() {
  32.             @Override
  33.             public void run() {
  34.                 System.out.println("子线程" + Thread.currentThread().getName() + "正在执行");
  35.                 try {
  36.                     Thread.sleep(3000);
  37.                 } catch (InterruptedException e) {
  38.                     e.printStackTrace();
  39.                 }
  40.                 System.out.println("子线程" + Thread.currentThread().getName() + "执行完毕");
  41.                 countDownLatch.countDown();
  42.             }
  43.         }.start();
  44.     }
  45. }
复制代码
在第一个线程countDown之前,主动抛一个RuntimeException来模拟第一个线程有异常的情况。
果然跟我们预期的一样,定时任务只触发了一次之后,就卡死了。
2.png

到这里,已经找到了这个问题的根本原因,应该就是某个子线程异常了,导致主线程一直处在等待状态。那么为啥日志里看不到呢?除非代码里吞了异常,我检查了,发现并没有。
我带着这个疑问,又去日志里逐行看了一遍,发现有一行非常不起眼的Error。
  1. 2024-08-27 08:11:00.031 ERROR default 81232 [ForkJoinPool.commonPool-worker-62] o.h.engine.jdbc.spi.SqlExceptionHelper 142  142 : HikariPool-1 - Connection is not available, request timed out after 30000ms.
复制代码
这下水落石出了。原来是因为数据库超时了。但还是有个问题,为什么我调低作业频率为5分钟的时候为什么没有复现。这个我跟项目组的技术确认后发现,是因为他们早上8点左右的时候正是数据库负载最高的时候,大数据平台会在8点左右同步数据过来。而我复现的时候是在下午,负载并不高。所以并没有出现因为数据库超时导致定时任务异常的现象。
问题解决

知道原因之后,怎么解决就很简单了,有很多办法

  • 优化定时任务的逻辑代码,countdown()一定要加异常处理,或者主线程await要加上超时时间。
  • 调整定时任务的时间,与大数据平台的推送峰口错开。
  • 提高数据库资源配置。
优化后的代码结构如下:
  1. @Component
  2. public class SpringCountDownLatchScheduler {
  3.     @Scheduled(cron = "0/15 * * * * ? ")
  4.     public void setCollectData() throws InterruptedException {
  5.         System.out.println("history=开始处理历史数据");
  6.         serviceA();
  7.         System.out.println("history=处理历史数据结束");
  8.     }
  9.     private void serviceA() throws InterruptedException {
  10.         CountDownLatch countDownLatch = new CountDownLatch(2);
  11.         serviceB(countDownLatch);
  12.         countDownLatch.await();
  13.     }
  14.     private void serviceB(CountDownLatch countDownLatch) {
  15.         new Thread() {
  16.             @Override
  17.             public void run() {
  18.                 System.out.println("子线程" + Thread.currentThread().getName() + "正在执行");
  19.                 try {
  20.                     Thread.sleep(3000);
  21.                     if (Boolean.TRUE) {
  22.                         throw new RuntimeException("我是异常");
  23.                     }
  24.                     System.out.println("子线程" + Thread.currentThread().getName() + "执行完毕");
  25.                 } catch (InterruptedException e) {
  26.                     e.printStackTrace();
  27.                 } finally {
  28.                     countDownLatch.countDown();
  29.                 }
  30.             }
  31.         }.start();
  32.         new Thread() {
  33.             @Override
  34.             public void run() {
  35.                 System.out.println("子线程" + Thread.currentThread().getName() + "正在执行");
  36.                 try {
  37.                     Thread.sleep(3000);
  38.                     System.out.println("子线程" + Thread.currentThread().getName() + "执行完毕");
  39.                 } catch (InterruptedException e) {
  40.                     e.printStackTrace();
  41.                 } finally {
  42.                     countDownLatch.countDown();
  43.                 }
  44.             }
  45.         }.start();
  46.     }
  47. }
复制代码
可以看到,虽然定时任务有异常,但是仍然没有影响到后续的调度。
3.png

总结反思

这次这个问题本质上是因为CountDownLatch的使用不规范导致的。但是实际上暴露了很多隐形的问题:

  • 项目的日志打印很不规范,错误信息不好辨识,这也是一开始没有定位到问题的很大一个原因。针对异常信息,一定要将完整的堆栈信息一并打印出来,不要只输出message。辨识度很低而且不利于排查问题原因。
  • 最好不要手工启动线程,而是利用线程池,并且给线程的命名提供符合实际含义的命名,这样会非常有利于排查问题。
  • 使用CountDownLatch.countdown()时一定要注意异常处理。
[重要提示]
所有博客内容,在我的个人博客网站可见,欢迎访问: TwoFish

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册