2023.10.22 关于 定时器(Timer) 详解

目录

引言

标准库定时器使用

自己实现定时器的代码

模拟实现的两大方面

核心思路

重点理解

自己实现的定时器代码最终代码版本


引言

  • 定时器用于在 预定的时间间隔之后 执行特定的任务或操作

实例理解:

  • 在服务器开发中,客户端向服务器发送请求,等待服务器响应,但可能因为某一故障,导致程序一直无法响应,从而容易出现客户端卡死的情况,所以为了应对该情况,我们通常可以设置一个定时器,若未在规定的时间内完成任务,则可以做一些操作,来取消客户端的等待

标准库定时器使用

java 复制代码
import 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 ,阻塞队列本身就是线程安全的,所以 带优先级的阻塞队列 便十分切合我们的需求

  • 以下是一个自己实现的定时器
java 复制代码
import 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 也在阻塞等待,就出现了死锁

模拟代码示例

java 复制代码
import 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 方法了
java 复制代码
import 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("程序开始运行");
    }
}

原因如下图所示:

相关推荐
charlie11451419111 分钟前
如何使用Qt创建一个浮在MainWindow上的滑动小Panel
开发语言·c++·qt·界面设计
Dcs15 分钟前
VSCode等多款主流 IDE 爆出安全漏洞!插件“伪装认证”可执行恶意命令!
java
神仙别闹19 分钟前
基于Python实现LSTM对股票走势的预测
开发语言·python·lstm
保持学习ing21 分钟前
day1--项目搭建and内容管理模块
java·数据库·后端·docker·虚拟机
京东云开发者32 分钟前
Java的SPI机制详解
java
超级小忍1 小时前
服务端向客户端主动推送数据的几种方法(Spring Boot 环境)
java·spring boot·后端
程序无bug1 小时前
Spring IoC注解式开发无敌详细(细节丰富)
java·后端
小莫分享1 小时前
Java Lombok 入门
java
程序无bug1 小时前
Spring 对于事务上的应用的详细说明
java·后端
食亨技术团队1 小时前
被忽略的 SAAS 生命线:操作日志有多重要
java·后端