1. Java自带的定时器
相信大家都定过闹钟,在我上学有早八的时候,硬是要定三个闹钟才起得来,7:20,7:30,7:40,那么我们今天所要实现的定时器,就类似于闹钟,设定多长时间之后,要干某某事情...
定时器是一种实际开发中非常常用的组件,比如网络通信中,如果对方 500ms 内没有返回数据,则断开连接尝试重连等等
在 Java 标准库中,也给我们提供了一个定时器:Timer 类,这个类的核心方法是 schedule。
schedule 方法中包含了两个参数,第一个参数指定将要执行的代码,第二个参数指定多长时间之后执行,单位是毫秒。
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("执行任务!");
}
}, 3000);
}
此时我们运行程序,就会在 3s 后打印 "执行任务!",这里有个点需要注意,我们描述任务使用的是 TimerTask 里的 run 方法,其实这个 TimerTask 里的 run 方法,跟 Runnable 的 run 方法是一模一样,因为源码实现中,TimerTask 类实现了 Runnable 这个接口,相当于多了一层封装!
当然我们也可以一次注册多个任务,这就好比列出一个清单一样:
这里我们注意,清单上的任务有很多,每个任务多久后执行的时间点也不一样,那我写清单的时候,可能先想到下午要干嘛,再想到早上要干嘛,先在清单上写下午干的活,再写早上要干的活,但是我执行的顺序,肯定是先执行早上的任务,在执行中午,下午...
这就好比我们注册任务一样,先注册一个 3s 后执行的任务,再注册一个 2s 后执行的任务,显然后注册的任务要先执行!
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("3秒后的任务执行");
}
}, 3000);
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("2秒后的任务执行");
}
}, 2000);
}
/*
打印结果:
2秒后的任务执行
3秒后的任务执行
*/
有了上述对定时器的认识,这里我们就模拟实现一个 Timer(定时器)。
2. 模拟实现定时器
上述的案例和分析,我们能很清楚的认识到,并不是先注册的任务先执行,而是按照时间前后来执行,比如我们定了三个闹钟:17:30 14:00 16:00,那么肯定是 14:00 的闹钟最先响!
所以我们注册的任务也是同理,是带有优先级的!这个优先级取决你设定的时间,到了时间就启动。
想到优先级,在前面学习的数据结构中,有一个优先级队列 PriorityQueue,底层是用堆来实现的,这样一来我们可以建小堆,按照指定的时间进行比较,谁会最先执行,谁就是堆顶的元素。
同时我们在内部定义一个线程,来扫描堆中元素是否到点该执行了,由于我们是小堆,所以堆顶元素一定是最先执行的,如果堆顶的任务都不能执行,那么后面的任务肯定也都不能执行,所以这个线程只需要扫描堆顶的元素,判断堆顶元素是否到时间的就行!
但是问题又来了,调用 schedule 注册任务时是一个线程往堆中写,而 MyTimer 内部还有一个线程一直读堆顶元素,而这两个线程都是在操作里面的优先级队列,势必会有线程安全问题(一个线程读,一个线程写),此处显然 PriorityQueue 就不行了,但是还有另外一个选择:PriorityBlockingQueue,这个上节我们提到过,是 Java 标准库提供的一个优先级阻塞队列,是线程安全的!
好了,基于上述的分析,下面我们就来模拟实现一个定时器,取名为 MyTimer:
这里我们使用内部类的方式,利用 MyTask 类来描述任务和执行任务的时间:
public class MyTimer {
private static class MyTask implements Comparable<MyTask> {
// 要执行的任务
private Runnable runnable;
// 执行任务的时间
private long delay;
private MyTask(Runnable runnable, long delay) {
this.runnable = runnable;
this.delay = delay;
}
private void run() {
runnable.run();
}
// 重写 compareTo 按照执行时间进行比较
@Override
public int compareTo(MyTask o) {
// 根据注册时间指定优先级, 建小堆
return (int) (this.delay - o.delay);
}
}
}
接着 MyTimer 类中还需要有一个优先级阻塞队列来存放要执行的任务,加上一个线程来扫描堆顶元素:
// 存放任务
private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
// 扫描线程
private Thread t;
那么这个 t 线程如何扫描呢?此处我们可以在 MyTimer 构造方法中,让 t 线程进行扫描:
public MyTimer() {
t = new Thread(() -> {
while (true) {
try {
MyTask task = queue.take();
// 判断是否到了执行时间了
if (task.delay <= System.currentTimeMillis()) {
// 执行任务
task.run();
} else {
// 没到时间把任务塞回队列
queue.put(task);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
最后就是 schedule 方法了:
public void schedule(Runnable runnable, long delay) {
// 注册一个任务, 执行时间为: 当前时间+延迟时间
queue.put(new MyTask(runnable, System.currentTimeMillis() + delay));
}
其实写到这,一个简单的定时器已经写完了,但是这个代码不够好,我们可以做优化!
3. 定时器代码优化
回过头来看我们上述写的 MyTimer 的构造方法,里面是一个 while (true) 循环,也就意味着这个线程要无止境的从队列中读元素,而这个线程会一直占用 CPU 资源。
举个例子,比如我现在定了一个早上 8:00 的闹钟,我 7点醒了,那么我打开手机一看,时间没到,关上手机,立刻又打开手机看,时间没到,关上手机,又马上打开手机看,还是没到时间.....
这里的 while (true) 循环就类似于上面的例子,t 线程从队列中取元素,发现时间没到,塞回队列,第一次循环结束,又从队列中取,发现时间没到,又塞回去....
有必要一直看到没到点吗?能不能让这个线程等一会呢?假设还差 100s,那就让这个线程等 100s 之后再执行嘛!这样还节省了 CPU 资源,更不用反复从队列中 take 和 put 了,也不用重复的向上调整了(堆的特性)!
于是我们就可以使用 wait 带参数版本,让线程主动等一段时间,等当前时间和执行到点时间的时间差就行,那么既然等的时间是明确的,可不可以采用 sleep 呢?
注意!sleep 是不建议的,如果当前需要等 30min 执行任务,那么在 sleep 的过程中,又添加了一个任务呢?只需要 10s 后执行呢?这样可能就会错过新任务的时间!有人说,sleep 不是也是可以唤醒吗?但是 sleep 的唤醒是会抛异常的,这个不推荐!
如果采用 wait 带参数,则会更合适,每次注册任务的时候,都 notify 唤醒一下,重新看堆顶的元素即可。
public MyTimer() {
t = new Thread(() -> {
while (true) {
try {
MyTask task = queue.take();
long curTime = System.currentTimeMillis();
if (curTime <= task.delay) {
// 到时间了执行任务
task.run();
} else {
// 没到时间先放回队列
queue.put(task);
// 根据当前时间和任务要执行的时间, 等一个时间差
synchronized (this) {
this.wait(curTime - task.delay);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
public void schedule(Runnable runnable, long delay) {
// 注册一个任务, 执行时间为: 当前时间+延迟时间
queue.put(new MyTask(runnable, System.currentTimeMillis() + delay));
// 每次有新的任务,都唤醒一下,让线程重新读堆顶元素,防止新任务最先执行
synchronized (this) {
this.notify();
}
}
但是上述代码还存在一个线程安全问题!
当代码执行完 12 行,就被 CPU 切走了,另一个线程开始注册任务,这个任务比堆中的其他任务都先执行,那么此时的 notify 就空喊一嗓子了,当 CPU 切回来时,扫描线程 t 就可能没有感知到又有新的任务注册进来了。
本质上就是 synchronized 范围太小了!我们扩大加锁的范围即可:
public MyTimer() {
t = new Thread(() -> {
while (true) {
try {
synchronized (this) {
MyTask task = queue.take();
long curTime = System.currentTimeMillis();
if (curTime <= task.delay) {
// 到时间了执行任务
task.run();
} else {
// 没到时间先放回队列
queue.put(task);
// 没到根据当前时间和任务要执行的时间, 等一个时间差
this.wait(curTime - task.delay);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
这样一来,t 线程读堆顶元素的时候,其他线程就不能放入元素了,等 t 线程执行到 this.wait(),就会自动释放锁,后面其他线程再注册任务的时候,每次 notify 就都是有效的了,t 也能感知到了!保证了每次 notify 都能有效唤醒!
那么实现到这,我们差不多已经能和自带的 Timer 差不多的效果了,但是注意!这里的定时器,不一定那么的准时!而我们目前能写到这个地步也就差不多可以了。
下期预告:【多线程】实现一个线程池