多线程 - 定时器

多线程 - 定时器_第1张图片

多线程 - 定时器

定时器的背景知识

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

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

  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);
}

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

手动实现一个定时器

定时器要求:

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

思路:

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

多线程 - 定时器_第2张图片

问题:

问题一: 因为调用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.如果没有新任务,则最多等到之前旧任务中的最早任务时间到,就被唤醒.


高能烧脑预警

博主代码写的过程中,遇到的一个线程安全/随机调度密切相关的问题.
考虑一个极端情况:
多线程 - 定时器_第3张图片

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

多线程 - 定时器_第4张图片

代码编写:

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 这样的实时操作系统,这样的系统线程调度开销是极快,可控的,可以保证误差在要求范围内的.

你可能感兴趣的:(学习笔记,javaEE的学习,java,性能优化,数据库,java-ee,学习,程序人生)