目录
引言
- 定时器用于在 预定的时间间隔之后 执行特定的任务或操作
实例理解:
- 在服务器开发中,客户端向服务器发送请求,等待服务器响应,但可能因为某一故障,导致程序一直无法响应,从而容易出现客户端卡死的情况,所以为了应对该情况,我们通常可以设置一个定时器,若未在规定的时间内完成任务,则可以做一些操作,来取消客户端的等待
标准库定时器使用
javaimport java.util.Timer; import java.util.TimerTask; public class ThreadDemo24 { 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("运行定时器任务A"); } },3000); timer.schedule(new TimerTask() { @Override public void run() { System.out.println("运行定时器任务B"); } },4000); } }
- 上述代码中的 schedule 方法的效果是给定时器 注册一个任务,任务不会立即执行,而是在指定时间进行执行
- 上述代码中的 schedule 方法有两个参数,一个参数为 TimerTask 接口,对 run 方法进行重写,从而指定要执行的任务,另一个参数为等待时间且单位为毫秒
注意:
- 一个定时器可以同时安排多个任务
- 定时器执行完任务之后,进程并不会立即退出,因为定时器内部需要维护一组线程来执行这些任务,这些线程被称为 前台线程
- 当我创建一个定时器并安排任务时,定时器会启动一个或多个线程,这些线程负责按计划执行任务
- 这些线程会一直运行,直到定时器被取消或程序显式地终止,这样做到目的是为了确保定时器能够准时执行任务,即使主线程已经完成或已退出
- 由于这些前台线程在定时器内部运行,所以它们会影响进程的退出
- 如果定时器中的任务尚未完成,这些前台线程将阻止进程退出,直到所有任务执行完毕或定时器被取消
- 这确保了任务得到完整执行,并且程序能够正常结束
- 从而需要注意的是,在使用定时器时不再需要它,我们应该主动取消定时器以释放资源并停止前台线程的执行
- 这样可以避免不必要的资源占用和线程执行
自己实现定时器的代码
模拟实现的两大方面
- 在指定时间执行所注册的任务
- 一个定时器可注册多个任务,且这多个任务按照约定时间,顺序执行
核心思路
- 有一个扫描线程,负责判定任务是否到执行时间
- 需要一个 数据结构 来保存所有被注册的任务
注意:
- 此处的每个任务均带有时间,并且一定是时间越靠前,就执行
- 所以在当下的场景中使用 优先级队列 便是一个很好的选择
- 时间小的,作为优先级高的
- 此时队首元素 就是整个队列中 最先要执行的任务
- 此时 扫描线程仅需扫描一下队首元素即可,不必遍历整个队列
- 因为队首元素还没到执行时间,后续元素更不可能到执行时间
- 当然 此处的优先级队列会在 多线程 环境下使用
- 因为 调用 schedule 方法是一个线程,扫描是另一个线程,从而此处涉及到线程安全问题
- 我们可以使用 标准库提供的 PriorityBlockingQueue ,阻塞队列本身就是线程安全的,所以 带优先级的阻塞队列 便十分切合我们的需求
- 以下是一个自己实现的定时器
javaimport java.util.concurrent.PriorityBlockingQueue; 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) { // 当前想要实现队首元素是 时间最小的任务 // 这两是 谁减谁,不需要去记,往往可以试一试就知道了 // 要么就是 this.time - o.time, 要么就是 o.time - this.time 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()) { // 如果时间还没到,就把任务塞回队列 queue.put(myTask); // 在 put 之后,进行一个 wait 等待 this.wait(myTask.getTime() - curTime); }else { // 如果时间到了,就把任务进行执行 myTask.run(); } } } catch (InterruptedException e) { e.printStackTrace(); } } }); 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(); } } } public class ThreadDemo25 { public static void main(String[] args) { MyTimer timer = new MyTimer(); timer.schedule(new Runnable() { @Override public void run() { System.out.println("执行了任务A"); } },1000); timer.schedule(new Runnable() { @Override public void run() { System.out.println("执行了任务B"); } },2000); } }
运行结果:
重点理解
- MyTask 类是用来描述一个任务的
- 其中包含 要执行的任务内容 和 任务在什么时候执行
- 正因为我们使用 优先级阻塞队列 来保存我们所有被注册的任务
- 所以我们需要指定当前任务的优先级是什么
- 此处我们任务的优先级是 越早执行的任务其优先级越高,即队首元素是时间最小的任务
- 从而我们需要实现一个 Comparable 接口,并重写 compareTo 方法
注意:
- 此处的变量 time 为 long 类型,所以需要强制类型转换为 int 类型
- 而且此处到底是(this.time - o.time)还是(o.time - this.time),我们仅试试就知道了,毕竟就这两种减法,不要去死记硬背
- 关于这里的时间戳
- 在 Java 中,System.currentTimeMillis() 是一个静态方法,它返回自1970年1月1日午夜(格林威治时间)以来当前时间的毫秒数
- 即返回一个 long类型的值,表示当前时间与1970年1月1日午夜之间的毫秒数差,这个值可以用来计算时间间隔、时间戳等操作
- 类似于输出 1626379152345
- 这里我们为什么要引入 wait 和 notify 呢?
- queue.put 操作是从 queue 中取出首元素
- 此时的 queue.put 操作是放在 while 循环中的,因为我们想保证任务能够及时执行,所以不断的循环取出我们 queue 中的首元素,拿出来与当前的时间进行比较,以免错过任务的执行时间
- 但是我们会发现,当距离队首元素执行任务还有很长一段时间的时候,queue 也会快速循环地将队首元素取出、比较、放回,那此时很显然就是在 忙等,CPU 不停地执行该循环操作,却毫无意义
- 那么我们便可以直接引入wait 和 notify 来解决此情形
- 当 queue 取出队首元素进行比较时,如果发现其还未到执行时间,那么将再次把该元素放回到 queue 中,然后再 wait 阻塞等待当前时间与首元素执行时间的时间差
- 正因为在 wait 阻塞等待的期间中,可能还会插入新元素,并且不能保证该新元素是否会成为 queue 中新的首元素,所以在我们每 插入一个新元素时,都需要进行 notify 一次, 唤醒线程,然后继续执行 while 循环中的操作
- 所以此处不能简单的使用 sleep 进行阻塞等待,因为无法感知新元素插入所导致的新改变
- 使用 wait 的前提是得拥有锁对象,所以要进行加锁操作
- 那么此时的 synchronized 有以下两种加锁方式,哪种更好呢?
- 我们拿 方案二的加锁方式 进行分析
- 当 线程t 执行 queue.take 语句时,此时便会将 queue 的队首元素取出来,然后准备进行比较操作
- 假设队首元素的执行时间为 11点,且此时的时间为 9点,即该队首元素还未到执行时间,那么便将会把该元素重新放回到 queue 中
- 如果此时的 线程t 正准备要执行 wait 进行阻塞等待时,CPU 转而执行其他,也就是在还未 wait 的情况下,又新增了一个任务,并且此时该任务的执行时间为 10点
- 那么在新增任务的前提下,继续执行 wait 阻塞等待 2个小时 ,此时便会直接错过准备在10点 执行的新任务
- 造成上述情况最主要的原因就是方案二并没有保证 take 和 wait 这两个操作执行的原子性,导致在执行这两个操作之间,可能会 put 进一个新任务
- 所以我们可以将 synchronized 加锁的范围扩大,直接将 锁的范围扩大到 方案一,以此想保证 take 和 wait 操作的原子性
- 但是仅这样我们能解决上述问题嘛?
- 也就是能否保证在执行 take 和 wait 这两个操作时,执行这两个操作之间,不会再 put 进一个新任务
- 显然仅通过扩大上述 synchronized 加锁的范围扩大,并不能完全保证,我们得保证再对锁对象加锁时,其 put 方法也需放入到 同一个锁对象的锁中,即将使用方案二
- 也就是当 扫描线程t 对 锁对象进行了加锁操作,此时其他线程便不能调用被 同一个 锁对象 加了锁的代码块
- 具体来说就是当主线程调用 schedule 方法准备执行 queue.put 语句插入新任务时, 便因为 扫描线程t 未释放 锁对象,所以主线程不能获取到锁对象,从而便会阻塞在 锁外,从而只要当 扫描线程t 释放了锁对象,主线程才能获取到锁对象,也就才能执行 queue.put 语句,才能往 queue 队列中插入新任务
- 所以通过以上修改,我们便能很好的保证在执行 take 和 wait 这两个操作时,执行这两个操作之间,不会再 put 进一个新任务
- 仔细思考上述代码,我们还会发现问题
- 那就是通过 synchronized 加锁的范围扩大 和 把 put 方法也放入到 同一个锁对象的锁中 这两个操作,虽然解决了出现下图所示情况
- 但是经过上述调整,该代码会存在 死锁 的情况
- 假设此时 new 了一个 MyTimer 对象定时器
- 那么此时就会初始化并调用 MyTimer 构造方法,构造方法就会创建一个线程t1,并开始执行其 run 方法,此时 线程t 便会拿到锁对象,程序进入 run 方法,但是由于 queue 队列中没有元素,因此就会在 queue.take 处阻塞等待,直到有任务放入队列中
- 此时主线程通过调用 schedule 方法准备往 queue 队列中加入任务,但是由于 线程t 已经拿到锁对象了,且并未释放锁对象,所以此时在准备执行 queue.put 语句时,便会阻塞等待,所以此时 schedule 无法将任务 put 到 queue 队列中,这时 线程t 在阻塞等待,schedule 也在阻塞等待,就出现了死锁
模拟代码示例
javaimport java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; class Syn { private Object locker = new Object(); BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(); public Syn() { Thread t1 = new Thread(() -> { synchronized (locker) { try { queue.take(); } catch (InterruptedException e) { e.printStackTrace(); } } }); t1.start(); } public void say() throws InterruptedException { synchronized (locker) { queue.put(Integer.valueOf("1")); } } } public class TestSyn { public static void main(String[] args) throws InterruptedException { Syn syn = new Syn(); Thread.sleep(1); syn.say(); System.out.println("主线程打印 queue 中的值:"+ syn.queue.take()); } }
执行结果:
- 我们发现新插入到 queue 中的值并未打印到控制台
通过 jconsole 观察线程情况:
- 综上所述,为了 防止死锁的发生,我们又需将 queue.put 操作放到 锁外
- 与上文通过 synchronized 加锁的范围扩大 和 把 queue.put 操作也放入到 同一个锁对象的锁中 这两个操作来保证 take 和 wait 这两个操作执行的原子性,也就是在执行 take 和 wait 这两个操作之间,不会再 put 进一个新任务
- 从而这里 queue.take 无论是放在锁外还是锁内,都会引发问题
自己实现的定时器代码最终代码版本
- 使用 优先级阻塞队列 无论如何修改代码总会存在 问题,所以 我们直接转而使用 优先普通级队列
- 不再使用 自带阻塞效果的 take 和 put 方法了
javaimport java.util.PriorityQueue; // 创建一个类, 用来描述定时器中的一个任务 class MyTimerTask implements Comparable<MyTimerTask> { // 任务啥时候执行. 毫秒级的时间戳. private long time; // 任务具体是啥. private Runnable runnable; public MyTimerTask(Runnable runnable, long delay) { // delay 是一个相对的时间差. 形如 3000 这样的数值. // 构造 time 要根据当前系统时间和 delay 进行构造. time = System.currentTimeMillis() + delay; this.runnable = runnable; } public long getTime() { return time; } public Runnable getRunnable() { return runnable; } @Override public int compareTo(MyTimerTask o) { // 认为时间小的, 优先级高. 最终时间最小的元素, 就会放到队首. // 怎么记忆, 这里是谁减去谁?? 不要记!! 记容易记错~~ // 随便写一个顺序, 然后实验一下就行了. return (int) (this.time - o.time); // return (int) (o.time - this.time); } } // 定时器类的本体 class MyNewTimer { // 使用优先级队列, 来保存上述的 N 个任务 private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>(); // 用来加锁的对象 private Object locker = new Object(); // 定时器的核心方法, 就是把要执行的任务添加到队列中. public void schedule(Runnable runnable, long delay) { synchronized (locker) { MyTimerTask task = new MyTimerTask(runnable, delay); queue.offer(task); // 每次来新的任务, 都唤醒一下之前的扫描线程. 好让扫描线程根据最新的任务情况, 重新规划等待时间. locker.notify(); } } // MyTimer 中还需要构造一个 "扫描线程", 一方面去负责监控队首元素是否到点了, 是否应该执行; 一方面当任务到点之后, // 就要调用这里的 Runnable 的 Run 方法来完成任务 public MyNewTimer() { // 扫描线程 Thread t = new Thread(() -> { while (true) { try { synchronized (locker) { while (queue.isEmpty()) { // 注意, 当前如果队列为空, 此时就不应该去取这里的元素. // 此处使用 wait 等待更合适. 如果使用 continue, 就会使这个线程 while 循环运行的飞快, // 也会陷入一个高频占用 cpu 的状态(忙等). locker.wait(); } MyTimerTask task = queue.peek(); long curTime = System.currentTimeMillis(); if (curTime >= task.getTime()) { // 假设当前时间是 14:01, 任务时间是 14:00, 此时就意味着应该要执行这个任务了. // 需要执行任务. queue.poll(); task.getRunnable().run(); } else { // 让当前扫描线程休眠一下, 按照时间差来进行休眠. // Thread.sleep(task.getTime() - curTime); locker.wait(task.getTime() - curTime); } } } catch (InterruptedException e) { e.printStackTrace(); } } }); t.start(); } } // 写一个定时器 public class ThreadDemo27 { public static void main(String[] args) { MyNewTimer timer = new MyNewTimer(); timer.schedule(new Runnable() { @Override public void run() { System.out.println("hello 3"); } }, 3000); timer.schedule(new Runnable() { @Override public void run() { System.out.println("hello 2"); } }, 2000); timer.schedule(new Runnable() { @Override public void run() { System.out.println("hello 1"); } }, 1000); System.out.println("程序开始运行"); } }
原因如下图所示: