目录
前言
定时器就类似于定闹钟,我们平时的闹钟,有两种风格:
- 指定特定时刻提醒
- 指定特定时间段后提醒
我们所说的定时器,不是提醒,而是执行一个事先准备好的代码/方法.定时器是我们日常开发中一个常用的组件,尤其是针对网络编程的时候,很容易出现卡顿,连接不上的情况,此时我们就可以使用定时器,来进行及时止损.
标准库的定时器
和阻塞队列类似,Java标准库里,也为我们提供了定时器.
这个Timer类就是标准库给我们提供的定时器.
调用schedule方法就可以实现定时器的效果.schedule翻译过来就是安排的意思.
schedule方法有两个参数,
第一个参数new TimerTask()其实就是个runnable,指明任务的内容,第二个参数,指定时间,在多少毫秒后,执行任务.
运行这样一段代码
可以看到代码的执行顺序是正确的.
实现一个定时器
设计思路
实现一个定时器,有两个问题需要解决:
1.让被注册的任务,能够在指定时间,被执行
2.一个定时器可以注册N个任务,N个任务会按照最初的约定时间,按顺序执行.
针对第一个问题:我们可以单独在定时器内部,实现一个线程.让这个线程周期性的扫描任务列表,判定任务是否是到时间了,如果到时间了,就执行任务;没到时间,就在等等.
针对第二个问题:我们首先要考虑的是应该用哪种数据结构来保存这N个任务比较合适.思考一下,我们不难发现,在此场景下,使用优先级队列,是一个很好的选择!因为任务列表里的每个任务,都带有一个时间(表示任务在多久之后被执行),一定是时间越靠前的,越先被执行.那么我们可以按照时间小的,作为优先级高的,此时队首元素,就是整个任务队列中,最先被执行的任务.
我们采用了优先级队列后,扫描线程就无需每次都遍历任务列表,只需要扫描队首元素即可.(因为如果队首元素的执行时间还没到,后续元素不可能到执行时间)
经过上述讨论,总结出定时器里的两个核心:
1.有一个扫描线程,负责判定时间到没到
2.有一个数据结构,来保存所有被注册的任务.(使用一个优先级队列来表示)
值得注意的是:此处的优先级队列,会在多线程的环境下使用.(很明显,调用schedule是一个线程,扫描是另一个线程),所以我们还要关注线程安全问题. 因此,标准库提供的PriorityBlockingQueue是很好的选择.
代码思路
任务的表示,我们自定义一个MyTask类.里面包含了要被执行的任务,和任务执行的时间.
定时器里,还需要一个"schedule"方法,来注册任务
schedule本身比较简单,只是单纯的把任务放到队列里了.
定时器真正麻烦在扫描线程的工作上:
阻塞队列,只能先把元素出队列,才好判定,不满足还要放回队列当中去,不像普通队列,可以直接取队首元素判定.
上述代码,是不完整的,里面还包含两个问题:
1.没有指定MyTask的比较规则
2.忙等问题
针对第一个问题:可以让我们的MyTask类实现Comparable接口,重写compareTo方法来实现.(也可以使用Comparator单独写一个比较器).
针对第二个问题:
"忙等":时间没到, 就会一直重复做取出来塞回去的操作.
这种现象就叫做"忙等",等但是没闲着.
正常情况下,等待就是要释放掉CPU资源,让CPU干别的事情.但是忙等,即进行了等待,还占用着CPU资源.
注意:我们的队列是优先级队列(堆实现),put放回去,会触发优先级调整(堆的向上调整),调整之后,myTask又回到队首了,下次循环取出的任务还是这个任务.
假设现在是13:00,而队首元素的任务时间是14:00执行,取出的元素,是不能执行的,那么在这个时间段里,这个循环可能要执行数以十亿次.
像忙等这样的情况,也是需要辩证看待的.但是在当前的场景下,确实是不太好的(无意义的占用CPU资源).有的情况下,忙等,也是一个好的选择.所以要根据场景辩证看待"忙等".
所以,上述场景下,针对上述代码,就不再进行忙等了,而是进行"阻塞式"等待.
那么问题又来了,阻塞式等待,我们是采用sleep还是wait呢??
此处的等待,如果当前是13:00,任务执行时间是14:00,那么等待一个小时就行了.此处看似等待时间是明确的,但实际上并不明确.因为随时可能有新任务的到来(随时可能有线程调用schedule方法添加新任务,添加的新任务的时间,可能早于当前队首元素的时间).
所以使用sleep,就不太合适了,如果使用sleep,那么在sleep等待过程中,添加的新任务是13:30的,此时依旧按照sleep一个小时来等待,那么这个新任务就被错过了.
因此,使用wait更加合适,能够方便随时唤醒等待.
使用wait等待,每次新任务来了(有人调用schedule),就notify一下,重新检查一下时间,重新计算要等待的时间,并且,wait也提供了一个带有"超时时间"的版本.
扫描线程里:
schedule方法里:
上述代码经过改动以后,还存在一个很严重的关于线程安全/随机调度的问题.
我们考虑一个极端情况:
假设代码执行到put这一行,这个线程就从cpu上调度走了,当线程回来之后,接下里就要进行wait操作了,此时wait的时间已经算好了.比如curTime是13:00,任务getTime是14:00,即将要wait一个小时(此时还没有执行,当前线程就从cpu上调度走了).
此时另一个线程调用了schedule,添加了新任务,新任务的执行时间是13:30.
此处调用schedule会执行notify通知wait唤醒.但由于扫描线程中的wait还没有执行到呢,所以此处的notify只是"空打一炮",不会产生任何的唤醒操作!!!
此时此刻,新的任务已经插入到了队列当中,新的任务由于时间最小,所以调整到了队首,紧接着扫描线程回到了cpu,但此时的等待时间还是一个小时.故,13:30的新任务就被错过了!!!
了解了上述问题之后,我们发现,造成上述问题的原因,是因为take操作和wait操作并非是原子的.如果将take和wait加上锁,保证在这个过程中,不会有新任务插入进来.问题就解决了.
(只要保证每次notify时,确实是在wait)
完整代码:
import java.util.concurrent.PriorityBlockingQueue;
//使用这个类来表示定时器中的任务
class MyTask implements Comparable<MyTask>{
//要执行的任务
private Runnable runnable;
//任务执行的时间(使用毫秒时间戳表示)
private long time;
public MyTask(Runnable runnable, long time) {
this.runnable = runnable;
this.time = time;
}
//获取当前任务的时间
public long getTime(){
return time;
}
//执行任务
public void run(){
runnable.run();
}
@Override
public int compareTo(MyTask o) {
//返回小于0,大于0,0
//this 比 o 小,返回 < 0
//this 比 o 大,返回 > 0
//this 和 o 相等,返回 0
//当前要实现的效果,是队首元素是时间最小的任务
//所以是this.time - o.time
return (int) (this.time-o.time);
}
}
//自己实现一个定时器
class MyTimer{
//扫描线程
private Thread t = null;
//有一个阻塞优先级队列,来保存任务
private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
public MyTimer(){
t = new Thread(()->{
while (true) {
try {
//取出队首元素,检查查看队首元素的任务是否到时间了
//如果时间没到,就把任务在塞回到队列当中去
//如果时间到了,就执行任务
synchronized (this) {
MyTask myTask = queue.take();
long curTime = System.currentTimeMillis();
if (curTime < myTask.getTime()){
//还没到时间,不必执行
//现在是12:00,取出来的任务是 14:00 执行
queue.put(myTask);
//在put之后,进行一个wait
this.wait(myTask.getTime() - curTime);
} else {
//时间到了,执行任务
myTask.run();
}
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t.start();
}
//第一个参数表示 任务的内容
//第二个参数表示 任务在多少毫秒之后被执行
public void schedule(Runnable runnable,long after){
MyTask task = new MyTask(runnable,System.currentTimeMillis() + after);
queue.put(task);
synchronized (this) {
this.notify();
}
}
}
public class ThreadDemo25 {
public static void main(String[] args) {
MyTimer myTimer = new MyTimer();
myTimer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("任务1");
}
},1000);
myTimer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("任务2");
}
},2000);
}
}