Java定时任务技术分析

1,398

《从零打造项目》系列文章

工具

ORM框架选型

数据库变更管理

定时任务框架

缓存

安全框架

开发规范

常见的业务场景:

  • 某博客平台,支持定时发送文章。
  • 某学习平台,定时发送学习任务通知用户
  • 定时进行数据抓取等等

在项目中要求我们在某个时刻去做某件事情,下面我们就来看看有哪些方法可以实现定时任务。

JDK内置类

Timer

java.util.Timer是 JDK 1.3 开始就已经支持的一种定时任务的实现方式。

Timer 内部使用一个叫做 TaskQueue 的类存放定时任务,它是一个基于最小堆实现的优先级队列。TaskQueue 会按照任务距离下一次执行时间的大小将任务排序,保证在堆顶的任务最先执行。这样在需要执行任务时,每次只需要取出堆顶的任务运行即可!由于某个任务的执行时间可能较长,则后面的任务运行的时间会被延迟,所以执行的时间和你预期的时间可能不一致。延迟的任务具体开始的时间,就是依据前面任务的结束时间。

核心方法:

//启动任务之后,延迟多久时间执行
void schedule(TimerTask task, long delay);
//在指定的时间执行任务
void schedule(TimerTask task, Date time);
//启动任务后,延迟多久时间执行,执行之后指定间隔多久重复执行任务
void schedule(TimerTask task, long delay, long period);
//指定时间启动任务,执行后间隔指定时间重复执行任务
void schedule(TimerTask task, Date firstTime, long period);

代码案例:

public class TimerUse {

  public static void main(String[] args) {
    System.out.println("当前时间: " + new Date() + "n" +
        "线程名称: " + Thread.currentThread().getName());

    testTimer1();
//    testTimer2();
//    testTimer3();
//    testTimer4();
  }

  // 方法一:设定指定任务task在指定时间time执行 schedule(TimerTask task, long delay)
  public static void testTimer1() {
    Timer timer = new Timer("Timer");
    timer.schedule(new TimerTask() {
      public void run() {
        System.out.println("当前时间: " + new Date() + "n" +
            "线程名称: " + Thread.currentThread().getName());
      }
    }, 3500);
    // 设定指定的时间time为3500毫秒
  }

  /**
   * 方法二:设定指定任务task在指定延迟delay后间隔指定时间peroid执行 schedule(TimerTask task, long delay, long period)
   */
  public static void testTimer2() {
    Timer timer = new Timer("Timer");
    timer.schedule(new TimerTask() {
      public void run() {
        System.out.println("当前时间: " + new Date() + "n" +
            "线程名称: " + Thread.currentThread().getName());
      }
    }, 2000, 3500);
  }


  /**
   * 方法三:在指定的时间执行任务 schedule(TimerTask task, Date time)
   */

  public static void testTimer3() {
    Date date = new Date();
    Calendar calendar = Calendar.getInstance();
    calendar.setTime(date);
    calendar.add(Calendar.MINUTE, 1); // 往后推一分钟

    Date time = calendar.getTime();    //获取当前系统时间

    Timer timer = new Timer("Timer");
    timer.schedule(new TimerTask() {
      public void run() {
        System.out.println("当前时间: " + new Date() + "n" +
            "线程名称: " + Thread.currentThread().getName());
      }
    }, time);
  }

  /**
   * 方法四:安排指定的任务task在指定的时间firstTime开始进行重复的固定速率period执行. schedule(TimerTask task, Date firstTime,
   * long period)
   */
  public static void testTimer4() {
    Calendar calendar = Calendar.getInstance();
    calendar.set(Calendar.HOUR_OF_DAY, 12); // 控制小时
    calendar.set(Calendar.MINUTE, 0);    // 控制分钟
    calendar.set(Calendar.SECOND, 0);    // 控制秒

    Date time = calendar.getTime();    //获取当前系统时间

    Timer timer = new Timer("Timer");
    timer.schedule(new TimerTask() {
      public void run() {
        System.out.println("当前时间: " + new Date() + "n" +
            "线程名称: " + Thread.currentThread().getName());
      }
    }, time, 1000 * 60 * 60 * 24);// 这里设定将延时每天固定执行
  }
}

