一、什么是定时器
定时器也是软件开发中的一个重要组件. 类似于一个 “闹钟”. 达到一个设定的时间之后, 就执行某个指定好的代码.
定时器是一种实际开发中非常常用的组件.
比如网络通信中, 如果对方 500ms 内没有返回数据, 则断开连接尝试重连.
比如一个 Map, 希望里面的某个 key 在 3s 之后过期(自动删除).
类似于这样的场景就需要用到定时器
二、标准库中的定时器
- 标准库中提供了一个 Timer 类. Timer 类的核心方法为 schedule .
- schedule 包含两个参数. 第一个参数指定即将要执行的任务代码, 第二个参数指定多长时间之后执行 (单位为毫秒).
代码示例:
import java.util.Timer;
import java.util.TimerTask;
public class TimerScheduleTest {
public static void main(String[] args) {
Timer timer = new Timer();
//new TimerTask是要实现的任务,源码可知这里是实现了Runable接口的
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("10秒到了后实现hello");
}
}, 10000);
System.out.println("先实现main");
}
}
//实现完以上代码后,该线程并没有结束!是因为Timer内部有专门的线程来负责执行注册的任务,还要等待其他的任务加进来
三、自己实现定时器
3.1 定时器的构成
- 一个带优先级的阻塞队列
为啥要带优先级呢?
因为阻塞队列中的任务都有各自的执行时刻 (delay). 最先执行的任务一定是 delay 最小的. 使用带优先级的队列就可以高效的把这个 delay 最小的任务找出来.
- 队列中的每个元素是一个 Task 对象.
- Task 中带有一个时间属性, 队首元素就是即将
- 同时有一个 扫描线程 一直扫描队首元素, 看队首元素是否需要执行
3.2 具体步骤
3.2.1 Task 类用于描述一个任务(作为 Timer 的内部类). 里面包含一个 Runnable 对象和一个 time(毫秒时间戳)这个对象需要放到 优先队列 中. 因此需要实现 Comparable 接口.
// 创建一个类, 表示一个任务.
class MyTask implements Comparable<MyTask> {//该任务是需要将时间短的放在队首,需要比较时间
// 任务具体要干啥
private Runnable runnable;
// 任务具体啥时候干. 保存任务要执行的毫秒级时间戳
private long time;
// delay是绝对的时间戳的值,即执行时刻
public MyTask(Runnable runnable, long delay) {
this.runnable = runnable;
this.time = System.currentTimeMillis() + delay;
}
public void run() {
runnable.run();//运行该任务
}
public long getTime() {//获取到该任务设置的 执行时刻
return time;
}
@Override
public int compareTo(MyTask o) {
//时间小的需要在队列前面
return (int) (this.time - o.time);
}
}
注意:
上述任务我们需要通过比较任务的执行时间大小来决定任务优先级,所以要实现 Comparable 接口,手动指定比较规则
3.2.2 Timer 类提供的核心方法为 schedule, 用于注册一个任务, 并指定这个任务多长时间后执行。Timer 实例中, 通过 PriorityBlockingQueue 来组织若干个 Task 对象.通过 schedule 来往队列中插入一个个 Task 对象
class MyTimer {
// 定时器内部要能够存放多个任务--用带优先级的阻塞队列
private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
public void schedule(Runnable runnable, long delay) {
MyTask task = new MyTask(runnable, delay);
queue.put(task);
// 每次任务插入成功之后, 都唤醒一下扫描线程, 让线程重新检查一下队首的任务看是否时间到要执行~~
synchronized (locker) {
locker.notify();
}
}
3.3.4 Timer 类中存在一个 扫描线程, 一直不停的扫描队首元素, 看看是否能执行这个任务.
private Object locker = new Object();
//在MyTimer类中设置一个扫描线程用来扫描队首任务,看看队首任务是否到达指定时间要去执行
public MyTimer() {
Thread t = new Thread(() -> {
while (true) {
try {
// 先取出队首元素
MyTask task = queue.take();
// 再比较一下看看当前这个任务时间到了没?
long curTime = System.currentTimeMillis();
if (curTime < task.getTime()) {
// 时间没到, 把任务再塞回到队列中.
queue.put(task);
// 由于while(true)执行的很快,就会一直获取当前时间和指定时间作比较,忙等比较浪费CPU资源。所以就给扫描线程指定一个等待时间
synchronized (locker) {
//为啥不用sleep:因为不能被中途唤醒!在等待的途中可能会有新的更前面的任务加入
locker.wait(task.getTime() - curTime);
}
} else {
// 时间到了, 执行这个任务
task.run();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
注意:
上述代码由于while(true)转的太快了,就会一直获取当前时间和指定时间作比较,这种忙等是比较浪费CPU资源。比如
第一个任务设定的是 1 min 之后执行某个逻辑. 但是这里的 while (true) 会导致每秒钟访问队首元素几万次. 而当前距离任务执行的时间还有很久呢.
所以我们对其进行了优化,引入一个 locker 对象, 借助该对象的 wait / notify 来解决 while (true) 的忙等问题
private Object locker = new Object();
同时做出以下修改:
- 修改扫描线程, 引入 wait, 等待一定的时间.扫描线程扫描队首元素后发现还没有达到指定的时间,就会让该线程等待从当前时间到等待时间的时间差,时间到之后就会提醒队首元素执行
- 修改 Timer 的 schedule 方法, 每次有新任务到来的时候唤醒一下 扫描线程. (因为新插入的任务可能是需要马上执行的).
注意: 不能引入sleep来进行等待,因为sleep是不能中途唤醒的,而对于扫描线程来说,随时唤醒是非常重要的