1. 标准库中的定时器
所谓的定时器,其实就是闹钟。我们可以设定一个时间,当这个时间到了,就可以执行一个指定的代码。java 标准库中提供的定时器:java.util.Timer。代码演示:
public class ThreadTest6 {
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello");
}
}, 2000);
}
}
Timer 的核心方法是 schedule,该方法有两个参数,第一个参数就是我们准备执行的任务,第二个参数就是时间,即过多久执行任务。
这里我们使用了 new TimerTask 来表示一个任务,这个 TimerTask 本质上是一个 Runnable,我们可以看看其源码:
由此可见 TimerTask 是一个实现了 Runnable 接口的抽象类,所以我们还是要重写 run 方法,这个 run 方法的内容也就是我们要执行的任务。
我们执行代码:
我们可以很直观的看到,隔了 2s 打印了 hello。
此时细心的朋友会发现,我们这个程序并没有退出,还在继续执行。原因是 Timer 里头内置了前台线程,前台线程会阻止进程结束。其实 run 方法的执行是依靠 Timer 内部的线程在时间到了之后执行的!
2. 自己实现一个定时器
我们要向自己实现一个定时器,肯定要参考 java 标准库的效果,然后我们发现,定时器,内部管理的不仅仅是一个任务,可以管理很多任务的,所以我们要把这一点考虑进去。
实现这个管理多个任务,最容易想到的方法就是创建多个线程,每个任务对应一个线程,但是我们在进一步想想就觉得这个方法不好,因为万一有1w个任务,我们创建1w个线程不合适。此时继续思考,聪明的我们就能意识到,虽然任务可能很多,但是他们的出发时间是不同的没所以说我们其实只需要 一个/一组 工作线程,每次都找到这些任务中离执行时间最近的任务就可以,也就是说一个线程先执行最早的任务,做完了再执行第二早的文物… 时间到了就执行,没到就再等等。所以说我们就需要知道最小的时间,这个时候我们又是很容易的想到排序,排序确实可以解决问题,但是排序效率比较低,而且如果出现插入操作也很麻烦,那么更高效方便的方法就是用堆,即PriorityQueue。除此之外,我们还希望在多线程操作下,优先级队列还能线程安全,而标准库中提供了带优先级的阻塞队列,PriorityBlockingQueue。
下面进行代码实现:
// 表示一个任务
class MyTask{
public Runnable runnable;
// 为了后续判定方便,我们使用绝对的时间戳
public long time;
public MyTask(Runnable runnable, long time) {
this.runnable = runnable;
// 取当前时间戳 + time,作为任务实际执行的时间戳
this.time = System.currentTimeMillis() + time;
}
}
class MyTimer{
PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
public void schedule(Runnable runnable, long time) {
MyTask task = new MyTask(runnable,time);
queue.put(task);
}
// 创建线程,负责执行任务
public MyTimer(){
Thread t = new Thread(() -> {
while (true) {
try {
MyTask task = queue.take();
long curTime = System.currentTimeMillis();
if (task.time <= curTime) {
// 时间到了,执行任务
task.runnable.run();
}else {
// 时间没到,将任务放回队列
queue.put(task);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
代码写到这里其实逻辑已经很清晰明了了,但是还存在两个非常严重的 bug,也算是细节方面还没有完全处理好:
- 优先级队列缺少比较规则,我们说了根据时间,但是显然我们并没有实现。
- 忙等:确实是在等,但是 CPU 没捞到休息(等待过程占用着 cpu)。举个例子,假设现在是12:00,在12:30的时候要做一件事,我们在12:00是看一下表,时间没到,然后立即有看一下表,又没到,然后…,所以我们一直在看表,没有休息,。这是不科学的,所以我们就需要在等待过程中释放 CPU。
下面我们就来完善一下代码:
// 表示一个任务
class MyTask implements Comparable<MyTask> {
public Runnable runnable;
// 为了后续判定方便,我们使用绝对的时间戳
public long time;
public MyTask(Runnable runnable, long time) {
this.runnable = runnable;
// 取当前时间戳 + time,作为任务实际执行的时间戳
this.time = System.currentTimeMillis() + time;
}
@Override
public int compareTo(MyTask o) {
return (int)(this.time - o.time);
}
}
class MyTimer{
PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
private Object locker = new Object();
public void schedule(Runnable runnable, long time) {
MyTask task = new MyTask(runnable,time);
queue.put(task);
synchronized (locker) {
locker.notify();
}
}
// 创建线程,负责执行任务
public MyTimer(){
Thread t = new Thread(() -> {
while (true) {
try {
synchronized (locker) {
MyTask task = queue.take();
long curTime = System.currentTimeMillis();
if (task.time <= curTime) {
// 时间到了,执行任务
task.runnable.run();
}else {
// 时间没到,将任务放回队列
queue.put(task);
wait();
locker.wait(task.time - curTime);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
} MyTask task = new MyTask(runnable,time);
queue.put(task);
synchronized (locker) {
locker.notify();
}
}
// 创建线程,负责执行任务
Thread t = new Thread(() -> {
while (true) {
try {
synchronized (locker) {
MyTask task = queue.take();
long curTime = System.currentTimeMillis();
if (task.time <= curTime) {
// 时间到了,执行任务
task.runnable.run();
}else {
// 时间没到,将任务放回队列
queue.put(task);
locker.wait(task.time - curTime);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
}
比较器很简单,不用多说。而为什么我们使用 wait 等待而不用 sleep呢,因为 wait 可以随时唤醒,假设在等待过程中插入了一个新的任务,这个新任务的时间更快,那么 notify 就可以唤醒 wait,下次在从队列里拿任务就会拿到更快的这个任务而如果使用 sleep,就会睡眠 设定好的时间,如果在这期间插入了新的、更快的任务,就会导致这个任务执行晚了,出现 bug。
我们在 main 方法中使用一下自己写的定时器,看看效果:
public class ThreadTest7 {
public static void main(String[] args) {
MyTimer timer = new MyTimer();
timer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hello1");
}
}, 1000);
timer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hello3");
}
}, 3000);
timer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hello4");
}
}, 4000);
timer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hello2");
}
}, 2000);
}
}
运行结果:
very good。
但是还没有结束,我们知道使用 wait 要搭配 synchronized,那么为什么我们 synchronized 要放在我们代码中的这个位置:
Thread t = new Thread(() -> {
while (true) {
try {
synchronized (locker) {
MyTask task = queue.take();
long curTime = System.currentTimeMillis();
if (task.time <= curTime) {
// 时间到了,执行任务
task.runnable.run();
}else {
// 时间没到,将任务放回队列
queue.put(task);
locker.wait(task.time - curTime);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
如果加在这里会怎么样:
Thread t = new Thread(() -> {
while (true) {
try {
MyTask task = queue.take();
long curTime = System.currentTimeMillis();
if (task.time <= curTime) {
// 时间到了,执行任务
task.runnable.run();
} else {
// 时间没到,将任务放回队列
queue.put(task);
synchronized (locker) {
locker.wait(task.time - curTime);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
加在这里可能产生 bug,假设当前时间为 12:00,现在最近的一个任务1是 12:30,那么就有可能出现这样一种情况:
此时当线程1执行完 put 操作,还没进行 wait 时,由于线程调度的随机性,很有可能就把这个线程1调度走了,然后此时又有另一个任务2插进来,这个任务2的执行时间为 12:15,此时由于插入任务2而引起的 notify 操作相当于空打一炮,没有作用,因为 线程1 还没有 wait:
当线程1调度回来继续执行时,此时 进入 wait 的时间是 12:30 - 12:00 = 30分钟,而不是 15 分钟,就会导致任务2错过正确执行时间。但是我们一开始的 synchronized 的位置就可以保证代码从 take 到 wait 这一段都是原子的:
此时即使出现上述情况,线程2也不能执行 notify,因为锁在 线程1这里没有被释放,所以线程2会阻塞等待锁,只有线程1释放了锁,线程2才能执行notify,但是此时线程1已经处于 wait 状态了,即 notify 不会出现空打一炮的情况。