• 多线程 - 定时器


    v2-fba4ff60d8ab6684550fa59adcbee98b_b

    多线程 - 定时器

    定时器的背景知识

    定时器 ~~ (就类似于定闹钟)

    平时的闹钟,有两种风格:

    1. 指定特定时刻,提醒
    2. 指定特定时间段之后,提醒

    这里的“定时器”,不是提醒,而是执行一个实现准备好的方法/代码,它是开发中一个常用的组件,尤其是在网络编程的时候,使用浏览器上网,打开一个网页,很容易出现,“卡了""连不上"的情况.这时就可以使用“定时器”来进行“止损”.

    标准库提供的定时器

    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);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    第一个参数: new TimerTask() => TimerTask这个抽象类实现了Runnable接口,即将要执行的任务代码 ~~ public abstract class TimerTask implements Runnable
    第二个参数: 指定多长时间之后执行(单位为毫秒)

    手动实现一个定时器

    定时器要求:

    1. 让被注册的任务,能够在指定时间被执行.
    2. 一个定时器是可以注册N个任务的,N个任务会按照最初约定的时间,按顺序执行.

    思路:

    在指定时间被执行 => 单独在定时器内部,创建个线程,让这个线程周期性的扫描,判定任务是否是到时间了.如果到时间了,就执行.没到时间,就再等等.
    注册N个任务 => 这个N个任务,就需要使用一个数据结构来保存的,而在当下场景中,使用优先级队列,就是一个很好的选择.再由于这里的每个任务都是需要按时间执行的,时间越靠前,就越先执行,时间小的,优先级就高.此时队首元素,就是整个队列中,最先要执行的任务 => 这时,扫描线程,只需要扫一下队首元素即可,就不必遍历整个队列(如果队首元素还没到执行时间内,后续元素更不可能到时间).

    image-20231006160206998

    问题:

    问题一: 因为调用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);
       }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    但是当前这个代码中存在一个严重的问题, 就是 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.如果没有新任务,则最多等到之前旧任务中的最早任务时间到,就被唤醒.


    高能烧脑预警

    博主代码写的过程中,遇到的一个线程安全/随机调度密切相关的问题.
    考虑一个极端情况:
    image-20231006221842050

    看了上述图示之后,就不难发现,问题出现的原因,是因为当前 take 操作,和 wait 操作,并非是原子的.如果在 take 和 wait 之间加上锁,保证在这个过程中,不会有新的任务过来,问题自然解决(换句话说,只要保证每次 notify 时,确实都正在wait ) => 扩大上述代码锁的范围.

    image-20231006223301553

    代码编写:

    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);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121

    博主备注: 程序里的计时操作,本身就难以做到非常精确,因为操作系统调度线程有时间开销的.存在ms级别的误差,都很正常.也不影响日常使用.如果应用场景,就是对时间误差非常敏感(发射导弹,发射卫星)此时就不会再使用windows, linux这样的操作系统了,而应该使用像vxworks 这样的实时操作系统,这样的系统线程调度开销是极快,可控的,可以保证误差在要求范围内的.

  • 相关阅读:
    搭建docke-cli的调试环境
    浅聊python函数装饰器和闭包
    【pen200-lab】10.11.1.8
    [MySQL] 表的增删查改(CURD)
    【XXL-JOB】1.docker-compose 安装 调配中心
    Neo4j:一、CQL语句
    MATLAB实现AHP层次分析法——以情人节选取礼物为例
    每日一题:请解释什么是闭包(Closure)?并举一个实际的例子来说明。(前端初级)
    武汉新时标文化传媒有限公司短视频运营方案
    Android Java反射与Proxy动态代理详解与使用基础篇(一)
  • 原文地址:https://blog.csdn.net/m0_73740682/article/details/133623571