【多线程】在多线程环境下实现一个定时器

发布于:2023-02-14 ⋅ 阅读:(756) ⋅ 点赞:(0)

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类能够试吃比较,以及注意解决这里的忙等问题
本文含有隐藏内容,请 开通VIP 后查看