Java 定时任务技术发展历程

3,672 阅读8分钟

定时任务是每个业务常见的需求,比如每分钟扫描超时支付的订单,每天定时归档数据库历史数据,每天统计前一天的数据并生成报表等等。常见的解决方案有XXL-JOB、Spring-Task等。本篇文章着重于探讨Java 定时任务技术的发展历程。

一、Timer

java.util.Timer 是JDK原生的工具类,用于创建定时任务。

创建 java.util.TimerTask 任务,在 run 方法中实现业务逻辑。通过 java.util.Timer 进行调度,支持按照固定频率或指定 Date 时刻执行。所有的 TimerTask 是在同一个线程中串行执行,相互影响。

1.1 最佳实践

TimerTask timerTask1 = new TimerTask() {
   @SneakyThrows
   @Override
   public void run() {
      System.out.println("timerTask1 run ... " + DateTime.now());
   }
};
TimerTask timerTask2 = new TimerTask() {
   @Override
   public void run() {
      System.out.println("timerTask2 run ... " + DateTime.now());
   }
};
Timer timer = new Timer();
System.out.println(DateUtil.now());
timer.schedule(timerTask1, 0);
timer.schedule(timerTask2, DateUtil.parseDateTime("2023-07-17 14:27:00"));

控制台:
main:     run ...    2023-07-17 14:26:55
timerTask1 run ...    2023-07-17 14:27:00
timerTask2 run ...    2023-07-17 14:27:00

1.1 类结构

image.png

  • TimerTask
public abstract class TimerTask implements Runnable {
  // 同步锁
	final Object lock = new Object();
  // 下次调度时间
  long nextExecutionTime;
	// run
	public abstract void run();
	// 取消任务
	public boolean cancel() {}
  // 状态 
  int state = VIRGIN;
}
  • Timer
public class Timer {

	// 有优先级的TimerTask队列(二叉堆)
	private final TaskQueue queue = new TaskQueue();
	// 核心调度线程
	private final TimerThread thread = new TimerThread(queue);
	// 构造器 - 是否是守护线程
	public Timer(boolean isDaemon) {}
	
	// 在固定延迟后调度一次任务
	public void schedule(TimerTask task, long delay) {}
	// 在指定时刻调度一次任务
	public void schedule(TimerTask task, Date time) {}
	// 在固定延迟之后执行一次,后续以固定延迟调度任务
	public void schedule(TimerTask task, long delay, long period) {}
	// 在指定时刻执行一次,后续以固定延迟调度任务
	public void schedule(TimerTask task, Date firstTime, long period){}
	
	// 在指定时刻执行一次,后续以固定频率调度任务
	public void scheduleAtFixedRate(TimerTask task, Date firstTime, long period){}
	// 在指定时刻执行一次,后续以固定频率调度任务
	public void scheduleAtFixedRate(TimerTask task, long delay, long period) {}
	
        
  private void sched(TimerTask task, long time, long period) {
      if (queue.getMin() == task){
          queue.notify();
      }
  }

	// 清除 state=CANCELLED 的任务
	public int purge() {}
	// 清空任务
	public void cancel(){} 
	
}

schedule方法 和 scheduleAtFixedRate方法的区别:

schedule:

nextExecutionTime = System.currentTimeMillis() + task.period

scheduleAtFixedRate:

nextExecutionTime = executionTime + task.period

总结:schedule更加注重间隔时间,scheduleAtFixedRate更加注重频率。当执行时间早于当前时间时, schedule 方法不具有追赶性, 而 scheduleAtFixedRate 方法具有追赶性

  • TaskQueue

TaskQueue是一个由堆实现的优先队列,是按任务下一次执行时间排序的优先级队列,使用的数据结构是小顶堆(保证堆顶的元素最小),保证能在O(1)找到最小值,本质上是一个完全二叉树,保证了根节点总是比叶子节点小。因为这个优先级队列主要是来存放TimerTask的,所以它使用的是一个TimerTask的数组来实现的。

class TaskQueue {

