使用 Java 来调度定时任务时,我们经常会使用 Timer 类搞定。Timer 简单易用,其源码阅读起来也非常清晰,本节我们来仔细分析一下 Timer 类,来看看 JDK 源码的编写者是如何实现一个稳定可靠的简单调度器。
Timer 调度任务有一次性调度和循环调度,循环调度有分为固定速率调度(fixRate)和固定时延调度(fixDelay)。固定速率就好比你今天加班到很晚,但是到了第二天还必须准点到公司上班,如果你一不小心加班到了第二天早上 9 点,你就连休息的时间都没有了。而固定时延的意思是你必须睡够 8 个小时再过来上班,如果你加班到凌晨 6 点,那就可以下午过来上班了。固定速率强调准点,固定时延强调间隔。
Timer timer = new Timer(); TimerTask task = new TimerTask() { public void run() { System.out.println("wtf"); } }; // 延迟 1s 打印 wtf 一次 timer.schedule(task, 1000) // 延迟 1s 固定时延每隔 1s 周期打印一次 wtf timer.schedule(task, 1000, 1000); // 延迟 1s 固定速率每隔 1s 周期打印一次 wtf timer.scheduleAtFixRate(task, 1000, 1000) 复制代码
如果你有一个任务必须每天准点调度,那就应该使用固定速率调度,并且要确保每个任务执行时间不要太长,千万别超过了第二天这个点。如果你有一个任务需要每隔几分钟跑一次,那就使用固定时延调度,它不是很在乎你的单个任务要跑多长时间。
Timer 类里包含一个任务队列和一个异步轮训线程。任务队列里容纳了所有待执行的任务,所有的任务将会在这一个异步线程里执行,切记任务的执行代码不可以抛出异常,否则会导致 Timer 线程挂掉,所有的任务都没得执行了。单个任务也不易执行时间太长,否则会影响任务调度在时间上的精准性。比如你一个任务跑了太久,其它等着调度的任务就一直处于饥饿状态得不到调度。所有任务的执行都是这单一的 TimerThread 线程。
class Timer { TaskQueue queue = new TaskQueue(); TimerThread thread = new TimerThread(queue); } 复制代码
Timer 的任务队列 TaskQueue 是一个特殊的队列,它内部是一个数组。这个数组会按照待执行时间进行堆排序,堆顶元素总是待执行时间最小的任务。轮训线程会每次轮训出时间点最近的并且到点的任务来执行。数组会自动扩容,如果任务非常多。
class TaskQueue { TimerTask[] queue = new TimerTask[128]; int size; } 复制代码
任意线程都可以通过 Timer.schedule 方法将任务加入 TaskQueue,但是 TaskQueue 又并不是线程安全的数据结构。所在每次修改 TaskQueue 时都需要加锁。
synchronized(queue) { ... } 复制代码
TimerTask 有 4 个状态,VIRGIN 是默认状态,刚刚实例化还没有被调度。SCHEDULED 表示已经将任务塞进 TaskQueue 等待被执行。EXECUTED 表示任务已经执行完成。CANCELLED 表示任务被取消了,还没来得及执行就被人为取消了。
abstract class TimerTask { int state = VIRGIN; static final int VIRGIN = 0; static final int SCHEDULED = 1; static final int EXECUTED = 2; static final int CANCELLED = 3; long nextExecutionTime; // 下次执行时间 long period = 0; // 间隔 } 复制代码
对于一个循环任务来说,它不存在 EXECUTED 状态,因为它每次刚刚执行完成,就被重新调度了。EXECUTED 状态仅仅存在于一次性任务,而且这个状态其实并不是表示任务已经执行完成,它是指已经从任务队列里摘出来了,马上就要执行。
任务间隔字段 period 比较特殊,当使用固定速率时,period 为正值,当使用固定间隔时,period 为负值,当任务是一次性时,period 为零。下面是循环任务的下次调度时间设定
currentTime = System.currentTimeMillis(); executionTime = task.nextExecutionTime; // 固定时延基于 currentTime 顺延 // 固定速率基于 executionTime(设定时间) 顺延 // next_exec_time = exec_time + period = first_delay + n * period queue.rescheduleMin( task.period<0 ? currentTime - task.period : executionTime + task.period); 复制代码
对于固定速率来说,如果任务执行时间太长超出了间隔,那么它可能会持续霸占任务队列,因为它的调度时间将总是低于 currentTime,排在堆顶,每次轮训取出来的都是它。运行完毕后,重新调度这个任务,它的时间依旧赶不上。持续下去你会看到这个任务的调度时间远远落后于当前时间,而其它任务可能会彻底饿死。这就是为什么一定要特别注意固定速率的循环任务运行时间不宜过长。
Timer 的任务支持取消操作,取消任务的线程和执行任务的线程极有可能不是一个线程。有可能任务正在执行中,结果另一个线程表示要取消任务。这时候 Timer 是如何处理的呢?在 TimerTask 类里看到了一把锁。当任务属性需要修改的时候,都会加锁。
abstract class TimerTask { final Object lock = new Object(); } // 取消任务 public boolean cancel() { synchronized(lock) { boolean result = (state == SCHEDULED); state = CANCELLED; return result; } } // 调度任务 private void sched(TimerTask task, long time, long period) { synchronized(task.lock) { if (task.state != TimerTask.VIRGIN) throw new IllegalStateException( "Task already scheduled or cancelled"); task.nextExecutionTime = time; task.period = period; task.state = TimerTask.SCHEDULED; } } // 运行任务 private void mainLoop() { while(true) { synchronized(task.lock) { if (task.state == TimerTask.CANCELLED) { queue.removeMin(); continue; } ... if(task.period == 0) { task.state = TimerTask.EXECUTED; } ... } task.run(); } } 复制代码
在任务运行之前会检查任务是不是已经被取消了,如果取消了,就从队列中移除。一旦任务开始运行 run(),对于单次任务来说它就无法被取消了,而循环任务将不会继续下次调度。如果任务没有机会得到执行(时间设置的太长),那么即使这个任务被取消了,它也会一直持续躺在任务队列中。设想如果你调度了一系列久远的任务,然后都取消了,这可能会成为一个内存泄露点。所以 Timer 还单独提供了一个 purge() 方法可以一次性清空所有的已取消的任务。
public int purge() { int result = 0; // 灭掉 CANCELLED 状态的任务 synchronized(queue) { for (int i = queue.size(); i > 0; i--) { if (queue.get(i).state == TimerTask.CANCELLED) { queue.quickRemove(i); result++; } } } // 堆调整 if (result != 0) queue.heapify(); } return result; } 复制代码
任务队列里没有任务了,调度线程必须按一定的策略进行睡眠。它需要睡眠一直到最先执行的任务到点时立即醒来,所以睡眠截止时间就是第一个任务将要执行的时间。同时在睡觉的时候,有可能会有新的任务被添加进来,它的调度时间可能会更加提前,所以当有新的任务到来时需要可以唤醒正在睡眠的线程。
private void mainLoop() { while(true) { ... task = queue.getMin(); currentTime = System.currentTimeMillis(); executionTime = task.nextExecutionTime; if(executionTime > currentTime) { // 开始睡大觉 queue.wait(executionTime - currentTime); } ... } } // 新任务进来了 private void sched(TimerTask task, long time, long period) { ... queue.add(task); if (queue.getMin() == task) queue.notify(); // 唤醒轮训线程 } 复制代码
代码中的 wait() 方法就是调用了 Object.wait() 来进行睡眠。当有新任务进来了,发现这个新任务的运行时间是最早的,那就调用 notify() 方法唤醒轮训线程。
Timer 提供了 cancel() 方法清空队列,停止调度器,不允许有任何新任务进来。它会将 newTasksMayBeScheduled 字段设置为 false 表示 Timer 即将终止。
class TimerThread { ... boolean newTasksMayBeScheduled; // 终止的标志 ... } public void cancel() { synchronized(queue) { thread.newTasksMayBeScheduled = false; queue.clear(); queue.notify(); } } 复制代码
如果 Timer 终止了,还有新任务进来就会抛出异常。
private void sched(TimerTask task, long time, long period) { synchronized(queue) { if (!thread.newTasksMayBeScheduled) throw new IllegalStateException("Timer already cancelled."); ... } } 复制代码
我们还注意到 Timer.cancel() 方法会唤醒轮训线程,为的是可以立即停止轮训。不过如果任务正在执行中,这之后 cancel() 就必须等到任务执行完毕才可以停止。
private void mainLoop() { while(true) { // 正常清空下,队列空了,轮训线程会休眠 // 但是如果 newTasksMayBeScheduled 为 false // 那么循环会退出,轮训线程会终止 while (queue.isEmpty() && newTasksMayBeScheduled) queue.wait(); if (queue.isEmpty()) break; ... } } 复制代码
还有一个特殊的场景需要特别注意,那就是当轮训线程因为队列里没有任务而睡眠的时候,Timer 对象因为不再被引用而被垃圾回收了。这时候需要主动唤醒轮训线程,让它退出。
class Timer { ... private final Object threadReaper = new Object() { @SuppressWarnings("deprecation") protected void finalize() throws Throwable { synchronized(queue) { thread.newTasksMayBeScheduled = false; queue.notify(); } } }; ... } 复制代码
当 Timer 被回收时,内部字段 threadPeaper 指向的对象也会被回收。所以 finalize 方法将会被调用,唤醒并终止 Timer 轮训线程。如果没有这个 threadPeaper 对象就可能会导致 JVM 里留下僵尸线程。
阅读更多精品文章,微信扫一扫上面的二维码关注公众号「码洞」