注意事项

1、创建一个 Timer 对象相当于新启动了一个线程,但是这个新启动的线程,并不是守护线程。它一直在后台运行,通过如下代码将新启动的 Timer 线程设置为守护线程。

Timer timer = new Timer(true);

变为守护线程,则意味着主线程执行结束,则程序就结束了,定时任务也就不会执行。

2、当计划时间早于当前时间,则任务立即被运行。

ScheduledExecutorService

ScheduledExecutorService 是一个接口,有多个实现类,比较常用的是 ScheduledThreadPoolExecutor

public class ScheduledThreadPoolExecutor 
  extends ThreadPoolExecutor implements ScheduledExecutorService {}

scheduledthreadexecutor

ScheduledThreadPoolExecutor 的状态管理、入队操作、拒绝操作等都是继承于 ThreadPoolExecutorScheduledThreadPoolExecutor 主要是提供了周期任务和延迟任务相关的操作;

schedule(Runnable command, long delay, TimeUnit unit) // 无返回值的延迟任务
schedule(Callable callable, long delay, TimeUnit unit) // 有返回值的延迟任务
scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit) // 固定频率周期任务
scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit) // 固定延迟周期任务

代码示例:

TimerTask repeatedTask = new TimerTask() {
  @SneakyThrows
  public void run() {
    System.out.println("当前时间: " + new Date() + "n" +
                       "线程名称: " + Thread.currentThread().getName());
  }
};
System.out.println("当前时间: " + new Date() + "n" +
                   "线程名称: " + Thread.currentThread().getName());
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
long delay = 1L;
long period = 2L;
// 延迟1s,周期2s
executor.scheduleAtFixedRate(repeatedTask, delay, period, TimeUnit.SECONDS);
// 定时任务重复执行3个周期
Thread.sleep((delay + period * 3) * 1000);
executor.shutdown();

执行结果为:

当前时间: Wed Nov 23 09:52:35 CST 2022n线程名称: main
当前时间: Wed Nov 23 09:52:36 CST 2022n线程名称: pool-1-thread-1
当前时间: Wed Nov 23 09:52:38 CST 2022n线程名称: pool-1-thread-1
当前时间: Wed Nov 23 09:52:40 CST 2022n线程名称: pool-1-thread-1
当前时间: Wed Nov 23 09:52:42 CST 2022n线程名称: pool-1-thread-1

注意事项

1、scheduleAtFixedRatescheduleWithFixedDelay 是我们最常用的两个方法,两者略有区别,前者为固定频率周期任务,如果任务执行时间超出周期时,下一次任务会立刻运行;后者为固定延迟周期任务,无论执行时间是多少,其结果都是在执行完毕后,停顿固定的时间,然后执行下一次任务

2、ScheduledThreadPoolExecutor 线程最多为核心线程,最大线程数不起作用,因为 DelayedWorkQueue 是无界队列。

更多内容推荐阅读:并发系列(7)之 ScheduledThreadPoolExecutor 详解

小结

在 JDK 中,内置了两个类,可以实现定时任务的功能:

  • java.util.Timer :可以通过创建 java.util.TimerTask 调度任务,在同一个线程中串行执行,相互影响。也就是说,对于同一个 Timer 里的多个 TimerTask 任务,如果一个 TimerTask 任务在执行中,其它 TimerTask 即使到达执行的时间,也只能排队等待。因为 Timer 是串行的,同时存在 坑坑 ,所以后来 JDK 又推出了 ScheduledExecutorService ,Timer 也基本不再使用。
  • java.util.concurrent.ScheduledExecutorService :在 JDK 1.5 新增,基于线程池设计的定时任务类,每个调度任务都会被分配到线程池中并发执行,互不影响。这样,ScheduledExecutorService 就解决了 Timer 串行的问题。