	private TimerTask[] queue = new TimerTask[128];
	// 队列首元素下标为1
	private int size = 0;

	
	// 在队尾添加
	void add(TimerTask task) {
        queue[++size] = task;
		fixUp(size);
	}

	// 删除队首
    void removeMin() {
        queue[1] = queue[size];
        queue[size--] = null;
        fixDown(1);
    }
	
	/**
     * 自底向上重建堆,k>>1 整数k右移1位表示除以2 k>>1 ==>k/2
     * 根据堆的性质可以知道一个节点k 的父节点可以表示为k/2
     * 因此这个方法的从指定的节点k,依次检查和它的父节点的关系
     * 如果父节点大于子节点就交换父节点与子节点(小顶堆,堆顶是最小的元素)
     */
	private void fixUp(int k) {
        while (k > 1) {
            int j = k >> 1; // 父节点
            // 父节点 <= 队列尾(新节点)
            if (queue[j].nextExecutionTime <= queue[k].nextExecutionTime)
                break;
            // 父节点 > 队列尾(新节点)
            TimerTask tmp = queue[j];  queue[j] = queue[k]; queue[k] = tmp;
            k = j;
        }
    }
	
	/**
     *自顶向下重建堆,k<<1 整数k向左移1位表示乘以2 k<<1 == >k*2
     *根据堆的性质知道,对于一个节点k,它的子节点分别为k*2 和k*2+1(如果存在)
     *
     *这个方法就是对于节点k如果存在子节点,则找到子节点中较小的一个和k进行比较交换
     */
	private void fixDown(int k) {
        int j;
        while ((j = k << 1) <= size && j > 0) {
            if (j < size &&
                // 比较两个子元素 找到较小的值
                queue[j].nextExecutionTime > queue[j+1].nextExecutionTime)
                j++;
            // 父节点 <= 所有子节点
            if (queue[k].nextExecutionTime <= queue[j].nextExecutionTime)
                break;
            // 交换父节点和较小的子节点的值
            TimerTask tmp = queue[j];  queue[j] = queue[k]; queue[k] = tmp;
            k = j;
        }
    }
}
  • TimerThread

    TimerThread 继承Thread类,单线程,因此需要mainLoop循环逻辑来轮询消费任务队列。

class TimerThread extends Thread {

    private TaskQueue queue;

    TimerThread(TaskQueue queue) {
        this.queue = queue;
    }

    public void run() {
        mainLoop();
    }
   
private void mainLoop() {
    // 无限循环来控制等待任务队列中加入任务
    while (true) {
        try {
            TimerTask task;
            boolean taskFired;
            // 获取任务队列的锁
            synchronized(queue) {
                // 如果任务队列为空,并且线程没有被cancel()
                // 则线程等待queue锁,queue.wait()方法会释放获得的queue锁
                // 这样在Timer中sched()方法才能够获取到queue锁
                while (queue.isEmpty() && newTasksMayBeScheduled)
                    queue.wait();
                
                // 如果任务队列为空了,那么就退出循环
                // 这种情况要发生,那么必须newTasksMayBeScheduled=false
                // 因为如果newTasksMayBeScheduled=true,就会在上面的while循环中执行queue.wait(),使线程进入等待状态
                // 等线程从等待状态恢复时,说明queue.notify()方法被调用了,
                // 而观察Timer代码这只可能在sched()方法中发生, 这个方法会在队列queue中add任务而使queue不再为空
                if (queue.isEmpty())
                    break; 

                long currentTime, executionTime;
                // 得到任务队列中的位置1的任务
                task = queue.getMin();
                // 获取任务的锁
                synchronized(task.lock) {
                    // 如果任务被取消了(TimerTask.cancel()方法被调用)
                    // 将任务从队列中移除,继续重新循环
                    if (task.state == TimerTask.CANCELLED) {
                        queue.removeMin();
                        continue;  // No action required, poll queue again
                    }
                    
                    // 获取任务的执行时间
                    currentTime = System.currentTimeMillis();
                    executionTime = task.nextExecutionTime;
                    
                    // 计算任务是否应该被触发
                    if (taskFired = (executionTime<=currentTime)) {
                        // 任务应该被触发,并且不是重复任务
                        // 将任务从队列中移除并修改任务的执行状态
                        if (task.period == 0) { // Non-repeating, remove
                            queue.removeMin();
                            task.state = TimerTask.EXECUTED;
                        } else { // 任务是重复执行任务,计算任务下一次应该被执行的时间,并重新排序任务队列
                            queue.rescheduleMin(
                              task.period<0 ? currentTime   - task.period
                                            : executionTime + task.period);
                        }
                    }
                }
                // 如果任务不应被触发,让其等待一定时间后执行
                if (!taskFired) // Task hasn't yet fired; wait
                    queue.wait(executionTime - currentTime);
            }
            // 任务应该被触发,让任务执行
            if (taskFired)  // Task fired; run it, holding no locks
                task.run();  // 任务也是一个线程
        } catch(InterruptedException e) {
        }
    }
}

}

