type
status
date
slug
summary
tags
category
icon
password
我们在项目开发可能会存在需要定时运行某类任务的场景,例如定时数据同步,定期发送营销短信等,如果这些定时运行的任务我们都通过新的线程不断轮询的方式实现的话,至少会存在以下问题:
  • 每个定时运行的任务都需要创建新的线程来处理,线程资源无法管控;当定时任务被取消的时候线程生命周期结束,如果需要再次执行定时任务又需要再次创建线程,线程的创建销毁成本高。
  • 执行定时任务的线程轮询周期需要手动处理,在业务开发过程中需要关注这些基础组件的运行;
而Java中提供了Timer以及ScheduledThreadPoolExecutor组件作为定时任务的解决方案,我们可以方便地通过组件来运行定时任务,这篇文章我会基于自己使用定时线程池的经验对定时任务的合理运行进行总结。

定时任务的解决方案介绍

Timer组件

Timer组件是Java在1.3版本提出的定时任务的解决方案,该实例会绑定一个后台线程来执行定时任务,线程数量无法调整,多个相同类型的任务提交到一个Timer执行,任务执行耗时会影响其他任务的执行。我理解该组件是JUC包未设计之前的历史解决方案,对于当前定时任务的技术选型个人更推荐ScheduledExecutorService,后面我对从多个角度对二者进行对比。

ScheduledExecutorService组件

ScheduledExecutorService属于对ExecutorService的扩展,通过查看其实现类ScheduledThreadPoolExecutor实例的源码能够发现,该线程池在构造的时候不支持指定内部的任务队列,而是直接在构造过程中指定使用内部队列DelayedWorkQueue作为任务队列,这是其与其他普通线程池最大的区别,也是该组件能够实现任务延迟处理的关键所在。
针对定时任务,我们最常使用的就是通过指定任务的执行周期来将任务提交到ScheduledThreadPoolExecutor实例中,对于调用者来说,将任务提交到该线程池之后就不需要关注其他情况,而且能够通过任务对应的ScheduledFutureTask来取消定时任务。
这里不会介绍最基础的API使用,相信大家通过日常使用能够很快上手,这里我来解答一个使用ScheduledThreadPoolExecutor时最常见的问题:通过scheduleAtFixedRate与scheduleWithFixedDelay提交任务执行的差异在哪里?下面是我针对这两个API的简单测试用例,通过运行测试用例的输出可以得到答案。
ThreadPoolScheduleAPITest.java
geekeritcom
  • fixRate:任务执行周期会受到任务耗时影响,当任务执行耗时超出指定的周期时,上次任务执行结束后线程池会立即开始执行新任务;
  • fixDelay:任务执行周期不受任务执行耗时的影响,每次的任务执行都会保持固定的周期时长间隔
下图是我根据这两个API的主要差别绘制的逻辑示意图,再结合上述相关的测试代码,相信很好理解。
notion image
 

Timer与ScheduledExecutorService简单对比

线程数量
底层实现
任务
特点
任务控制
Timer
1
单个线程
TimerTask抽象类的子类
实例创建时启动线程; 不能细粒度控制任务,无法获取队列任务; 单线程串行执行
无法取消单个任务
ScheduledExecutorService
自定义
线程池
Runnable接口的实现
任务提交时尝试创建线程; 可以获取队列任务 多线程并行执行,并发能力更高
可以利用任务的Future取消单个任务

定时任务的“丢失”

无论采取哪种方案来实现定时任务的运行,都需要注意任务的异常捕获等级不够,导致的定时任务终止执行,表象就是代码中没有打印任何异常,任务也不再执行。
我在项目中曾经遇到过的问题是,某个定时任务的处理链路非常长,某天测试人员反馈定时任务偶发无法执行,我查看日志发现没有任何异常信息,于是将线程信息dump后进行分析,发现线程进入阻塞状态。但是为什么定时线程会进入阻塞状态不再获取任务呢?关键是日志里也没有打印任何异常信息啊?在分析代码的各种情况确认没有问题后,怀疑难道是出现了更高级别的异常,导致没有捕获?
于是提高了异常捕获等级后重新测试,终于发现是由于该定时线程在某些情况下会出现处理链路很长,导致出现了StackOverflowError的错误,但是之前的代码只处理了Exception级别的异常,导致定时任务会被“丢失”。
示例代码如下,因此在日常开发中我们一定要注意定时任务的异常处理。
ScheduleTaskThrowErrorTest.java
geekeritcom

Spring定时任务

如果我们在Spring框架下进行开发,Spring也提供了定时任务的解决方案,但是使用时一定要了解清楚其实现原理,否则很可能会踩坑。
我们日常使用Spring框架进行开发时,可以使用@EnableScheduling注解启用内置的定时任务机制,利用@Scheduled注解能够指定任务的周期等信息,使用还是非常方便的。

警惕默认方案的坑

需要注意的是,使用Spring框架提供的定时任务默认情况下内部只会创建一个线程的线程池,因此如果业务中多个地方同时使用默认的定时线程池,某个任务的长耗时操作就会影响其他业务线程的正常执行。
为什么Spring默认情况下只会创建单线程的定时任务线程池呢
我们使用Spring提供的定时任务机制,一定要在配置类添加@EnableScheduling注解,下面是该注解上的部分JavaDoc描述
By default, will be searching for an associated scheduler definition: either a unique org.springframework.scheduling.TaskScheduler bean in the context,or a TaskScheduler bean named "taskScheduler" otherwise; the same lookup will also be performed for a java.util.concurrent.ScheduledExecutorService bean. If neither of the two is resolvable, a local single-threaded default scheduler will be created and used within the registrar.
上述意思非常明确,默认情况下会去扫描类型为TaskScheduler的bean,以及ScheduledExecutorService类型的bean,如果二者都没有扫描到,则创建一个单线程的定时线程池注册到Spring容器中。
ScheduleTaskTest.java
geekeritcom
使用上述测试代码运行后会发现,task2并不会按照我们预期每两秒执行一次,这就是因为task1执行耗时过长导致线程被占用,任务只能被放入等待队列中。
那如果我们想要使用Spring提供的定时任务解决方案,如何解决这种问题呢?根据Spring官方文档可以得到,解决方案有以下几种:
  1. 通过配置文件指定Spring内置定时任务线程池的线程数量
    1. 创建TaskScheduler组件注入Spring容器
    1. 创建ScheduledExecutorService组件注入Spring容器
    这几种方案我也亲身验证了,并放在了上述测试用例的工程中。

    Spring定时线程池的装配

    我对单线程线程池如何组装的一点探究:定时线程池组件由ScheduledExecutorFactoryBean组件负责创建,该组件继承于ExecutorConfigurationSupport,在Spring容器启动后会触发组件的初始化方法。
    notion image
    最终会由ScheduledExecutorFactoryBean组件来创建线程池
    notion image
    线程池的实例化
    notion image
    夜游西湖Docker入门
    Loading...