🥰🥰🥰来都来了,不妨点个关注叭!
👉博客主页:欢迎各位大佬!👈
文章目录
- 1. 定时器是什么?
- 2. 定时器的应用场景
- 3. Timer类的使用
- 3.1 Timer类创建定时器
- 3.2 schedule()方法的介绍
- 3.3 使用Timer管理多个任务
- 4. 如何自己实现一个定时器?
- 4.1 需考虑的问题
- 4.1.1 如何实现自定义 MyTimer 类同时管理多个任务
- 4.1.2 如何保证多线程下操作 PriorityQueue 线程安全
- 4.2 实现的思路
- 4.2.1 自定义类 MyTimer
- 4.2.2 构造线程执行任务
- 4.3 存在的问题
- 4.3.1 当前队列里的 Mytask 元素是按照什么规则表示优先级的?
- 4.3.2 while(true)带来CPU忙等问题
- 4.4 最终完整代码
1. 定时器是什么?
【定时器】就是闹钟,就是设定一个时间,当该时间一到,可执行一个指定的代码,即在预设的时间执行一个或多个动作
Java 标准库(java.util)中提供定时器类:Timer类,其核心方法:schedule()
2. 定时器的应用场景
定时器的应用场景非常多!
尤其在网络编程中,在实际中,很多的"等待"不应该是无止境地等待,应该有一个期限!比如打开浏览器访问某个网站,如果正好此时的网络信号不佳,则可能加载很长时间,浏览器会设置一个超时时间,如果访问该页面等待时间超过这个超时时间仍然没有结果,则提醒用户无需等待下去(浏览器会提示 504 gateway time out)
比如数据备份,服务器每天凌晨自动备份数据,使用定时器每天在指定时间执行数据备份的操作;再比如定时邮件发送,自动发送定时的提醒邮件等,设置定时器在特定时间执行发送邮件的操作,还有缓存更新,缓存数据定期更新,以保持数据的时效性,定时器周期性地更新缓存等等
总之,定时器在 Java 应用中十分常见,尤其是在需要按计划执行操作的时候
3. Timer类的使用
3.1 Timer类创建定时器
Timer 类提供以下 4 个构造方法:
public Timer()
public Timer(boolean isDaemon)
public Timer(String name)
public Timer(String name, boolean isDaemon)
其中参数 name 是线程的名字,isDaemon参数设置为 true 表示将该线程设置为后台线程,默认是前台线程
(关于前后台线程可以回顾前期内容 Thread 类及其基本用法,其中属性方法是否为后台线程 --> isDaemon()中有介绍)
以下是使用 Timer 类创建定时器,具体代码如下:
public class ThreadDemo {
public static void main(String[] args) {
//1.创建 timer 对象
Timer timer = new Timer();
//2. 设定时间
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello1!");
}
},2000);
System.out.println("hello2!");
}
}
打印结果:
运行结果可以看到,实现了该效果,先立即执行打印 hello2,等待 2s 后,执行定时器中的操作,打印 hello1
【注意】这里我们可以看到打印 hello1 后,程序并未终止退出,这是为什么呢?
这里是因为 Timer 类里内置的前台线程,前台线程会阻止当前进程结束!实际上,run()方法的执行正是依赖 Timer 类中内部线程控制时间到了之后再执行!!!
3.2 schedule()方法的介绍
在 Java 的 Timer 类中,schedule() 方法用于安排一个 TimerTask 任务在未来的某个时间点执行,Timer 类有几个不同的 schedule() 方法,它们之间的主要区别在于任务的执行是一次性的还是周期性的,带有参数 period 的方法则是可以周期性执行!
void schedule(TimerTask task, long delay)
void schedule(TimerTask task, Date time)
void schedule(TimerTask task, Date firstTime,long period)
void schedule(TimerTask task, long delay,long period)
以上 4 个构成重载,第一个参数均为被安排的任务,后面的参数表示设定任务将要等待的时间,具体解释说明这两个参数:task 和 delay
【task】task to be scheduled,被安排的任务(安排一个工作,说明这个工作不是立刻完成,而是在未来的某个时间点完成)
【delay】delay in milliseconds before task is to be executed,任务执行前的延迟时间,以毫秒为单位
public class ThreadDemo {
public static void main(String[] args) {
//1.创建 timer 对象
Timer timer = new Timer();
//2. 设定时间
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello1!");
}
},4000);
}
}
上述代码 schedule() 方法的第一个参数是 TimerTask,本质上就是 Runnable,通过查看 TimerTask 源代码可以看出, TimerTask 就是一个实现了 Runnable 接口的抽象类!因此,当我们创建 TimerTask 对象的时候,需要重写 run() 方法,run() 方法中写的内容定义的行为,即表示任务具体要做什么的内容
图解说明:
3.3 使用Timer管理多个任务
定时器内部管理的不仅仅是一个任务,可以管理很多很多任务!
那么我们会想这样一个问题,如果有很多个任务,比如几万个,假如定时器里面有几万个任务,如果创建几万个线程,会消耗非常多的资源,这是一个多么庞大的数字!!!
虽然任务可能会有很多个,它们的触发的时间是不同的,因此,只需要一个或者一组工作线程,每次找到这些任务中最先到达时间的任务,即在一个线程中,先执行最早的任务,根据触发时间依次执行,时间到了则执行,没有到则等待,这样就不需要创建那么多线程也可以按照指定时间依次执行任务了!
以下代码给定时器同时定义 5 个任务:
public class Test {
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello1");
}
},1000);
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello2");
}
},2000);
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello3");
}
},3000);
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello4");
}
},4000);
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello5");
}
},5000);
}
}
效果如下:
运行程序后,根据时间,先打印 hello1,接着每间隔 1s 打印,实现该效果!
4. 如何自己实现一个定时器?
4.1 需考虑的问题
4.1.1 如何实现自定义 MyTimer 类同时管理多个任务
从上述使用 Timer 管理多个任务可以看到,该机制是需要一个/一组工作线程,每次找到这些任务中最先到达时间的任务,在一个线程中,先执行最早的任务,根据触发时间依次执行,时间到了则执行,没有到则等待
我们自己实现 MyTimer 类,如何才能实现这样的机制呢?
堆!!! 堆是这里需要用到的核心数据结构,在 Java 标准库中直接就提供了优先级队列 —— PriorityQueue
因此,我们使用 PriorityQueue 来实现
可能会有疑问,为什么不用排序?
要知道,排序的效率要低于堆,并且在插入新元素的时候,想要维护原有序列的规律是比较困难的
(有直接的优先级队列,为什么不用捏!)
4.1.2 如何保证多线程下操作 PriorityQueue 线程安全
定时器可能有多个线程执行 schedule() 方法,那么希望在多线程下操作 PriorityQueue 还能够保证线程安全!
要保证线程安全,进一步我们思考,Java 标准库中提供带优先级的阻塞队列 —— PriorityBlockingQueue ,能够解决这个线程安全问题
上期内容多线程系列中介绍了阻塞队列,BlockingQueue 接口有 7 个实现类,其中有一个类是 PriorityBlockingQueue
【PriorityBlockingQueue】是支持优先级排序的无界阻塞队列,它遵循优先级队列规则,即可以实现根据任务的执行时间来建立小根堆,取头元素则是当前该队列中的最小元素,并且带优先级的阻塞队列同样只有入队列和出队列有阻塞特性!其它方法则不具备阻塞特性
4.2 实现的思路
4.2.1 自定义类 MyTimer
首先创建一个自定义类 MyTimer 类来模拟 Timer 类,创建这个类要表示两方面的信息:
1)执行的任务是什么 (Mytask用于描述一个要执行的任务)
2)任务执行的时间(为后续判定方便,这里使用绝对的时间戳)
【绝对时间戳】
MyTimer 类中定义的schedule()方法中,其中需要传入一个参数 delay,表示的是相对时间,如 5000 ms,表示在 5s 后执行该任务,因此,在构造 MyTask 的时候,需要将相对时间转换为绝对时间,如下:
this.time = System.currentTimeMillis()+delay;
其中 System.currentTimeMillis()
用于获取当前毫秒级别的时间戳,即为当前时刻和基准时刻 ms 数之差
(基准时刻是 1970 年 1 月 1 日 00:00:00)
MyTimer 类封装核心数据结构 —— PriorityBlockingQueue,MyTask 作为元素放在这个优先级阻塞队列中
代码如下:
//表示一个任务
class MyTask {
public Runnable runnable;
//为了方便后续判断使用绝对的时间戳
public long time;
public MyTask(Runnable runnable, long delay) {
this.runnable = runnable;
//取当前时刻的时间戳+delay作为该任务实际执行的时间戳
this.time = System.currentTimeMillis()+delay;
}
}
class MyTimer {
//这个结构就是带有优先级的阻塞队列 核心数据结构
private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
//此处delay是一个形如5000这样的数字(多长时间之后执行该任务)
//这列的元素需要手动封装
//创建一个类 表示两方面 1.执行的任务是啥 2.任务啥时候执行
public void schedule(Runnable runnable, long delay) {
//根据参数构造MyTask,插入队列即可
MyTask myTask = new MyTask(runnable,delay);
queue.put(myTask);
}
}
4.2.2 构造线程执行任务
定时器类中的构造方法中构造一个线程负责执行具体任务,判断带有优先级的阻塞队列中的各个任务是否到达可执行的时间
先从 queue 中取出一个元素任务,这个任务的时间是所有任务执行实现最早的,再获取当前的时间,通过比较当前时间和该任务的时间,判断是否达到了该任务的执行时间,如果达到了,则执行 run()方法,未达到,则将取出的任务放回队列中
为什么要使用绝对时间?
因为我们要将执行时间与此刻的时间戳进行对比,判断是否执行任务
代码如下:
class MyTimer {
//这个结构就是带有优先级的阻塞队列 核心数据结构
private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
//此处delay是一个形如5000这样的数字(多长时间之后执行该任务)
//这列的元素需要手动封装
//创建一个类 表示两方面 1.执行的任务是啥 2.任务啥时候执行
public void schedule(Runnable runnable, long delay) {
//根据参数构造MyTask,插入队列即可
MyTask myTask = new MyTask(runnable,delay);
queue.put(myTask);
}
//在这里构造一个线程负责执行具体任务
public MyTimer() {
Thread t = new Thread(() -> {
while(true) {
try {
synchronized (locker) {
//阻塞队列只有阻塞的入队列和阻塞的出队列,没有阻塞的查看队首元素
MyTask myTask = queue.take();
//当前队列无元素 队列阻塞 退出循环
//队列有元素就可以获取到元素
//看钱当前任务时间是否合适
long curTime = System.currentTimeMillis();
if (myTask.time <= curTime) {
//时间到了可以执行任务了
myTask.runnable.run();
} else {
//时间没到
//把刚才取出的任务重新塞回队列中
queue.put(myTask);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
4.3 存在的问题
但是上述代码存在两个严重的bug!!!
4.3.1 当前队列里的 Mytask 元素是按照什么规则表示优先级的?
如果 Mytask 元素没有实现比较方法规则,在添加元素是可能会抛出 classCastException 异常,因为优先级队列无法确定对象之间排序的顺序!(注意这里我们比较的是对象,而不是简单的数字等)
忘记对象比较的小伙伴们可以看看这期内容:对象的比较
因此 PriorityBlockingQueue 可以通过直接实现 Comparable 接口并重写该接口的 compareTo 方法,也可以通过 自定义比较器类,实现Comparator接口
这里使用 Mytask 类实现Comparable 接口,并重写 compareTo 方法,通过对象的时间 time 来进行比较
代码如下:
class MyTask implements Comparable<MyTask>{
public Runnable runnable;
//为了方便后续判断使用绝对的时间戳
public long time;
public MyTask(Runnable runnable, long delay) {
this.runnable = runnable;
//取当前时刻的时间戳+delay作为该任务实际执行的时间戳
this.time = System.currentTimeMillis()+delay;
}
@Override
public int compareTo(MyTask o) {
//取时间最小的元素
return (int)(this.time-o.time);
}
}
4.3.2 while(true)带来CPU忙等问题
忙等,CPU确实是在等着,但是没有休息,等待过程中占用着CPU,就比如我点的餐,显示8:30做好,我期待着它能提前做好,8:01去看一下,8:0去看一下餐好没好,又过5分钟,我又去看一下,剩下的时间我一直不停地看时间,去看餐到底好没好,这段时间算是浪费了!
忙等在上述代码中表现为while(true)的执行内容中,每秒钟可能访问队首元素非常多次,但是还没有到时间,离执行时间还有很久,这是在做无意义的事情,造成了CPU的浪费!
解决的方式就是需要在等待过程中释放 CPU,提到忙等,我就立马想到了使用 wait() 方法,不知道各位想起小丁忙等的故事了嘛~ wait 方便随时提前唤醒,比如当前时刻是14:30,约定14:40需要执行上课这个任务,取出队首元素,发现时间没到,则 wait 等待10min,在等待过程中,突然来一个任务,比如14:35要去接水,则就不能一直等待了,而是唤醒notify,此时工作线程就会重新取队首元素,即14:35的接水任务!
因此,时间还没有到,则将刚取出来的队首元素放回队列,并进入 wait 等待,一直到时间到,将它唤醒,如果插入新元素,调用 notify,唤醒锁对象
代码如下:
//表示一个任务
class MyTask implements Comparable<MyTask>{
public Runnable runnable;
//为了方便后续判断使用绝对的时间戳
public long time;
public MyTask(Runnable runnable, long delay) {
this.runnable = runnable;
//取当前时刻的时间戳+delay作为该任务实际执行的时间戳
this.time = System.currentTimeMillis()+delay;
}
@Override
public int compareTo(MyTask o) {
return (int)(this.time-o.time);
//取时间最小的元素
}
}
class MyTimer {
//创建一个锁对象
private Object locker = new Object();
//这个结构就是带有优先级的阻塞队列 核心数据结构
private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
//此处delay是一个形如3000这样的数字(多长时间之后执行该任务)
//这列的元素需要手动封装
//创建一个类 表示两方面 1.执行的任务是啥 2.任务啥时候执行
public void schedule(Runnable runnable, long delay) {
//根据参数构造MyTask,插入队列即可
MyTask myTask = new MyTask(runnable,delay);
queue.put(myTask);
synchronized (locker) {
locker.notify();
}
}
//在这里构造一个线程负责执行具体任务
public MyTimer() {
Thread t = new Thread(() -> {
while(true) {
try {
synchronized (locker) {
//阻塞队列只有阻塞的入队列和阻塞的出队列,没有阻塞的查看队首元素
MyTask myTask = queue.take();
//当前队列无元素 队列阻塞 退出循环
//队列有元素就可以获取到元素
//看钱当前任务时间是否合适
long curTime = System.currentTimeMillis();
if (myTask.time <= curTime) {
//时间到了可以执行任务了
myTask.runnable.run();
} else {
//时间没到
//把刚才取出的任务重新塞回队列中
queue.put(myTask);
//实时时间进行调整
locker.wait(myTask.time-curTime);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
深入解析:
1)为什么是在 schedule() 方法里 notify()
因为在 schedule() 方法中使用 notify 是为了确保正在等待的线程能够及时得到通知,重新检查队列并执行更早的任务,因为可能新来的任务,执行时间更早!
2)确定加锁位置问题
我们来分析一下,以下两种加锁位置:
第一种加锁位置即为上述代码写的,是正确的,第二种加锁位置是错误的,将会引起线程安全问题,会出现空打一炮的情况,使新的任务执行时间更早而无法及时执行
对此,我们进一步分析第二种加锁位置:假设线程 t1 执行到 put 时候,切到线程 t2 执行
接下来,等 t1 线程继续执行的时候,将要 wait 等待30min,线程 t2 的 notify 已经执行过了,wait 已经错过 notify了,此时的 wait 就会导致新的任务无法及时执行!!!所以是有问题的~
那第一种加锁位置,为什么就正确呢?仍然假设线程 t1 执行到 put 时候,切到线程 t2 执行
因此,新加一个12:10的任务,在 t1 线程执行到 wait 前,t2 线程因为没有锁进行阻塞等待,当 t1 进入 wait,t1 释放锁,t2 插入新的任务竞争到锁,并执行 notify 唤醒 t1 线程,让 t1 线程重新扫描阻塞队列中的任务,发现比原来更早的执行任务12:10,进行更新执行这个更早的任务!
总之,多线程是很复杂的,稍不留神就很容易出现错误!牢记线程安全问题的根本原因:抢占式执行!!!
4.4 最终完整代码
//表示一个任务
class MyTask implements Comparable<MyTask>{
public Runnable runnable;
//为了方便后续判断使用绝对的时间戳
public long time;
public MyTask(Runnable runnable, long delay) {
this.runnable = runnable;
//取当前时刻的时间戳+delay作为该任务实际执行的时间戳
this.time = System.currentTimeMillis()+delay;
}
@Override
public int compareTo(MyTask o) {
return (int)(this.time-o.time);
//取时间最小的元素
}
}
class MyTimer {
//创建一个锁对象
private Object locker = new Object();
//这个结构就是带有优先级的阻塞队列 核心数据结构
private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
//此处delay是一个形如3000这样的数字(多长时间之后执行该任务)
//这列的元素需要手动封装
//创建一个类 表示两方面 1.执行的任务是啥 2.任务啥时候执行
public void schedule(Runnable runnable, long delay) {
//根据参数构造MyTask,插入队列即可
MyTask myTask = new MyTask(runnable,delay);
queue.put(myTask);
synchronized (locker) {
locker.notify();
}
}
//在这里构造一个线程负责执行具体任务
public MyTimer() {
Thread t = new Thread(() -> {
while(true) {
try {
synchronized (locker) {
//阻塞队列只有阻塞的入队列和阻塞的出队列,没有阻塞的查看队首元素
MyTask myTask = queue.take();
//当前队列无元素 队列阻塞 退出循环
//队列有元素就可以获取到元素
//看钱当前任务时间是否合适
long curTime = System.currentTimeMillis();
if (myTask.time <= curTime) {
//时间到了可以执行任务了
myTask.runnable.run();
} else {
//时间没到
//把刚才取出的任务重新塞回队列中
queue.put(myTask);
//实时时间进行调整
locker.wait(myTask.time-curTime);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
public class ThreadDemo {
public static void main(String[] args) {
MyTimer myTimer = new MyTimer();
myTimer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hi4");
}
},4000);
myTimer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hi3");
}
},3000);
myTimer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hi2");
}
},2000);
myTimer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hi1");
}
},1000);
System.out.println("hi0");
}
}
效果如下:
💛💛💛本期内容回顾💛💛💛
✨✨✨本期内容到此结束啦~