定时器
1 什么是定时器
定时器 类似于一个 “闹钟”,达到一个设定的时间之后,就执行某个指定好的代码。
定时器是一种实际开发中非常常用的组件。
比如网络通信中,如果对方 500ms 内没有返回数据,则断开连接尝试重连。
比如一个 Map,希望里面的某个 key 在 3s 之后过期(自动删除)。
类似于这样的场景就需要用到定时器。
2 标准库中定时器
Timer 这个类就是标准库的定时器
Timer timer = new Timer();
定时器使用
package thread;
import java.util.Timer;
import java.util.TimerTask;
public class ThreadDemo4 {
public static void main(String[] args) {
// Timer 这个类就是标准库的定时器
System.out.println("程序启动!");
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("定时器任务启动");
}
},5000); //5000毫秒后执行 run 方法中的任务
}
}
在等待了5000毫秒后,就执行了定时器的任务。
schedule 这个方法的效果是给定时器注册一个任务。
但是这个任务不会立即执行,而是在指定时间进行执行。
3 实现一个定时器
3.1 实现的思路
1、让被注册的任务能够在指定时间被执行
- 单独在定时器内部搞个线程,让这个线程周期性的扫描,判定任务是否到时间了。
- 如果到时间了,就执行;没到时间就等等。
2、一个定时器是可以注册多个任务的,这多个任务会按照约定时间按顺序执行
- 这里的多个任务需要使用 优先级队列 来保存。
3.2 为什么要使用优先级队列来保存任务
定时器里的每一个任务都是带有 “时间” 概念的,也就是多长时间过后就执行。
可以肯定的是,时间越靠前的越先执行。
可以把时间小的,作为优先级最高。
此时的队首元素就是整个队列中最要先执行的任务。
此时只需要扫描线程扫描队首元素即可,而不必遍历整个队列。
因为如果队首元素还没到执行的时间,后续的元素就更不可能到执行的时间。
3.3 开始实现
1、我们可以使用标准库中带有阻塞功能的优先级队列: PriorityBlockingQueue 来保存
要执行的任务。
定义一个类来表示我们要执行的任务和执行的时间
//表示定时器中的任务
class 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();
}
}
此时 MyTask 就是要保存在 PriorityBlockingQueue 中的任务
class MyTimer {
//扫描线程
private Thread search = null;
//保存任务的阻塞优先级队列
private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
}
2、定时器类需要注册一个 “schedule” 方法来注册任务
我们期望这里保存的是一个 绝对时间,而 after 是一个像 1000ms 这样的毫秒级时间,
一个时间间隔。
所以需要使用当前的时间戳加上 System.currentTimeMillis() 得到一个是在什么时间去执行的标准时间戳。
//第一个参数是任务内容
//第二个参数是任务在多少毫秒之后执行
public void schedule(Runnable runnable, Long after) {
//注意这里的时间换算
MyTask task = new MyTask(runnable, System.currentTimeMillis() + after);
queue.put(task); //填到队列当中
}
3、如何实现扫描线程的主要逻辑
1、因为使用的是 优先级队列,所以这里只要取出队首元素即可。
MyTask myTask = queue.take();
2、计算出当前的时间
Long curTime = System.currentTimeMillis();
3、如果到了执行任务的时间就执行,没到就把任务重新塞回队列中
if (curTime < myTask.getTime()) {
// 要把任务塞回到队列中
queue.put(myTask);
} else { // 到执行任务时间了
// 执行任务
myTask.run();
}
完整代码
//构造方法里创建一个线程
public MyTimer() {
search = new Thread(() -> {
while (true) {
try {
// 取出队首元素,检查队首元素任务是否到时间了
// 如果没到时间,就把任务重新放到队列中
// 如果到时间了,就执行任务
MyTask myTask = queue.take(); //拿出队首元素
long curTime = System.currentTimeMillis(); //计算当前的时间
// 还没到执行的时间
if (curTime < myTask.getTime()) {
// 要把任务塞回到队列中
queue.put(myTask);
} else { // 到执行任务时间了
// 执行任务
myTask.run();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
search.start();
}
上述代码存在的两个问题
1、没有指定 MyTask 怎么比较优先级
现在执行两个任务看一下状况
public static void main(String[] args) throws InterruptedException{
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);
}
Comparable 用来描述比较规则的接口,这里提示我们还没有描述规则的 Comparable 接口。
可以让 MyTask 类实现 Comparable 接口
或者也可以使用 Comparable 单独写一 个比较器。
下面是实现一个 Comparable
class MyTask implements Comparable<MyTask> {
@Override
public int compareTo(MyTask o) {
// 这里会返回 <0 >0 =0 三种结果
// this 比 o 小 返回 <0
// this 比 o 大 返回 >0
// this 等于 o 返回 =0
return (int) (this.time - o.time); // 因为时间是long类型的,所以返回需要强制类型转换
}
}
2、如果执行的时间没到就会一直重复取出来塞进去的操作(忙等)
按理说,等待是要释放 CPU 资源的,让 CPU 资源可以干别的事情。
但是忙等,即进行了等待,又占用着 CPU 资源。
就像是有的人虽然今天休假,但是一会又要线上开会,一会又要打扫卫生。
自己还没怎么休息,但是一天就过去了,自己虽然是在休假,但是也没有闲着。
如果此时还没到任务执行的时间,比如说任务执行的时间是 14:00, 但是现在是 13:00
那么在这个时间段内,上述代码的循环操作就可能会被执行数十亿次,甚至更多。
就好比 18:00 就下课了,但是此时是 17:30 ,我过一会看一下时间,过一会看一下时间。
虽然是在等待着下课时间的到来,但是我也没有闲着。
针对上述的情况,不要在忙等了,而是要进行阻塞式等待。
可以使用 sleep 或者 wait 。
不使用 sleep 的原因:
-
随时都有可能有新的任务到来,如果新任务执行的时间更早呢。
也就是说这里等待的时间不明确。 -
如果新的任务执行的时间是 30 分钟后,但是 sleep 设置的时间是 1个小时,
那么这个时候就会错过这个任务。
使用 wait 更合适,更方便随时唤醒。
如果有新的任务来了就 notify 唤醒,然后在检查一下时间,重新计算要等待的时间。
而且 wait 也提供了一个带有 “超时时间” 的版本
带有超时时间的 wait 就可以保证:
- 当新任务来的时候,随时 notify 唤醒
- 如果没有新任务,则最多等到之前旧任务中的最早任务时间到,就会被唤醒。
在 put 操作之后 进行 wait,还要搭配锁来使用。
在 schedule 方法里进行唤醒(notify)
synchronized (this) {
this.wait(myTask.getTime() - curTime); // 得到需要等待的时间
}
// 唤醒wait
synchronized (this) {
this.notify();
}
此时的代码还有一个和线程随机调度相关的问题
假设代码执行到了 ** queue.put(myTask);** ,这个线程就要从 cpu 调度走了。
当线程回来之后,接下来就要进行 wait 操作了,此时 wait 的时间已经是算好的。
比如当前时间是 13:00 ,任务时间是 14:00 ,即将要 wait 1 小时。(此时还没有执行wait)
如果此时有另一个线程调用了 schedule 方法添加新任务,新任务是 13:30 执行。
由于 扫描线程 wait 还没执行呢,所以此处的 notify 只是会空打一炮,
不会产生任何的唤醒操作。
此时此刻,新的任务虽然已经插入到队列,新的任务也是在队首,
紧接着,扫描线程回到 cpu 了,此时等待的时间仍然是 1 小时。
因此,13:30 的任务就被错过了。
了解了上述问题之后就不难发现,问题出现的原因,是因为当前 take 操作和 wait 操作不是原子的。
如果在 take 和 wait 之间加上锁,保证在这个过程中不会有新的任务过来,问题自然解决。
//构造方法里创建一个线程
public MyTimer() {
search = new Thread(() -> {
while (true) {
try {
// 取出队首元素,检查队首元素任务是否到时间了
// 如果没到时间,就把任务重新放到队列中
// 如果到时间了,就执行任务
synchronized (this) {
MyTask myTask = queue.take(); //拿出队首元素
long curTime = System.currentTimeMillis(); //计算当前的时间
// 还没到执行的时间
if (curTime < myTask.getTime()) {
// 要把任务塞回到队列中
queue.put(myTask);
// put 之后进行 wait
this.wait(myTask.getTime() - curTime); // 得到需要等待的时间
} else { // 到执行任务时间了
// 执行任务
myTask.run();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
search.start();
}
完整代码
package thread;
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
return (int) (this.time - o.time); // 因为时间是long类型的,所以返回需要强制类型转换
}
}
class MyTimer {
//扫描线程
private Thread search = null;
//保存任务的阻塞优先级队列
private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
//构造方法里创建一个线程
public MyTimer() {
search = new Thread(() -> {
while (true) {
try {
// 取出队首元素,检查队首元素任务是否到时间了
// 如果没到时间,就把任务重新放到队列中
// 如果到时间了,就执行任务
synchronized (this) {
MyTask myTask = queue.take(); //拿出队首元素
long curTime = System.currentTimeMillis(); //计算当前的时间
// 还没到执行的时间
if (curTime < myTask.getTime()) {
// 要把任务塞回到队列中
queue.put(myTask);
// put 之后进行 wait
this.wait(myTask.getTime() - curTime); // 得到需要等待的时间
} else { // 到执行任务时间了
// 执行任务
myTask.run();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
search.start();
}
//第一个参数是任务内容
//第二个参数是任务在多少毫秒之后执行
public void schedule(Runnable runnable, long after) {
//注意这里的时间换算
MyTask task = new MyTask(runnable, System.currentTimeMillis() + after);
queue.put(task); //填到队列当中
// 唤醒wait
synchronized (this) {
this.notify();
}
}
}
public class ThreadDemo5 {
public static void main(String[] args) throws InterruptedException {
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);
}
}