TimerThread主要做了三件事:

  1. 在队列中获取要执行的任务
  2. 如果任务已取消或者是非重复执行的任务,在队列中去除
  3. 执行任务

1.2 问题

  1. 单线程执行。对于同一个 Timer 里的多个 TimerTask 任务,如果一个 TimerTask 任务在执行中,其它 TimerTask 即使到达执行的时间,也只能排队等待。而且,如果一个任务抛出异常而没有被捕获,那么整个 Timer 的线程也将终止,整个定时任务就会失败。
TimerTask timerTask1 = new TimerTask() {
   @SneakyThrows
   @Override
   public void run() {
      System.out.println("timerTask1 run ... " + DateTime.now());
      Thread.sleep(10000);
   }
};

TimerTask timerTask2 = new TimerTask() {
   @Override
   public void run() {
      System.out.println("timerTask2 run ... " + DateTime.now());
   }
};
Timer timer = new Timer();
System.out.println("main       run ... "+DateUtil.now());
timer.schedule(timerTask1, 0);
timer.schedule(timerTask2, 0);

控制台:
main       run ... 2023-07-17 14:53:54
timerTask1 run ... 2023-07-17 14:53:55
timerTask2 run ... 2023-07-17 14:54:05
  1. 内存泄漏。java.util.Timer 使用一个任务队列来存储已计划的任务。如果一个TimerTask已经过期或执行完毕或调用了 cancel 方法,它将仍然存在于任务队列中,Timer将继续持有对TimerTask对象的引用,从而阻止 TimerTask 对象被垃圾回收,直到timerTask=queue.min()timerThread扫到并remove
static class AlarmTask extends TimerTask {

   String name;
   byte[] bytes = new byte[10 * 1024 * 1024]; //模拟业务数据 10M

   public AlarmTask(String name) {
      this.name = name;
   }

   @Override
   public void run() {
      System.out.println(("[" + name + "]嘀。。。"));
   }

}

public static void main(String[] args) {
   long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
   System.out.println("maxMemory:" + maxMemory); // 267M
   int i = 0;
   Timer timer = new Timer();
   timer.schedule(new AlarmTask("闹钟" + i++), 100, 100);
   while (true) {
      TimerTask alarm = new AlarmTask("闹钟" + i);
      timer.schedule(alarm, 100, 10_0000);
      alarm.cancel();
      System.out.println("已取消闹钟" + i++);
   }
}

控制台:
已取消闹钟24
已取消闹钟25
已取消闹钟26
[Full GC (Ergonomics)  279231K->277557K(299008K), 0.0026737 secs]
[Full GC (Allocation Failure)  277557K->277539K(299008K), 0.0080032 secs]
java.lang.OutOfMemoryError: Java heap space
Dumping heap to D:\Desktop\time.dump ...
Unable to create D:\Desktop\time.dump: File exists
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at com.swagger.funs.job.TestTimerTask$AlarmTask.<init>(TestTimerTask.java:42)
	at com.swagger.funs.job.TestTimerTask.main(TestTimerTask.java:30)
[闹钟0]嘀。。。

