在 Java 应用程序中安排重复性任务
java.util.Timer
和java.util.TimerTask
类(我将二者统称为Java的定时器框架)使得程序员可以轻松地安排简单的任务。(请注意,这些类在 J2ME 中也可用。)在 Java 2 SDK 标准版 1.3 版中引入此框架之前,开发人员必须编写自己的调度程序,这涉及处理线程和Object.wait()
方法的复杂性。但是,Java 定时器框架不够丰富,无法满足许多应用程序的调度需求。即使是需要每天同时重复的任务也不能直接使用Timer
进行调度,因为夏令时的来来往往会发生时间跳跃。
本文介绍了一个调度框架,它是对Timer
和TimerTask
的推广,允许更灵活的调度。该框架非常简单——它由两个类和一个接口组成——而且很容易学习。如果你习惯于使用 Java 计时器框架,那么你应该能够很快掌握调度框架。)
安排一次性任务
调度框架构建在 Java 计时器框架类之上。因此,在解释调度框架的使用方式和实现方式之前,我们将首先了解如何使用这些类进行调度。
想象一个鸡蛋计时器,它通过播放声音告诉您何时过去了数分钟(因此你的鸡蛋已煮熟)。清单 1 中的代码构成了用 Java 语言编写的简单的鸡蛋计时器的基础:
清单 1. EggTimer 类
package org.tiling.scheduling.examples;
import java.util.Timer;
import java.util.TimerTask;
public class EggTimer {
private final Timer timer = new Timer();
private final int minutes;
public EggTimer(int minutes) {
this.minutes = minutes;
}
public void start() {
timer.schedule(new TimerTask() {
public void run() {
playSound();
timer.cancel();
}
private void playSound() {
System.out.println("Your egg is ready!");
// Start a new thread to play a sound...
}
}, minutes ∗ 60 ∗ 1000);
}
public static void main(String[] args) {
EggTimer eggTimer = new EggTimer(2);
eggTimer.start();
}
}
一个EggTimer
实例拥有一个Timer
实例来提供必要的调度。当使用start()
方法启动鸡蛋计时器时,它会安排 aTimerTask
在指定的分钟数后执行。当时间到时, 上的run()
方法TimerTask
由Timer
幕后调用,使其播放声音。然后应用程序在定时器被取消后终止。
安排重复性任务
Timer允许通过指定固定的执行速率或执行之间的固定延迟来安排任务重复执行。但是,有许多应用程序具有更复杂的调度要求。例如,每天早上在同一时间响起叫醒电话的闹钟不能简单地使用 86400000 毫秒(24 小时)的固定速率时间表,因为在时钟前进的日子里,闹钟会太晚或太早或向后(如果您的时区使用夏令时)。解决方案是使用日历算法来计算每日事件的下一次预定发生。这正是调度框架所支持的。考虑AlarmClock 清单 2 中的实现(请参阅相关链接以下载调度框架的源代码,以及包含框架和示例的 JAR 文件):
清单 2. AlarmClock 类
package org.tiling.scheduling.examples;
import java.text.SimpleDateFormat;
import java.util.Date;
import org.tiling.scheduling.Scheduler;
import org.tiling.scheduling.SchedulerTask;
import org.tiling.scheduling.examples.iterators.DailyIterator;
public class AlarmClock {
private final Scheduler scheduler = new Scheduler();
private final SimpleDateFormat dateFormat =
new SimpleDateFormat("dd MMM yyyy HH:mm:ss.SSS");
private final int hourOfDay, minute, second;
public AlarmClock(int hourOfDay, int minute, int second) {
this.hourOfDay = hourOfDay;
this.minute = minute;
this.second = second;
}
public void start() {
scheduler.schedule(new SchedulerTask() {
public void run() {
soundAlarm();
}
private void soundAlarm() {
System.out.println("Wake up! " +
"It's " + dateFormat.format(new Date()));
// Start a new thread to sound an alarm...
}
}, new DailyIterator(hourOfDay, minute, second));
}
public static void main(String[] args) {
AlarmClock alarmClock = new AlarmClock(7, 0, 0);
alarmClock.start();
}
}
请注意代码与鸡蛋计时器应用程序的相似程度。AlarmClock实例拥有Scheduler实例(而不是一个Timer)提供必要的调度。启动时,闹钟会安排 a SchedulerTask(而不是 a TimerTask)来播放闹钟。而不是在固定延迟后安排任务执行,闹钟使用一个DailyIterator类来描述它的时间表。在这种情况下,它只是在每天早上 7:00 安排任务。这是典型运行的输出:
Wake up! It's 24 Aug 2003 07:00:00.023
Wake up! It's 25 Aug 2003 07:00:00.001
Wake up! It's 26 Aug 2003 07:00:00.058
Wake up! It's 27 Aug 2003 07:00:00.015
Wake up! It's 28 Aug 2003 07:00:00.002
...
DailyIterator
实现ScheduleIterator
接口,该接口将SchedulerTask
的计划执行时间指定为一系列java.util.Date
对象。然后,next()
方法按时间顺序迭代对象。返回值null
会导致任务被取消(也就是说,它永远不会再次运行)——实际上,重新调度的尝试将导致抛出异常。清单 3 包含ScheduleIterator接口:
清单 3. ScheduleIterator 接口
package org.tiling.scheduling;
import java.util.Date;
public interface ScheduleIterator {
public Date next();
}
DailyIterator的next()
方法返回Date表示每天同一时间(上午 7:00)的对象,如清单 4 所示。因此,如果你调用next()
一个新构造的DailyIterator类,您将获得该日期当天或之后的当天上午 7:00传入构造函数。随后的调用next()
将在随后几天的上午 7:00 返回,并永远重复。要实现此行为,请DailyIterator使用java.util.Calendar实例。构造函数设置日历,以便第一次调用next()返回正确的,Date只需在日历上添加一天。请注意,该代码没有明确提及夏令时修正;它不需要,因为Calendar实现(在这种情况下GregorianCalendar)会处理这个问题。
清单 4. DailyIterator 类
package org.tiling.scheduling.examples.iterators;
import org.tiling.scheduling.ScheduleIterator;
import java.util.Calendar;
import java.util.Date;
/∗∗
∗ A DailyIterator class returns a sequence of dates on subsequent days
∗ representing the same time each day.
∗/
public class DailyIterator implements ScheduleIterator {
private final int hourOfDay, minute, second;
private final Calendar calendar = Calendar.getInstance();
public DailyIterator(int hourOfDay, int minute, int second) {
this(hourOfDay, minute, second, new Date());
}
public DailyIterator(int hourOfDay, int minute, int second, Date date) {
this.hourOfDay = hourOfDay;
this.minute = minute;
this.second = second;
calendar.setTime(date);
calendar.set(Calendar.HOUR_OF_DAY, hourOfDay);
calendar.set(Calendar.MINUTE, minute);
calendar.set(Calendar.SECOND, second);
calendar.set(Calendar.MILLISECOND, 0);
if (!calendar.getTime().before(date)) {
calendar.add(Calendar.DATE, ‑1);
}
}
public Date next() {
calendar.add(Calendar.DATE, 1);
return calendar.getTime();
}
}
实现调度框架
在上一节中,我们学习了如何使用调度框架,并将其与 Java 定时器框架进行了比较。接下来,我将向你展示该框架是如何实现的。除了ScheduleIterator
在显示界面清单3中,还有另外两个类-Scheduler
和SchedulerTask
-组成的框架。这些类实际上在封面下使用Timer
和TimerTask
,因为日程实际上只不过是一个系列的一次性计时器。清单 5 和
6 显示了这两个类的源代码:
清单 5. 调度程序
package org.tiling.scheduling;
import java.util.Date;
import java.util.Timer;
import java.util.TimerTask;
public class Scheduler {
class SchedulerTimerTask extends TimerTask {
private SchedulerTask schedulerTask;
private ScheduleIterator iterator;
public SchedulerTimerTask(SchedulerTask schedulerTask,
ScheduleIterator iterator) {
this.schedulerTask = schedulerTask;
this.iterator = iterator;
}
public void run() {
schedulerTask.run();
reschedule(schedulerTask, iterator);
}
}
private final Timer timer = new Timer();
public Scheduler() {
}
public void cancel() {
timer.cancel();
}
public void schedule(SchedulerTask schedulerTask,
ScheduleIterator iterator) {
Date time = iterator.next();
if (time == null) {
schedulerTask.cancel();
} else {
synchronized(schedulerTask.lock) {
if (schedulerTask.state != SchedulerTask.VIRGIN) {
throw new IllegalStateException("Task already
scheduled " + "or cancelled");
}
schedulerTask.state = SchedulerTask.SCHEDULED;
schedulerTask.timerTask =
new SchedulerTimerTask(schedulerTask, iterator);
timer.schedule(schedulerTask.timerTask, time);
}
}
}
private void reschedule(SchedulerTask schedulerTask,
ScheduleIterator iterator) {
Date time = iterator.next();
if (time == null) {
schedulerTask.cancel();
} else {
synchronized(schedulerTask.lock) {
if (schedulerTask.state != SchedulerTask.CANCELLED) {
schedulerTask.timerTask =
new SchedulerTimerTask(schedulerTask, iterator);
timer.schedule(schedulerTask.timerTask, time);
}
}
}
}
}
清单 6 显示了SchedulerTask该类的源代码:
清单 6. SchedulerTask
package org.tiling.scheduling;
import java.util.TimerTask;
public abstract class SchedulerTask implements Runnable {
final Object lock = new Object();
int state = VIRGIN;
static final int VIRGIN = 0;
static final int SCHEDULED = 1;
static final int CANCELLED = 2;
TimerTask timerTask;
protected SchedulerTask() {
}
public abstract void run();
public boolean cancel() {
synchronized(lock) {
if (timerTask != null) {
timerTask.cancel();
}
boolean result = (state == SCHEDULED);
state = CANCELLED;
return result;
}
}
public long scheduledExecutionTime() {
synchronized(lock) {
return timerTask == null ? 0 : timerTask.scheduledExecutionTime();
}
}
}
就像鸡蛋定时器一样,调度器的每个实例都拥有一个计时器的实例,以提供底层调度。与用于实现鸡蛋计时器的单一一次性计时器不同,调度器将一次性计时器串连在一起,以ScheduleIterator
指定的时间执行SchedulerTask
类。
考虑调度器公共的schedule()
方法——这是调度的入口点,因为它是客户端调用的方法。(唯一的其他公共方法cancel()
,在Canceling tasks
中介绍。)所述的第一次执行的时间SchedulerTask,通过调用ScheduleIterator
接口上的next()
方法。然后通过调用底层Timer类上的one-shot schedule()
方法启动调度,一边在此时执行。为一次性执行提供的TimerTask
对象是嵌套SchedulerTimerTask
类的一个实例,它打包了任务和迭代器。在分配的时间内,run()
方法在嵌套类上调用,它使用打包的任务和迭代器引用来重新安排任务的下一次执行。reschedule()
方法与schedule()
方法非常相似,不同之处在于它是私有的,并且对SchedulerTask
执行一组略有不同的状态检查。重新调度过程无限重复,为每次调度的执行构造一个新的嵌套类实例,直到任务或调度程序被取消(或 JVM 关闭)。
与对应的TimerTask
一样,SchedulerTask
在其生命周期中经历一系列状态。创建时,它处于一种VIRGIN
状态,这意味着它从未被调度过。一旦被调度,它就会转移到一个SCHEDULED
状态,如果任务被下面描述的方法之一取消,则之后会切换到到CANCELLED
状态。管理正确的状态转换,例如确保非VIRGIN
任务不会被调度两次,会增加Scheduler
和SchedulerTask
类的额外复杂性。每当执行可能改变任务状态的操作时,代码必须在任务的锁定对象上同步。
取消任务
取消计划任务的方式有三种。第一种是调用SchedulerTask
上的cancel()
方法。这就像TimerTask
上调用cancel()
:该任务将永远不会再次运行,尽管如果已经运行,它将一直运行到完成。cancel()方法
的返回值是是一个布尔值,用来指示在尚未被调用cancel()
的情况下是否会运行进一步的计划任务。更准确地说,如果任务在调用cancel()
之前立即处于SCHEDULED
状态,它就会返回true
。如果你尝试重新安排已取消(甚至已安排)的任务,Scheduler
则会抛出IllegalStateException
.
取消计划任务的第二种方法是ScheduleIterator
返回null
。这只是第一种方式的快捷方式,因为Scheduler
类调用SchedulerTask
类上的cancel()
。如果你希望迭代器(而不是任务)控制调度何时停止,则以这种方式取消任务很有用。
第三种方式是通过调用它的cancel()
方法来取消整体的Scheduler
。这将取消调度程序的所有任务,并使其处于不能再调度更多任务的状态。
扩展 cron 工具
调度框架可以比作 UNIX cron工具,除了调度时间的规范是命令式控制而不是声明式控制。例如,DailyIterator
类在AlarmClock
实现中使用与cron
作业具有相同的调度,由0 7 * * *
开始的crontab
条目指定。(这些字段分别指定分钟、小时、月中的某一天、月份和星期几。)
但是,调度框架比cron具有更强大的灵活性。 想象一个HeatingController
应用程序在早上打开热水。我想指示它“在工作日的早上 8:00 和周末早上 9:00 打开热水”。使用cron,我需要两个crontab
条目(0 8 * * 1,2,3,4,5
和0 9 * * 6,7
)。通过使用 ScheduleIterator
,解决方案更加优雅,因为我可以使用组合定义单个迭代器。清单 7 显示了一种方法:
清单 7. 使用组合定义单个迭代器
int[] weekdays = new int[] {
Calendar.MONDAY,
Calendar.TUESDAY,
Calendar.WEDNESDAY,
Calendar.THURSDAY,
Calendar.FRIDAY
};
int[] weekend = new int[] {
Calendar.SATURDAY,
Calendar.SUNDAY
};
ScheduleIterator i = new CompositeIterator(
new ScheduleIterator[] {
new RestrictedDailyIterator(8, 0, 0, weekdays),
new RestrictedDailyIterator(9, 0, 0, weekend)
}
);
一个RestrictedDailyIterator
类就像DailyIterator
,除了它被限制在一周的特定日期运行;并且一个CompositeIterator
类采用一组ScheduleIterators
并将日期正确地排序到一个单一的时间表中。
还有很多其他的调度cron不能产生,但是可以实现ScheduleIterator
。例如,“每个月的最后一天”描述的日程安排可以使用标准 Java 日历算法(使用Calendar类)来实现,而使用cron. 应用程序甚至不必使用Calendar该类。在本文的源代码中,我提供了一个安全灯控制器示例,该控制器按照“在日落前 15 分钟开灯”的时间表运行。该实现使用 Calendrical Calculations 软件包,计算本地日落时间(给定纬度和经度)。
实时保证
在编写使用调度的应用程序时,重要的是要了解框架在及时性方面的承诺。我的任务会提前还是推迟执行?如果是这样,最大误差幅度是多少?不幸的是,这些问题没有简单的答案。然而,在实践中,该行为对于一大类应用程序来说已经足够好了。下面的讨论假设系统时钟是正确的(有关网络时间协议的信息,请参阅相关链接)。
因为Scheduler将其调度委托给Timer类,所以Scheduler可以做出的实时保证与Timer. Timer使用该Object.wait(long)方法调度任务。当前线程等待直到被唤醒,这可能是由于以下原因之一:
- 所述
notify()
或notifyAll()
方法被称为通过另一个线程的对象。 - 该线程被另一个线程中断。
- 该线程在没有通知的情况下被唤醒。(虚假唤醒)
- 指定的时间已经过去。
第一种可能性不会发生在Timer类上,因为wait()
被调用的对象是私有的。即便如此,Timer对前三个原因的提前唤醒实施了保障措施,从而确保线程在时间过去后唤醒。现在,文档注释Object.wait(long)
指出它可能会在“或多或少”时间过去后唤醒,因此线程可能会提前唤醒。在这种情况下,Timer发出另一个wait()
为(scheduledExecutionTime - System.currentTimeMillis())
毫秒,从而保证任务永远不能被早期执行。
任务可以延迟执行吗?是的。延迟执行的主要原因有两个:线程调度和垃圾收集。
Java 语言规范在线程调度上故意含糊其辞。这是因为 Java 平台是通用的,面向广泛的硬件和相关操作系统。虽然大多数 JVM 实现都有一个公平的线程调度程序,但这并不能保证——当然,实现有不同的策略来为线程分配处理器时间。因此,当一个Timer线程在其分配的时间后唤醒时,它实际执行任务的时间取决于 JVM 的线程调度策略,以及有多少其他线程在争用处理器时间。因此,为了减少延迟任务执行,您应该最大限度地减少应用程序中可运行线程的数量。值得考虑在单独的 JVM 中运行调度程序来实现这一点。
JVM 执行垃圾收集 (GC) 所花费的时间对于创建大量对象的大型应用程序来说可能很重要。默认情况下,当 GC 发生时,整个应用程序必须等待它完成,这可能需要几秒钟或更长时间。(命令行选项-verbose:gc的java应用程序启动器将导致每个 GC 事件都报告到控制台。)为了尽量减少由于 GC 引起的暂停,这可能会阻碍即时任务执行,您应该尽量减少应用程序创建的对象数量。同样,这有助于在单独的 JVM 中运行您的调度代码。此外,您可以尝试使用多种优化选项来最小化 GC 暂停。例如,增量 GC 试图将主要收集的成本分散到几个次要收集上。代价是这会降低 GC 的效率,但对于更及时的调度来说,这可能是一个可以接受的代价。
什么时候调用
为了确定任务是否正在及时运行,如果任务本身监视和记录任何延迟执行的实例会有所帮助。SchedulerTask,如TimerTask,有一个scheduledExecutionTime()
方法返回最近一次执行此任务的时间。评估System.currentTimeMillis()
- scheduledExecutionTime()
任务开始时的表达式run()方法可让您确定任务执行的延迟时间(以毫秒为单位)。可以记录此值以生成有关延迟执行分布的统计信息。该值还可用于决定任务应该采取什么操作——例如,如果任务太晚,它可能什么都不做。如果在遵循上述指南后, 你的应用程序需要更严格的及时性保证,请考虑查看
Java 实时规范。
结论
在本文中,我介绍了对 Java 计时器框架的简单增强,它允许非常灵活的调度策略。新框架本质上是对cron——事实上,cron作为一个ScheduleIterator
接口来实现以提供纯 Javacron替代品是有价值的。虽然不提供严格的实时保证,但该框架适用于需要定期调度任务的大量通用 Java 应用程序。