1.定时器的应用场景以及概述:
所谓定时器,就相当于一个闹钟,你设置定时时间,在多长时间之后闹钟就会响。所谓的在多线程环境下创建一个定时器就是在多长时间之后,这个线程完成一项任务。
就好比说:我们在弱网的条件下登录B站,我们登录的时候会有一个超时时间,并且在后台的代码中检查此次登录有没有成功,如果到了超时时间还没有登录成功,那么就会提示用户,此次登录已超时。在这里有两大要点:第一个就是超时时间,第二个就是提示用户登录已超时的这个任务
2. 在Java中自带的定时器
那么根据上述对定时器的介绍,相信童鞋们有疑惑,那么我们使用join(指定超时时间时间)或者是sleep(指定休眠时间)方法,让当前的线程处于阻塞状态,那么当前的任务就在这个指定的时间就执行不了了吗,其实这样是可以的,但是这两种方法都是基于系统内部的定时器来实现的。
我们现在先介绍一下,在标准库中定时器的用法,然后我们在根据定时器的特性自己实现一个简单的定时器。
此时的这个Timer类是在java.util.Timer包下的,它的核心方法就是schedule(),schedule的翻译是安排,其参数有两个:第一个参数表示的是要执行的任务是什么,第二个参数表示的是在多长时间后执行
public class demo5 {
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello world");
}
},3000);
//timer。schedule()表示的是在3s之后就会执行new TimerTask()任务中被重写的run()方法
System.out.println("main");
}
}
Timer内部有专门的线程,来负责执行注册的任务。那么此时我们就知道,当前在main()方法中存在两个线程,一个是主线程,另一个是这个专门执行注册任务的线程。本来两个线程之间是并发执行的,但是此时有了定时器,那么这个专属线程在执行程序的前三秒之内,一直处于阻塞状态。那么先被执行的线程就是这个main线程,到了3s之后,这个专属线程就会从阻塞状态变为就绪状态,就会执行任务里的run()方法。
3. 自己实现一个简单的定时器
我们此时先盘一盘要实现一个简单的定时器要有啥东西,就是说Timer内部都需要啥东西
第一步: 描述一个任务,创建一个专门的类来表示一个定时器中的任务(TimerTask).
- 首先我们要有一个将要被执行的任务(runnable)
- 其次就是在多长时间之后执行任务的时间(time)
class MyTask2{
public Runnable runnable;
public long time;
public MyTask2(Runnable runnable, long time) {
this.runnable = runnable;
this.time = System.currentTimeMillis() + time;//此时的time只是表示的是一个时间段,在这里加上系统的当前时间,就便是在一定时间(相加的这个时间)运行这个任务
}
//调用MyTask2类中的run()方法,让任务跑起来
public void run(){
runnable.run();
}
}
第二步: 组织任务(使用一些数据结构把一些任务给组织起来放到一起)
就打个比方现在定时器我们已经创建好了,我们此时有多个任务要被执行,任务一:一个小时之后,去写作业,任务二:三个小时之后,去上课,任务三:10分钟之后,区休息一下。
我们可以想想,我们所创建的定时器肯定是把距离现在时间最近的任务先执行吧,那么我们就要考虑一下要对所有的任务的时间做一个排序。
我们在安排任务的时候,这些任务的顺序都是无序的,但是执行任务的时候,这就不是无序的了,需要按照时间先后来执行,所以说当前的需求就是,能够快速的找到所有任务中,时间最小的任务。
还记得堆(优先队列)嘛?在堆的概念中,有大跟堆,小跟堆的说法,大跟堆就是在堆中堆顶元素是所有元素中最大的那一个,同理小跟堆就是在堆中堆顶元素是所有元素最小的那一个,那么我们此时就把众多的任务放到小跟堆里面,根据时间进行比较,把距离现在时间最短的一个任务放到堆顶。
但是此时我们还要考虑一下,我们是在多线程环境下,那么队列就要考虑到线程安全问题,肯能在多个线程里进行注册任务,同时还有一个专门的线程来去任务,此处的队列就需要注意线程安全问题,所以我们就可能想到把这个优先队列给搞成带有阻塞的,那么就可以在多线程环境下可以是线程安全。
class Timer2{
public PriorityBlockingQueue<MyTask2> queue = new PriorityBlockingQueue<MyTask2>();
public void schedule(Runnable runnable,long time){
//把任务添加到队列中
MyTask2 task2 = new MyTask2(runnable,time);
queue.put(task2);
}
}
创建一个时间类,在这个类里面实现一个schedule()方法,还是和标准库自带的方法一样,设置两个参数,一个是将要中的任务,另一个是过多长执行的时间段。new 出一个任务对象,并且把任务和时间段传给new出来的这个task2引用,通过这个样描述一个任务。然后把这个描述好的任务,添加到带有阻塞的优先级队列中,让优先队列自行调整任务先后执行的次序。
第三步:执行时间到了的任务。
需要先执行时间最靠前的任务,我们在这里需要一个扫描现场层,不停的检查当前的优先队列的队首元素,看看说当前最靠前的这个任务是不是时间到了?
public Timer2(){
//设置一个扫描线程,不停的扫描优先级队列
Thread t = new Thread(()->{
while(true){
//得到队列中的队首元素
try {
MyTask2 task = queue.take();
long curTime = System.currentTimeMillis(); //得到当前系统的时间
if(curTime < task.getTime()){
//如果当前的系统时间不等于这个任务的执行时间
queue.put(task);
}else{
//如果等于直接执行
task.run();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
代码解析:
我们此时设置一个Timer2的构造方法,目的是当我们一旦new出Timer类型的对象,就使用扫描线程扫描优先队列,我们在Timer2类下的schedule()方法中已经把描述好的任务添加到队列中了(queue.put()),此时我们获取当前优先队列中的队首元素和当前的系统时间作比较(curTime == task.getTime()),如果两个时间相等,那么直接执行这个任务,如果不等于,那么就把这个任务又放到队列中.
这个时候我们的定时器已经搞的差不多了,运行一下:
class MyTask2{
public Runnable runnable;
public long time;
public MyTask2(Runnable runnable, long time) {
this.runnable = runnable;
this.time = System.currentTimeMillis() + time;
}
//调用MyTask2类中的run()方法,让任务跑起来
public void run(){
runnable.run();
}
//得到执行的时间
public long getTime(){
return time;
}
}
class Timer2{
public PriorityBlockingQueue<MyTask2> queue = new PriorityBlockingQueue<MyTask2>();
public void schedule(Runnable runnable,long time){
//把任务添加到队列中
MyTask2 task2 = new MyTask2(runnable,time);
queue.put(task2);
}
public Timer2(){
//设置一个扫描线程,不停的扫描优先级队列
Thread t = new Thread(()->{
while(true){
//得到队列中的队首元素
try {
MyTask2 task = queue.take();
//可能有些老铁会问在这里为什么不使用peek()方法呢,把任务拿出来又那任务拿进去,这不减低代码的效率吗?
//确实peek()会更好一点,但是这里也没有啥开销,我们在一下学过堆,堆的调整操作的时间复杂度是logN,我们要是到logN 就近似于O(1),O(1)的时间复杂度,不大吧
//假如说现在有N个任务,这个N=10w,或者N=100W,logN之后就会变得非常小,在说一个定时器里面能有10w个任务吗?这个概率是很小的
long curTime = System.currentTimeMillis(); //得到当前系统的时间
if(curTime < task.getTime()){
//如果当前的系统时间不等于这个任务的执行时间
queue.put(task);
}else{
//如果等于直接执行
task.run();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
public class demo6 {
public static void main(String[] args) {
Timer2 time = new Timer2();
time.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hello world");
}
},3000);
System.out.println("hello main");
}
}
此时我们会发现一个异常,ClassCastException
这个是类型转换异常
,再看后面的描述:MyTask2 cannot be cast to java.lang Comparable
看到这个异常的老铁们有没有恍然大悟,原因就是我们没有重写Comparable接口中的compareTo()方法,那么
为什么要实现这个接口呢
?因为我们把描述好的任务添加到队列中没有规定比较的内容。
此时就找出了代码上述代码中的第一个缺陷:MyTask2没有指定比较规则
像刚才咱们实现的MyTask2这个类的比较规则,并不是默认就存在的,这里需要咱们手动的指定,按照时间的大小来比较,在标准库中的集合类,很多都是雨哦一定的约数显示的,不是随便拿个类就能放这些集合类里面去的。
修改之后的代码:
class MyTask2 implements Comparable<MyTask2>{
public Runnable runnable;
public long time;
public MyTask2(Runnable runnable, long time) {
this.runnable = runnable;
this.time = System.currentTimeMillis() + time;
}
//调用MyTask2类中的run()方法,让任务跑起来
public void run(){
runnable.run();
}
//得到执行的时间
public long getTime(){
return time;
}
@Override
public int compareTo(MyTask2 o) {
//此时表示的是小跟堆
return (int) (this.getTime() - o.getTime());
}
}
运行代码:
我们此时的效果就和标准库中Timer类下的schedule()方法的执行效果是一样的了,但是代码中还有一个缺陷。
代码的第二个缺陷: 在扫描线程中出现了忙等
那么怎样解决这个忙等呢?
其实我们在扫描线程的时候,加上一个带有阻塞的方法,规定线程的阻塞时间(描述好的任务的执行时间-现在系统中的时间),这个方法就是wait(指定阻塞时间),wait()有一个版本指定等待时间(不需要notify()去唤醒,到了时间之后会自动唤醒)
那么有些老铁就会问为什么不使用sleep()方法呢,这个方法也可以指定等待时间?
其实因为sleep()方法,不能中途被唤醒,wait()方法可以被中途唤醒
在线程等待的过程中,可能要有产生的新的任务,新的任务是可能出现在之前所有任务的最前面的,所以我们还要在schedule()方法中,需要添加一个notify操作,用于中途唤醒线程。
class Timer2{
public Object locker = new Object();
public PriorityBlockingQueue<MyTask2> queue = new PriorityBlockingQueue<MyTask2>();
public void schedule(Runnable runnable,long time){
//把任务添加到队列中
MyTask2 task2 = new MyTask2(runnable,time);
queue.put(task2);
//用于中途唤醒线程
synchronized(locker){
locker.notify();
}
}
public Timer2(){
//设置一个扫描线程,不停的扫描优先级队列
Thread t = new Thread(()->{
while(true){
//得到队列中的队首元素
try {
MyTask2 task = queue.take();
long curTime = System.currentTimeMillis(); //得到当前系统的时间
if(curTime < task.getTime()){
//如果当前的系统时间不等于这个任务的执行时间
queue.put(task);
//因为要设置线程等待,那么自然要使用synchronized(),设置一个锁对象
synchronized(locker){
//限定等待时间:任务要执行的时间 - 现在的系统时间
locker.wait(task.getTime() - curTime);
}
}else{
//如果等于直接执行
task.run();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
4.总结
自己构建一个定时器:
- 描述一个任务:runnable + time
- 使用优先队列来组织若干个任务,并且这个优先队列要带有阻塞
- 实现一个schedule()方法来注册任务到队列中
- 创建一个扫描线程,这个扫描线程不停的获取到队首元素,并且判定时间是否到达,另外需要注意的是:让MyTask2类能够试吃比较,以及注意解决这里的忙等问题