在日常开发中,我们很少直接使用 Timer 或 ScheduledExecutorService 来实现定时任务的需求。主要有几点原因:

  • 它们仅支持按照指定频率,不直接支持指定时间的定时调度,需要我们结合 Calendar 自行计算,才能实现复杂时间的调度。例如说,每天、每周五、2019-11-11 等等,不支持 Cron 表达式。
  • 它们是进程级别,而我们为了实现定时任务的高可用,需要部署多个进程。此时需要等多考虑,多个进程下,同一个任务在相同时刻,不能重复执行。
  • 项目可能存在定时任务较多,需要统一的管理,此时不得不进行二次封装。

所以,一般情况下,我们会选择专业的调度任务中间件

中间件

Spring Task

由于 SpringTask 已经存在于 Spring 框架中,所以无需添加依赖。

下面我们弄个小 Demo 测试一下,新建一个项目,引入依赖。

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>2.6.3</version>
  <relativePath/>
</parent>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.20</version>
  </dependency>
</dependencies>

添加 SpringTask 的配置类

@Configuration
@EnableScheduling
public class SpringTaskConfig {

}

在 application.yml 添加关于 Spring Task 的配置,如下:

spring:
  task:
    # Spring Task 调度任务的配置,对应 TaskSchedulingProperties 配置类
    scheduling:
      thread-name-prefix: job- # 线程池的线程名的前缀。默认为 scheduling- ,建议根据自己应用来设置
      pool:
        size: 10 # 线程池大小。默认为 1 ,根据自己应用来设置
      shutdown:
        await-termination: true # 应用关闭时,是否等待定时任务执行完成。默认为 false ,建议设置为 true
        await-termination-period: 60 # 等待任务完成的最大时长,单位为秒。默认为 0 ,根据自己应用来设置

spring.task.scheduling.shutdown 配置项,是为了实现 Spring Task 定时任务的优雅关闭。

定时任务测试类

@Service
@Slf4j
public class ScheduledTaskService {

  private final AtomicInteger counts = new AtomicInteger();

  //  @Scheduled(cron = "0 0/10 * ? * ?")//每10分钟执行一次
  @Scheduled(fixedRate = 3000) // 每 3秒执行一次
  public void pushMessage() {
    log.info("[execute]定时第({})给用户发送通知", counts.incrementAndGet());
  }

}

最后创建一个启动类,启动项目,控制台输出如下:

SpringTask定时任务

Spring Task 支持 Cron 表达式 。Cron 表达式主要用于定时作业(定时任务)系统定义执行时间或执行频率的表达式,非常厉害,你可以通过 Cron 表达式进行设置定时任务每天或者每个月什么时候执行等等操作。

Cron 格式中每个时间元素的说明

Cron参数说明

平时可以找一个 Cron 表达式生成器在线网站按需生成想要的表达式。

SpringTask 功能小结:

1、SpringTask 内置于 Spring 框架,相比于Quartz更加简单方便,不需要引入其他依赖。

2、Spring Task 底层是基于 JDK 的 ScheduledThreadPoolExecutor 线程池来实现的。

3、支持 Cron 表达式

4、只支持单机,功能单一

Quartz

Github:github.com/quartz-sche…

Quartz 作为一个优秀的开源调度框架,Quartz 具有以下特点:

  1. 强大的调度功能,例如支持丰富多样的调度方法,可以满足各种常规及特殊需求;
  2. 灵活的应用方式,例如支持任务和调度的多种组合方式,支持调度数据的多种存储方式;
  3. 分布式和集群能力,Terracotta 收购后在原来功能基础上作了进一步提升。

另外,作为 Spring 默认的调度框架,Quartz 很容易与 Spring 集成实现灵活可配置的调度功能。

在 Quartz 体系结构中,有三个组件非常重要:

  • Scheduler :调度器。Scheduler启动Trigger去执行Job。
  • Trigger :触发器。用来定义 Job(任务)触发条件、触发时间,触发间隔,终止时间等。四大类型:SimpleTrigger(简单的触发器)、CornTrigger(Cron表达式触发器)、DateIntervalTrigger(日期触发器)、CalendarIntervalTrigger(日历触发器)。
  • Job :任务。具体要执行的业务逻辑,比如:发送短信、发送邮件、访问数据库、同步数据等。

Quartz 应用分为单机模式和集群模式。实际应用中,我们都会选择集群模式,关于 Quartz 的使用,后续会单独出一篇文章进行介绍。