dump文件信息: 企业微信截图_16895816412591.png

1.3 总结

Timer 本质上是一个单个后台线程Thread,用于依次执行该对象的所有任务。当Timer对象被new出来时,后台线程就会启动,没有任务会wait(),直到添加任务后被唤醒。

二、ScheduledExecutorService

ScheduledExecutorService 是 java.util.concurrent 包下基于线程池设计的定时任务解决方案。

任务之间可以多线程并发执行,互不影响,当任务来的时候,才会真正创建线程去执行,每个调度任务都会分配到线程池中的一个线程去执行,解决 Timer 定时器无法并发执行的问题,支持 fixedRate 和 fixedDelay。

2.1 最佳实践

private static final int CORE_SIZE = 5;
private static final ScheduledExecutorService executorService = Executors.newScheduledThreadPool(CORE_SIZE);

@SneakyThrows
public static void main(String[] args) {
   // 延迟1s后执行一次
   executorService.schedule(() -> System.out.println("hello schedule"), 1, TimeUnit.SECONDS);
   // 延迟1s后执行一次,支持返回值
   executorService.schedule(() -> 1, 1, TimeUnit.SECONDS).get();
   // 按照固定频率执行,每隔5秒跑一次
   executorService.scheduleAtFixedRate(() -> System.out.println("hello fixedRate"), 5, 5, TimeUnit.SECONDS);
   // 按照固定延时执行,上次执行完后隔5秒执行下一次
   executorService.scheduleWithFixedDelay(() -> System.out.println("hello fixedDelay"), 10, 5, TimeUnit.SECONDS);
}

2.2 类结构

  • ScheduledThreadPoolExecutor
public class ScheduledThreadPoolExecutor
        extends ThreadPoolExecutor
        implements ScheduledExecutorService {
		
	// 创建了一个线程池
    public ScheduledThreadPoolExecutor(int corePoolSize) {
        super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
              new DelayedWorkQueue());
    }	
    
  // 调度	
	public ScheduledFuture<?> schedule(Runnable command,
                                       long delay,
                                       TimeUnit unit) {
		// 计算任务的执行时间,当前时间 + 延迟时间
		long triggerTime = System.nanoTime() + unit.toNanos(delay);
		// 创建一个延迟任务,并将其添加到任务队列中
		RunnableScheduledFuture<?> task = new ScheduledFutureTask<>(command, triggerTime);
		// 将任务添加到任务队列中
		getQueue().add(task);
		// 启动线程池中的线程来执行任务
		ensurePrestart();
        return t;
    }				
}

ThreadPoolExecutor Worker工作线程启动后会不断从任务队列中拿出队列头,然后执行。在ScheduledThreadPoolExecutor 中,我们需要确保线程每次拿出的任务都是最近的时间内需要执行的任务,也就是说,队列头的任务必须是剩余时间最少、需要优先执行的。

  • DelayedWorkQueue
static class DelayedWorkQueue extends AbstractQueue<Runnable>
        implements BlockingQueue<Runnable> {
		

	public RunnableScheduledFuture<?> take() throws InterruptedException {            
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();            
    try {  
        // 自旋,只有任务到了要执行的时间才将任务出队返回              
        for (;;) {
            RunnableScheduledFuture<?> first = queue[0];                    
            // 如果没有任务,就让线程在available条件下等待。
            if (first == null)
                available.await();                    
            else {                        
                // 获取任务的剩余延时时间
                long delay = first.getDelay(NANOSECONDS);                        
                // 如果延时时间到了,就返回这个任务,用来执行。
                if (delay <= 0)                            
                    return finishPoll(first);                        
                // 如果任务还没有到时间,则将first设置为null,当线程等待时,不持有first的引用
                first = null;
           
                // 如果前面有线程在等待,当前线程直接进入等待状态
                if (leader != null)
                    // 条件锁
                    available.await();      
                // 如果前面没有有线程在等待
                else {                            
                    // 记录一下当前等待队列头任务的线程
                    Thread thisThread = Thread.currentThread();
					// 将当前线程作为leader
                    leader = thisThread;                            
                    try {                                
                        // 当任务的延时时间到了时,能够自动超时唤醒。
                        available.awaitNanos(delay);
                    } finally {                        
                        // 唤醒后再次获得锁后把leader再置空     
                        if (leader == thisThread)
                            leader = null;
                    }
                }
            }
        }
	} finally {  
			// 唤醒等待任务的线程              
        if (leader == null && queue[0] != null)                    
            available.signal();
        ock.unlock();
    }
	}		
}

