多线程 - 定时器
定时器的背景知识
定时器 ~~ (就类似于定闹钟)
平时的闹钟,有两种风格:
- 指定特定时刻,提醒
- 指定特定时间段之后,提醒
这里的“定时器”,不是提醒,而是执行一个实现准备好的方法/代码,它是开发中一个常用的组件,尤其是在网络编程的时候,使用浏览器上网,打开一个网页,很容易出现,“卡了""连不上"的情况.这时就可以使用“定时器”来进行“止损”.
标准库提供的定时器
timer.schedule();
这个方法的效果是,给定时器,注册一个任务.任务不会立即执行,而是在指定时间进行执行.
public static void main(String[] args) {
System.out.println("程序启动!");
// 这个 Timer 类就是标准库中的定时器
Timer timer =new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("运行定时器任务");
}
},3000);
}
第一个参数: new TimerTask()
=> TimerTask
这个抽象类实现了Runnable
接口,即将要执行的任务代码 ~~ public abstract class TimerTask implements Runnable
第二个参数: 指定多长时间之后执行(单位为毫秒)
手动实现一个定时器
定时器要求:
- 让被注册的任务,能够在指定时间被执行.
- 一个定时器是可以注册N个任务的,N个任务会按照最初约定的时间,按顺序执行.
思路:
在指定时间被执行 => 单独在定时器内部,创建个线程,让这个线程周期性的扫描,判定任务是否是到时间了.如果到时间了,就执行.没到时间,就再等等.
注册N个任务 => 这个N个任务,就需要使用一个数据结构来保存的,而在当下场景中,使用优先级队列,就是一个很好的选择.再由于这里的每个任务都是需要按时间执行的,时间越靠前,就越先执行,时间小的,优先级就高.此时队首元素,就是整个队列中,最先要执行的任务 => 这时,扫描线程,只需要扫一下队首元素即可,就不必遍历整个队列(如果队首元素还没到执行时间内,后续元素更不可能到时间).
问题:
问题一: 因为调用schedule是一个线程,扫描是另一个线程,这里的优先级队列就会在多线程环境下使用了,这时就不得不考虑线程安全了.
问题二: 队列中的任务如何表示? 使用Runnable
来表示任务的话是不行的,Runnable
只是表述了任务内容,还需要描述任务什么时候被执行.
问题三: 如何进行任务的注册/创建?
问题四: 扫描线程具体的实现?
问题五: 任务MyTask
如何进行优先级的比较?
解决:
问题一: 使用标准库提供的带优先级的阻塞队列 PriorityBlockingQueue
,它本身就是线程安全的,就不需要考虑了.
问题二: 自定义一个MyTask
类,来表示一个定时器中的任务,这个类包含两个私有属性private Runnable runnable;
和private long time;
~~ runnable
是要执行的任务内容,time
是任务在什么时候执行(使用毫秒时间来表示).
问题三: 提供一个schedule
方法,来进行任务的注册/创建,这个schedule
方法本身是比较的简单的,只是单纯的把任务放到队列里.
问题四: 取出队首元素, 检查看看队首元素任务是否是到时间了,如果时间没到,把取出来的元素重新入队queue.put(myTask);
,在 put 之后, 再进行一个 waitthis.wait(myTask.getTime() - curTime);
,如果时间到了,就执行任务内容.
问题五: 1.明确当前的任务是怎样的优先级,以哪个字段/属性指定优先级关系.2.让MyTask
类实现Comparable
接口,或者使用Comparator
单独写个比较器(博主选择的是实现Comparable
接口).
优化: Timer 类中存在一个 worker 线程, 一直不停的扫描队首元素, 看看是否能执行这个任务设定的时间已经到达了,相关代码如下:
while (true) {
try {
synchronized (this) {
MyTask myTask = queue.take();
long curTime = System.currentTimeMillis();
if (curTime < myTask.getTime()) {
// 还没到时间,先不必执行
queue.put(myTask);
} else {
// 时间到了,执行任务
myTask.run();
}
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
但是当前这个代码中存在一个严重的问题, 就是 while (true) {queue.put(myTask);}
假设现在是8:00,队首元素的任务是10:00,取出的元素,显然是不能执行的,而由于这里的队列是优先级队列(堆),queue.put(myTask)
会触发优先级调整,(堆的调整)调整之后, myTask
又回到队首了,下次循环取出来的还是这个任务. => 它就是一个没有任何阻塞的循环,在8:00到10:00这个时间段内,这个循环可能就要执行数以十亿次….就会造成了无意义的CPU浪费.
理解: 好比我们上高中的时间,每天都要6:00起床,而我有次5:00就醒了,看了眼闹钟,发现是5:00,正常来说,我会立刻继续睡,再睡个半小时,但是这个代码却不是这样的,按着这个代码执行逻辑的,我就必须在放下表后,又立刻拿起表来,又看时间,发现是5:00,然后又拿起闹钟,看时间,就这样重复着,知道时间到了6:00,然后才起床上学,但是这个一看不科学啊!这样做,就毫无意义,这样的代码是存在问题滴!!!
这种现象,在我们计算机领域也被称为“忙等” ~~ 等,但并没有闲着.正常来说,等待是要释放CPU资源的,让CPU做其它的事情,但是“忙等”,既进行了等待,又占用着CPU资源.
注: 像忙等这样的情况,也是需要辩证的看待的.在当前场景中,”忙等”,确实是不太好的.但是有的情况下,忙等,却是一个好的选择.
策略: 针对上述代码,就不要进行“忙等”了,而是进行"阻塞式"等待.这时就想到sleep
或者wait
,不过,博主要说的是sleep
看似可行,但是实际上不可以的,因为做不到等待的时间明确!!!随时都可能会有新的任务创建/注册(随时可能有线程调用schedule
添加新任务),万一新的任务更早了,是做不到等待时间的更新,此时仍然按照之前的等待,就会错过新任务的执行时间. 使用wait
更合适,更方便随时唤醒.使用wait
等待,每次有新任务来了(有线程调用schedule
),就 notify
一下,重新检查下时间.并再次计算要等待的时间,从而做到等待时间的更新.
注: 这里的wait
是要使用带有“超时时间”版本的,这样就可以保证: 1.当新任务来了,随时 notify
唤醒; 2.如果没有新任务,则最多等到之前旧任务中的最早任务时间到,就被唤醒.
高能烧脑预警
博主代码写的过程中,遇到的一个线程安全/随机调度密切相关的问题.
考虑一个极端情况:
看了上述图示之后,就不难发现,问题出现的原因,是因为当前 take 操作,和 wait 操作,并非是原子的.如果在 take 和 wait 之间加上锁,保证在这个过程中,不会有新的任务过来,问题自然解决(换句话说,只要保证每次 notify 时,确实都正在wait ) => 扩大上述代码锁的范围.
代码编写:
package thread;
import java.util.concurrent.PriorityBlockingQueue;
/**
* Created with IntelliJ IDEA.
* Description:
* User: fly(逐梦者)
* Date: 2023-10-06
* Time: 16:32
*/
// 使用这个类来表示一个定时器的任务.
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);
}
}
// 自己写个简单的定时器
class MyTimer {
// 扫描线程
private Thread t = null;
public MyTimer() {
t = new Thread(() -> {
while (true) {
// 取出队首元素, 检查看看队首元素任务是否是到时间了
// 如果时间没到,把取出来的元素重新入队
// 如果时间到了,就把任务进行执行
try {
synchronized (this) {
MyTask myTask = queue.take();
long curTime = System.currentTimeMillis();
if (curTime < myTask.getTime()) {
// 还没到时间,先不必执行
// 现在是13: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();
}
// 用一个阻塞优先级队列, 来保存任务
private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
// 指定两个参数
// 第一个参数是 任务内容
// 第二个参数是 任务在多少毫米之后执行. 形如 1000
public void schedule(Runnable runnable, long after) {
// 进行时间上的换算
MyTask task = new MyTask(runnable, System.currentTimeMillis() + after);
queue.put(task);
synchronized (this) {
this.notify();
}
}
// 这个 schedule 方法本身比较简单,只是单纯的把任务放到队列里去了
}
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);
myTimer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("任务3");
}
}, 3000);
}
}
博主备注: 程序里的计时操作,本身就难以做到非常精确,因为操作系统调度线程有时间开销的.存在ms级别的误差,都很正常.也不影响日常使用.如果应用场景,就是对时间误差非常敏感(发射导弹,发射卫星)此时就不会再使用windows, linux这样的操作系统了,而应该使用像vxworks 这样的实时操作系统,这样的系统线程调度开销是极快,可控的,可以保证误差在要求范围内的.