Quartz 框架出现的比较早,后续不少定时框架,或多或少都基于 Quartz 研发的,比如当当网的elastic-job就是基于quartz二次开发之后的分布式调度解决方案。

并且,Quartz 并没有内置 UI 管理控制台,不过你可以使用 quartzui 这个开源项目来解决这个问题。

Quartz 虽然也支持分布式任务。但是,它是在数据库层面,通过数据库的锁机制做的,有非常多的弊端比如系统侵入性严重、节点负载不均衡。

Quartz 优缺点:

  • 可以与 Spring 集成,并且支持动态添加任务和集群。
  • 分布式支持不友好,没有内置 UI 管理控制台、使用麻烦(相较于其他框架)。

XXL-JOB

官方说明:XXL-JOB 是一个轻量级分布式任务调度平台,其核心设计目标是开发迅速、学习简单、轻量级、易扩展。现已开放源代码并接入多家公司线上产品线,开箱即用。

通俗来讲:XXL-JOB 是一个任务调度框架,通过引入 XXL-JOB 相关的依赖,按照相关格式撰写代码后,可在其可视化界面进行任务的启动执行中止以及包含了日志记录与查询任务状态监控

官方文档:www.xuxueli.com/xxl-job/

GitHub:github.com/xuxueli/xxl…

Gitee:gitee.com/xuxueli0323…

特性:

xxl-job特性

Xxl-job 解决了很多 Quartz 的不足。

Xxl-job 解决了很多 Quartz 的不足

XXL-JOB 的架构设计如下图所示:

xxl-job架构图

从上图可以看出,XXL-JOB调度中心执行器 两大部分组成。调度中心主要负责任务管理、执行器管理以及日志管理。执行器主要是接收调度信号并处理。另外,调度中心进行任务调度时,是通过自研 RPC 来实现的。

关于 xxl-job 的使用,下篇文章会详细介绍的。

xxl-job 的优点相对于 Quartz 非常明显,使用更加简单,而且内置了 UI 管理控制台。

Elastic-Job

Github:github.com/apache/shar…

官方文档:shardingsphere.apache.org/elasticjob/…

ElasticJob 是面向互联网生态和海量任务的分布式调度解决方案,基于QuartzZooKeeper 开发,由两个相互独立的子项目 ElasticJob-Lite 和 ElasticJob-Cloud 组成。 它通过弹性调度、资源管控、以及作业治理的功能,打造一个适用于互联网场景的分布式调度解决方案,并通过开放的架构设计,提供多元化的作业生态。 它的各个产品使用统一的作业 API,开发者仅需一次开发,即可随意部署。

ElasticJob-Lite 的架构设计如下图所示:

ElasticJob-Lite Architecture

从上图可以看出,Elastic-Job 没有调度中心这一概念,而是使用 ZooKeeper 作为注册中心,注册中心负责协调分配任务到不同的节点上。

Elastic-Job 中的定时调度都是由执行器自行触发,这种设计也被称为去中心化设计(调度和处理都是执行器单独完成)。

@Component
@ElasticJobConf(name = "dayJob", cron = "0/10 * * * * ?", shardingTotalCount = 2,
        shardingItemParameters = "0=AAAA,1=BBBB", description = "简单任务", failover = true)
public class TestJob implements SimpleJob {
    @Override
    public void execute(ShardingContext shardingContext) {
        log.info("TestJob任务名:【{}】, 片数:【{}】, param=【{}】", shardingContext.getJobName(), shardingContext.getShardingTotalCount(),
                shardingContext.getShardingParameter());
    }
}

Elastic-Job 支持的功能:

Elastic-Job 支持的功能

关于 Elastic-Job 的使用,未来会抽时间出一篇文章的。

Elastic-Job 相较于 XXL-JOB 缺点也比较明显,就是需要引入额外的中间件,比如 Zookeeper,增加了操作难度。

总结

由于本人目前接触到的框架有限,除了上述四种中间件,还有不少大公司自研的中间件,如果从个人开发的角度来看,推荐大家使用 XXL-JOB,开箱即用,且配有可视化界面。如果想对其他中间件有所了解,这里推荐阅读如下两篇文章: