Java 定时任务学习笔记
线程等待实现
创建一个线程,然后在 while 循环里一直运行,通过 sleep 方法来达到定时任务的效果
public class Task {
public static void main(String[] args) {
// run in a second
final long timeInterval = 1000;
Runnable runnable = new Runnable() {
@Override
public void run() {
while (true) {
System.out.println("Hello !!");
try {
Thread.sleep(timeInterval);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread thread = new Thread(runnable);
thread.start();
}
}
Timer 实现
JDK 自带的 Timer 是一个定时器工具,使用一个后台线程计划执行指定任务,可以安排任务“执行一次”或者定期“执行多次”
Timer 类的核心方法如下:
// 在指定延迟时间后执行指定的任务
schedule(TimerTask task,long delay);
// 在指定时间执行指定的任务。(只执行一次)
schedule(TimerTask task, Date time);
// 延迟指定时间(delay)之后,开始以指定的间隔(period)重复执行指定的任务
schedule(TimerTask task,long delay,long period);
// 在指定的时间开始按照指定的间隔(period)重复执行指定的任务
schedule(TimerTask task, Date firstTime , long period);
// 在指定的时间开始进行重复的固定速率执行任务
scheduleAtFixedRate(TimerTask task,Date firstTime,long period);
// 在指定的延迟后开始进行重复的固定速率执行任务
scheduleAtFixedRate(TimerTask task,long delay,long period);
// 终止此计时器,丢弃所有当前已安排的任务。
cancal();
// 从此计时器的任务队列中移除所有已取消的任务。
purge();
定义一个通用的 TimerTask 类,用于定义执行的任务
public class DoSomethingTimerTask extends TimerTask {
private String taskName;
public DoSomethingTimerTask(String taskName) {
this.taskName = taskName;
}
@Override
public void run() {
System.out.println(new Date() + " : 任务「" + taskName + "」被执行。");
}
}
在指定延迟时间后执行一次,这是比较常见的场景,比如:当系统初始化某个组件之后,延迟几秒,然后进行定时任务的执行
public class DelayOneDemo {
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new DoSomethingTimerTask("DelayOneDemo"),1000L);
}
}
在指定的延迟时间开始执行定时任务,定时任务按照固定的间隔进行执行,比如:延迟 2 秒执行,固定执行间隔为 1 秒
public class PeriodDemo {
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new DoSomethingTimerTask("PeriodDemo"),2000L,1000L);
}
}
在指定的延迟时间开始执行定时任务,定时任务按照固定的速率进行执行,比如:延迟 2 秒执行,固定速率为 1 秒
public class FixedRateDemo {
public static void main(String[] args) {
Timer timer = new Timer();
timer.scheduleAtFixedRate(new DoSomethingTimerTask("FixedRateDemo"),2000L,1000L);
}
}
schedule 和 scheduleAtFixedRate 的区别在于:当某一次任务执行超时,恢复后两者都会立即执行下次任务,schedule 还是按照原来的间隔,scheduleAtFixedRate 则会加快节奏,努力追上进度
ScheduledExecutorService 实现
基于线程池设计的定时任务类,每个调度任务都会分配到线程池中的一个线程去执行,也就是说,任务是并发执行,互不影响
ScheduledExecutorService 主要有以下 4 个方法:
ScheduledFuture<?> schedule(Runnable command,long delay, TimeUnit unit);
<V> ScheduledFuture<V> schedule(Callable<V> callable,long delay, TimeUnit unit);
ScheduledFuture<?> scheduleAtFixedRate(Runnable command,long initialDelay,long period,TimeUnitunit);
ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,long initialDelay,long delay,TimeUnitunit);
ScheduledExecutorService 定义的这四个接口方法和 Timer 对应的方法几乎一致,只不过 Timer 的 scheduled 方法需要在外部传入一个 TimerTask 的抽象任务。ScheduledExecutorService 封装更加细致,传入 Runnable 或 Callable 内部都会封装类似 TimerTask 的抽象任务类(ScheduledFutureTask),然后传入线程池,启动线程去执行该任务
scheduleAtFixedRate 方法,按指定频率周期执行某个任务,定义及参数说明:
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
long initialDelay,
long period,
TimeUnit unit);
参数对应含义:command 为被执行的线程,initialDelay 为初始化后延时执行时间,period 为两次开始执行最小间隔时间,unit 为计时单位
scheduleAtFixedRate 是以 period 为间隔来执行任务的,如果任务执行时间小于 period,则上次任务执行完成后会间隔 period 后再去执行下一次任务。但如果任务执行时间大于 period,则上次任务执行完毕后会不间隔的立即开始下次任务
public class ScheduleAtFixedRateDemo implements Runnable{
public static void main(String[] args) {
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
executor.scheduleAtFixedRate(
new ScheduleAtFixedRateDemo(),
0,
1000,
TimeUnit.MILLISECONDS);
}
@Override
public void run() {
System.out.println(new Date() + " : 任务「ScheduleAtFixedRateDemo」被执行。");
try {
Thread.sleep(2000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
scheduleWithFixedDelay 方法,按指定频率间隔执行某个任务。定义及参数说明:
public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
long initialDelay,
long delay,
TimeUnit unit);
参数对应含义:command 为被执行的线程;initialDelay 为初始化后延时执行时间,period 为前一次执行结束到下一次执行开始的间隔时间(间隔执行延迟时间),unit 为计时单位
scheduleWithFixedDelay 不管任务执行多久,都会等上一次任务执行完毕后再延迟 delay 后去执行下次任务
public class ScheduleAtFixedRateDemo implements Runnable{
public static void main(String[] args) {
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
executor.scheduleWithFixedDelay(
new ScheduleAtFixedRateDemo(),
0,
1000,
TimeUnit.MILLISECONDS);
}
@Override
public void run() {
System.out.println(new Date() + " : 任务「ScheduleAtFixedRateDemo」被执行。");
try {
Thread.sleep(2000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
Spring Task
Spring 自带一套定时任务工具 Spring-Task,支持注解和配置文件两种形式
基于注解形式的实现如下:
@Component("taskJob")
public class TaskJob {
@Scheduled(cron = "0 0 3 * * ?")
public void job1() {
System.out.println("通过cron定义的定时任务");
}
@Scheduled(fixedDelay = 1000L)
public void job2() {
System.out.println("通过fixedDelay定义的定时任务");
}
@Scheduled(fixedRate = 1000L)
public void job3() {
System.out.println("通过fixedRate定义的定时任务");
}
}
Quartz 框架
Quartz 是 Job scheduling(作业调度)领域的一个开源项目,既可以单独使用,也可以和 Spring 框架整合使用。使用Quartz 可以开发一个或者多个定时任务,每个定时任务可以单独指定执行的时间,例如每隔 1 小时执行一次,每个月第一天上午 10 点执行一次、每个月最后一天下午 5 点执行一次等等
Quartz 的核心概念包括:
- Scheduler:调度器,是 Quartz 的核心,负责管理和调度任务
- Job:任务,是实际执行的工作单元。需要实现 Job 接口
- JobDetail:定义任务的详细信息,包括任务的名称、组、以及任务的类
- Trigger:触发器,定义任务何时执行。常用的触发器包括 SimpleTrigger 和 CronTrigger
- JobStore:任务存储,定义任务的存储方式。常见的有 RAMJobStore(内存存储)和 JDBCJobStore(数据库存储)
要使用 Quartz,首先需要在项目的 pom 文件中引入相应的依赖:
<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz</artifactId>
<version>2.3.2</version>
</dependency>
</dependency>
定义执行任务的 Job,这里要实现 Quartz 提供的 Job 接口:
public class HelloJob implements Job {
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
System.out.println("Hello, Quartz! Current time: " + System.currentTimeMillis());
}
}
创建 Scheduler 和 Trigger,并执行定时任务:
public class QuartzExample {
public static void main(String[] args) throws SchedulerException {
// 创建 Scheduler 实例
Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler();
// 定义一个 JobDetail 实例
JobDetail job = JobBuilder.newJob(HelloJob.class)
.withIdentity("helloJob", "group1")
.build();
// 创建一个触发器,每隔5秒执行一次
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("helloTrigger", "group1")
.startNow()
.withSchedule(SimpleScheduleBuilder.simpleSchedule()
.withIntervalInSeconds(5)
.repeatForever())
.build();
// 调度任务
scheduler.start();
scheduler.scheduleJob(job, trigger);
}
}
CronTrigger 允许使用 Cron 表达式来定义复杂的调度规则
public class CronTriggerExample {
public static void main(String[] args) throws SchedulerException {
Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler();
JobDetail job = JobBuilder.newJob(HelloJob.class)
.withIdentity("cronJob", "group1")
.build();
// 使用 Cron 表达式创建触发器
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("cronTrigger", "group1")
.withSchedule(CronScheduleBuilder.cronSchedule("0/10 * * * * ?"))
.build();
scheduler.start();
scheduler.scheduleJob(job, trigger);
}
}
Cron 表达式用于定义任务调度的时间规则,它由 6 或 7 个字段组成,字段之间用空格分隔,以下是每个字段的含义:
┌───────────── 秒 (0 - 59)
│ ┌───────────── 分 (0 - 59)
│ │ ┌───────────── 小时 (0 - 23)
│ │ │ ┌───────────── 日 (1 - 31)
│ │ │ │ ┌───────────── 月 (1 - 12)
│ │ │ │ │ ┌───────────── 星期几 (0 - 7) (0 和 7 都是星期日)
│ │ │ │ │ │
│ │ │ │ │ │
* * * * * *
如下特殊字符:
*
:表示任意值?
:仅在日和星期字段中使用,表示不指定值-
:表示范围,例如 10-12 表示从 10 到 12- `,:表示列表值,例如 1,2,3 表示 1、2、3
/
:表示增量,例如 0/15 表示从 0 开始每 15 分钟L
:表示最后,例如 L 在日字段表示月的最后一天W
:表示最近的工作日,例如 15W 表示最接近 15 号的工作日#
:表示第几个星期几,例如 2#1 表示第一个星期一
示例:
0 0 12 * * ?
:每天中午 12 点执行0 15 10 ? * *
:每天上午 10:15 执行0 15 10 * * ?
:每天上午 10:15 执行0 15 10 * * ? 2024
:2024 年每天上午 10:15 执行0 * 14 * * ?
:每天下午 2 点到 2:59 每分钟执行一次0 0/5 14 * * ?
:每天下午 2 点到 2:55 每 5 分钟执行一次0 0/5 14,18 * * ?
:每天下午 2 点到 2:55 每 5 分钟执行一次,以及每天下午 6 点到 6:55 每 5 分钟执行一次0 0-5 14 * * ?
:每天下午 2 点到 2:05 每分钟执行一次0 10,44 14 ? 3 WED
:每年三月的每个星期三下午 2:10 和 2:44 执行
使用 JobListener 可以在任务执行的不同阶段进行拦截和处理
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.quartz.JobListener;
public class MyJobListener implements JobListener {
@Override
public String getName() {
return "MyJobListener";
}
@Override
public void jobToBeExecuted(JobExecutionContext context) {
//在任务即将被执行时调用,可以在任务执行前进行一些准备工作或记录日志。
System.out.println("Job is about to be executed: " + context.getJobDetail().getKey());
}
@Override
public void jobExecutionVetoed(JobExecutionContext context) {
//在任务执行被否决时调用,当某些条件满足时,可以阻止任务的执行,并在此方法中执行相应的处理逻辑。
System.out.println("Job execution was vetoed: " + context.getJobDetail().getKey());
}
@Override
public void jobWasExecuted(JobExecutionContext context, JobExecutionException jobException) {
//在任务执行完成后调用,可以在任务执行后进行一些清理工作或记录日志。如果任务执行过程中抛出异常,jobException 将包含该异常信息。
System.out.println("Job was executed: " + context.getJobDetail().getKey());
if (jobException != null) {
System.out.println("Job encountered an exception: " + jobException.getMessage());
}
}
}
在 Scheduler 中注册 JobListener
import org.quartz.*;
import org.quartz.impl.StdSchedulerFactory;
public class JobListenerExample {
public static void main(String[] args) throws SchedulerException {
Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler();
//创建任务
JobDetail job = JobBuilder.newJob(HelloJob.class)
.withIdentity("listenerJob", "group1")
.build();
创建触发器
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("listenerTrigger", "group1")
.startNow()
.withSchedule(SimpleScheduleBuilder.simpleSchedule()
.withIntervalInSeconds(5)
.repeatForever())
.build();
// 创建并注册 JobListener
MyJobListener listener = new MyJobListener();
scheduler.getListenerManager().addJobListener(listener);
scheduler.start();
scheduler.scheduleJob(job, trigger);
}
}
Quartz 默认任务是并发执行的,如果需要确保同一个任务实例不被并发执行,可以在 Job 添加 @DisallowConcurrentExecution
注解,此时新的任务实例只有在前一个实例完成后才会开始执行
Spring Boot 集成 Quartz 的方式也很简单,引入封装好的 Quartz 依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
定义执行任务的 Job 和之前没有区别,定义 Scheduler 和 Trigger
import com.example.demo.quartz.task.DongAoJob;
import org.quartz.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 定义任务描述和具体的执行时间
*/
@Configuration
public class QuartzConfig {
@Bean
public JobDetail jobDetail() {
//指定任务描述具体的实现类
return JobBuilder.newJob(HelloJob.class)
// 指定任务的名称
.withIdentity("dongAoJob")
// 任务描述
.withDescription("任务描述:用于输出冬奥欢迎语")
// 每次任务执行后进行存储
.storeDurably()
.build();
}
@Bean
public Trigger trigger() {
//创建触发器
return TriggerBuilder.newTrigger()
// 绑定工作任务
.forJob(jobDetail())
// 每隔 5 秒执行一次 job
.withSchedule(SimpleScheduleBuilder.repeatSecondlyForever(5))
.build();
}
}
Quartz 默认采用 RAMJobStore,将任务相关信息保存在内存里,应用重启后,定时任务信息将会丢失,下面采用数据库方式存储任务信息
在 application.properties 文件中加入 Quartz 相关配置
# 将 Quartz 持久化方式修改为 jdbc
spring.quartz.job-store-type=jdbc
# 实例名称(默认为quartzScheduler)
spring.quartz.properties.org.quartz.scheduler.instanceName=SC_Scheduler
# 实例节点 ID 自动生成
spring.quartz.properties.org.quartz.scheduler.instanceId=AUTO
# 修改存储内容使用的类
spring.quartz.properties.org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX
# 数据源信息
spring.quartz.properties.org.quartz.jobStore.dataSource=quartz_jobs
spring.quartz.properties.org.quartz.dataSource.quartz_jobs.driver=com.mysql.cj.jdbc.Driver
spring.quartz.properties.org.quartz.dataSource.quartz_jobs.URL=jdbc:mysql://127.0.0.1:3306/quartz_jobs?serverTimezone=UTC&useUnicode=true&characterEncoding=UTF-8
spring.quartz.properties.org.quartz.dataSource.quartz_jobs.user=root
spring.quartz.properties.org.quartz.dataSource.quartz_jobs.password=123456
下载 Quartz 发布包,下载完成后,解压缩进入 /docs/dbTables 目录,找到匹配数据库的 SQL 文件,下载地址:https://www.quartz-scheduler.org/downloads/,创建 quartz_jobs 数据库,执行 SQL 文件即可
在配置文件中开启分布式支持
# 开启集群,多个 Quartz 实例使用同一组数据库表
spring.quartz.properties.org.quartz.jobStore.isClustered=true
Quartz 支持分布式定时任务的原理是在数据库中配置定时器信息, 以数据库锁的方式达到同一个任务始终只有一个节点在运行
上图每个服务器节点有一个 Scheduler 实例(调度器线程),每个 Scheduler 实例会争抢访问数据库的 Trigger 并加行锁,比如一个 Scheduler 实例想要访问数据库看是否有 Trigger 将要触发,那么它就开一个事务,首先获取并占用 TRIGGER_ACCESS 锁,然后再处理业务,在此期间其他实例无法访问与该 Trigger 有关的数据表,也就无法执行任务。获得行锁的 Scheduler 实例处理完业务后 commit work,结束事务,TRIGGER_ACCESS 锁就被释放了
如果一个 Scheduler 实例获取一个 TRIGGER_ACCESS 锁但是还没处理完就挂掉了,会导致与 Trigger 有关的表一直处于加锁状态无法被其他 Scheduler 实例访问,因此 Quartz 又提出了一个接管锁的机制:
- 每个 Scheduler 实例都在 QRTZ_SCHEDULER_STATE 表里有自己的唯一 ID,例如以 hostname + time 标识
- 每个 Scheduler 实例的 ClusterManager 线程定期往 QRTZ_SCHEDULER_STATE 表更新 LAST_CHECKIN_TIME 作为心跳
- 当某个 Scheduler 实例超过一定时间没有心跳更新时,其它 Scheduler 实例得到这个信息,会接管对应的行锁,并恢复过时的任务
XXL-JOB
XXL-JOB 是一个可以在 WEB 界面配置执行定时任务的中间件,支持分布式服务调用,XXL-JOB 自身也可以部署多个节点组成集群,本身是一个基于 SpringBoot 的 Java WEB 程序,可以通过下载 GitHub 源码进行部署
XXL-JOB 中“调度模块”和“任务模块”完全解耦,调度模块进行任务调度时,将会解析不同的任务参数发起远程调用,调用各自的远程执行器服务。这种调用模型类似RPC调用,调度中心提供调用代理的功能,而执行器提供远程服务的功能
基于数据库的集群方案,数据库选用Mysql;集群分布式并发环境中进行定时任务调度时,会在各个节点会上报任务,存到数据库中,执行时会从数据库中取出触发器来执行,如果触发器的名称和执行时间相同,则只有一个节点去执行此任务
通过 git 下载源码
git clone git@github.com:xuxueli/xxl-job.git
打开并执行 doc/db/tables_xxl_job.sql 文件
修改 xxl-job-admin/src/main/resources 的 application.properties 配置文件,修改数据库配置
### xxl-job, datasource
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
spring.datasource.username=your_username
spring.datasource.password=your_password
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
运行 xxl-job-admin/src/main/java 包下的 XxlJobAdminApplication的main() 方法,实际为一个 springboot 项目,启动后即运行一个 web 程序
在浏览器中访问 http://localhost:8080/xxl-job-admin/toLogin
,出现如下页面,默认登录账户:admin,密码:123456,第一次登录成功后请修改密码
创建一个 SpringBoot 项目,引入 maven 依赖
<!-- xxl job -->
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
<version>2.4.0</version>
</dependency>
yaml 文件配置,将 addresses 修改为自己部署的 xxl-job-admin 地址
xxl:
job:
executor:
appname: ${spring.application.name}
logpath: ${spring.application.name}/xxl-job
logretentiondays: 30
admin:
addresses: http://127.0.0.1:8080/xxl-job-admin
xxl-job 没有使用 spring-boot-starter,需自行将配置类注入到 spring 容器
@Configuration
public class XxlJobConfig {
private Logger logger = LoggerFactory.getLogger(XxlJobConfig.class);
@Value("${xxl.job.admin.addresses}")
private String adminAddresses;
@Value("${xxl.job.accessToken}")
private String accessToken;
@Value("${xxl.job.executor.appname}")
private String appname;
@Value("${server.port}")
private int port;
@Value("${xxl.job.executor.logpath}")
private String logPath;
@Value("${xxl.job.executor.logretentiondays}")
private int logRetentionDays;
@Bean
public XxlJobSpringExecutor xxlJobExecutor() {
logger.info(">>>>>>>>>>> xxl-job config init.");
XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
xxlJobSpringExecutor.setAppname(appname);
xxlJobSpringExecutor.setPort(port + 10000);
xxlJobSpringExecutor.setAccessToken(accessToken);
xxlJobSpringExecutor.setLogPath(logPath);
xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);
return xxlJobSpringExecutor;
}
}
在 xxl-job-admin 创建执行器,将 appname 替换为你的服务名,选择自动注册
启动客户端程序,将会自动注册到 xxl-job-admin 服务端
创建一个任务调度,在客户端中编写如下代码,并重新启动客户端
@Slf4j
@Component
public class XxlJobHandler {
/**
* 更新状态
**/
@XxlJob("UpdateStatus")
public void updateStatus() {
String jobParam = XxlJobHelper.getJobParam();
log.info("任务执行" + jobParam);
}
}
注意 @XxlJob("UpdateStatus")
注解的 UpdateStatus 表示任务的唯一名称,XxlJobHelper.getJobParam();
可以用来获取执行任务时传递的参数
主要在 Cron 中配置任务调度的时间周期,可选择 CRON 或固定速度,JobHandler 需配置 @XxlJob
注解的名称
创建完成后,点击操作,点击执行一次,任务执行成功将会打印日志。点击启动任务,将会按照我们既定的调度周期执行任务