流程: image.png

2.3 整体图解

  • schedule: 企业微信截图_16896656863932.png

  • scheduleAtFixedRate、scheduleWithFixedDelay

image.png

2.4 总结

优点:

  1. 简单易用:ScheduledExecutorService 是 Java 提供的标准定时任务调度工具,使用起来非常简单,无需额外的依赖。
  2. 线程池管理:ScheduledExecutorService 基于线程池来执行任务,能够有效管理线程资源,避免频繁创建和销毁线程。
  3. 延迟和周期性任务:ScheduledExecutorService 支持延迟执行和周期性执行任务,可以方便地设置任务的执行间隔和起始时间。
  4. 并发性:ScheduledExecutorService 可以支持多个任务同时执行,通过线程池的并发性,可以提高任务执行的效率。
  5. 可靠性:ScheduledExecutorService 是基于线程池和内部定时器实现的,能够提供相对可靠的定时任务调度。

缺点:

  1. 有限的功能:ScheduledExecutorService 提供的功能相对简单,对于复杂的任务调度需求可能不够满足。
  2. 线程资源消耗:ScheduledExecutorService 依赖于线程池来执行任务,如果任务频率很高,可能会占用大量的线程资源。如果任务没有得到及时执行,任务堆积可能导致线程池资源耗尽。
  3. 不支持任务持久化:ScheduledExecutorService 本身并不提供任务的持久化功能,如果应用程序重启,之前设置的定时任务可能会丢失。
  4. 不适合长时间任务:由于 ScheduledExecutorService 是基于线程池的,长时间运行的任务可能会占用线程池的线程,导致其他定时任务无法及时执行。
  5. 不支持集群环境:ScheduledExecutorService 不支持在集群环境下自动协调任务,如果应用程序部署在多个节点组成的集群中,可能会导致任务重复执行。
  6. 单点故障:如果使用单个 ScheduledExecutorService 来处理所有的定时任务,并且该服务发生故障,所有的定时任务都将受到影响。

综上所述,ScheduledExecutorService 是一个简单实用的定时任务调度工具,适用于大部分简单的定时任务需求,但在一些特殊的场景下,可能需要使用其他更为高级和灵活的调度框架。

三、Spring Task

Spring Task是 SpringBoot提供的轻量级定时任务工具。

1. 配置

我们通过注解可以很方便的配置,支持 cron 表达式、fixedRate、fixedDelay。

public class SpringTaskTest {

   /**
    * 每分钟的第30秒跑一次
    */
   @Scheduled(cron = "30 * * * * ?")
   public void task1() throws InterruptedException {
      System.out.println("hello cron");
   }

   /**
    * 首次延迟1s执行,后面每隔5秒跑一次
    */
   @Scheduled(initialDelay = 1000, fixedRate = 5000)
   public void task2() throws InterruptedException {
      System.out.println("hello fixedRate");
   }

   /**
    * 上次跑完隔3秒再跑
    */
   @Scheduled(fixedDelay = 3000)
   public void task3() throws InterruptedException {
      System.out.println("hello fixedDelay");
   }
}
2. 触发器Trigger

spring提供了两种触发器,CronTrigger 以及 PeriodicTrigger。

/**
 * cron表达式
 *
 * @return CronTrigger
 */
public static CronTrigger cronTrigger() {
   return new CronTrigger("1/5 * * * * ?");
}

/**
 * 固定间隔/固定延时执行
 *
 * @return PeriodicTrigger
 */
public static PeriodicTrigger periodicTrigger() {
   // 设定每2秒执行一次
   PeriodicTrigger periodicTrigger = new PeriodicTrigger(2, TimeUnit.SECONDS);
   // 初始延迟时间 5s
   periodicTrigger.setInitialDelay(5000);
   // 设置true为固定速率,false为固定延时
   periodicTrigger.setFixedRate(true);
   return periodicTrigger;
}
3. API

通过注解的方式配置定时任务,该任务调度规则即在开发时就被确定下来了。但很多时候,我们需要能够在运行时动态创建任务。Spring内部实际是创建了一个 ThreadPoolTaskScheduler 对象,并注册为spring bean。注解配置的任务最终都会被提交到ThreadPoolTaskScheduler进行调度执行。 ThreadPoolTaskScheduler实现了TaskScheduler接口,我们可以注入TaskScheduler,使用API调用的方式动态的创建任务。

由触发器调度

public void springTaskSchedulerTrigger() {
   // 每 5s执行一次任务
   taskScheduler.schedule(() -> System.out.println("Trigger task"), new CronTrigger("1/5 * * * * ?"));
}

调度一次性任务

/**
 * 在指定时间点startTime执行一次性任务
 * 开始时间为 Instant对象
 */
public void springTaskSchedulerInstant() {
   // 当前时间1分钟后执行一次
   taskScheduler.schedule(() -> System.out.println("Instant startTime task"), Instant.now().atZone(ZoneId.systemDefault()).plusMinutes(1).toInstant());
}

/**
 * 在指定时间点startTime执行一次性任务
 * 开始时间为 Date对象
 */
public void springTaskSchedulerDate() {
   // 当前时间10s后执行一次
   taskScheduler.schedule(() -> System.out.println("Date startTime task"), new Date(System.currentTimeMillis() + 10000L));
}

调度固定速率的可重复执行任务

ScheduledFuture<?> scheduleAtFixedRate(Runnable task, Instant startTime, Duration period):在指定时间点开始执行任务,每隔period执行一次
ScheduledFuture<?> scheduleAtFixedRate(Runnable task, Date startTime, long period):在指定时间点开始执行任务,每隔period毫秒执行一次
ScheduledFuture<?> scheduleAtFixedRate(Runnable task, Duration period):每隔period执行一次
ScheduledFuture<?> scheduleAtFixedRate(Runnable task, long period):每隔period毫秒执行一次

调度固定延时的可重复执行任务

ScheduledFuture<?> scheduleWithFixedDelay(Runnable task, Instant startTime, Duration delay):在指定时间点开始执行任务,每次延迟delay执行一次
ScheduledFuture<?> scheduleWithFixedDelay(Runnable task, Date startTime, long delay):在指定时间点开始执行任务,每次延迟delay毫秒执行一次
ScheduledFuture<?> scheduleWithFixedDelay(Runnable task, Duration delay):每次延迟delay执行一次
ScheduledFuture<?> scheduleWithFixedDelay(Runnable task, long delay):每次延迟delay毫秒执行一次
4. 线程池参数

springboot对于spring-task默认是创建了一个核心线程数为1的线程池。

public class ThreadPoolTaskScheduler extends ExecutorConfigurationSupport
      implements AsyncListenableTaskExecutor, SchedulingTaskExecutor, TaskScheduler {

   private volatile int poolSize = 1;
   super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue(),threadFactory, handler);
}

如果想更深度定制线程池,也可以自己实现TaskScheduler接口,并注册为Bean:

@Bean(destroyMethod = "shutdown", name = "taskScheduler")
public ThreadPoolTaskScheduler taskScheduler() {
   ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
   scheduler.setPoolSize(10);
   scheduler.setThreadNamePrefix("job-schedule-");
   scheduler.setAwaitTerminationSeconds(60);
   scheduler.setWaitForTasksToCompleteOnShutdown(true);
   return scheduler;
}

四、Quartz

Quartz 是一套轻量级的任务调度框架,由Java编写。

我们只需要定义了 Job(任务),Trigger(触发器)和 Scheduler(调度器),即可实现一个定时调度能力。

image.png

五、XXL-JOB