1. 定义与应用场景
- 定义
在软件开发中,定时器是一种非常重要的机制,它允许程序在特定时间间隔后执行某些操作。定时器可以基于硬件实现,也可以由操作系统或软件框架提供。定时器也是软件开发中的⼀个重要组件. 类似于⼀个 “闹钟”. 达到⼀个设定的时间之后, 就执⾏某个指定好的代码. - 应用场景
- 任务调度:在任务调度系统中,可以使用软件定时器来安排任务的执行时间。
- 定时提醒:在应用程序中,可以使用软件定时器来实现定时提醒功能,如闹钟、待办事项提醒等。
- 网络通信:在网络通信中,可以使用软件定时器来控制数据的发送和接收时间。
- 性能监控:在性能监控系统中,可以使用软件定时器来定时采集系统或应用的性能指标。
2. 标准库中的定时器
标准库中提供了⼀个 Timer 类. Timer 类的核⼼⽅法为schedule
,
schedule
包含两个参数. 第⼀个参数指定即将要执⾏的任务代码, 第⼆个参数指定多⻓时间之后执⾏ (单位为毫秒).
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello");
}
}, 3000);
3. 实现定时器
· 需求
在实现定时器之前,我们要先梳理一下定时器的需求:在指定时刻执行相应的任务。于是,根据这个需求,我们可以粗略的罗列出以下步骤:
- 定义一个类表示任务。
- 通过数据结构实现一个容器,存放等待完成的任务。
- 创建线程不断读取容器中的任务。
- 判断读取的任务是否已经到达规定的时刻执行任务。
根据以上步骤,实现定时器并逐步优化。
· 实现
-
定义一个类表示任务
class MyTimerTask { //也可以继承Runnable类并重写Run方法 private Runnable runnable; // 用一个字段表示要执行任务的时刻 private long time; public MyTimerTask(Runnable runnable, long delay){ // 初始化成员变量 this.runnable = runnable; // 将延迟时间换算成任务执行的时刻 this.time = System.currentTimeMillis() + delay; } public void run(){ runnable.run(); } public long getTime(){ return time; } }
这里使用
System
类currentTimeMillis
方法获取当前时间,加上delay
从而换算出任务执行的具体时刻。 -
实现一个存放任务的容器
首先想到的能实现这样的容器的数据结构就是链表,于是我们先用一个链表来表示该容器:
定义一个定时器
MyTimer
,将该容器(链表)加入定时器中,创建schedule
方法,将任务添加到任务链表中。class MyTimer { List<MyTimerTask> list = new LinkedList<>(); public void schedule(Runnable runnable, long delay){ // 将传入的等待执行的代码和等待时间打包成任务并添加到任务链表中 MyTimerTask task = new MyTimerTask(runnable,delay); list.add(task); } }
但是,这样子的代码却面临着这样一个问题:
添加到任务链表中的任务执行时间有着先后顺序,但是添加任务的顺序却是无序的,因此我们要想确定链表中某一时刻哪个任务要执行就得不停的遍历链表来确定,这样的做法会占用大量cpu资源,不利于程序运行,要重新选择容器。
现在对容器的要求是:
在无序添加任务的前提下,程序能够根据任务的执行顺序访问任务列表。也就是说,这个容器要实现的就是在任务被添加到容器中时,容器能够根据任务的执行时间,从快到慢将任务排序,这样就能保证每次只访问容器的第一个任务就能确定是否应该执行了,毫无疑问,优先级队列
就符合这样的要求。class MyTimerTask implements Comparable<MyTimerTask>{ //也可以继承Runnable类并重写Run方法 private Runnable runnable; // 用一个字段表示要执行任务的时刻 private long time; public MyTimerTask(Runnable runnable, long delay){ // 初始化成员变量 this.runnable = runnable; // 将延迟时间换算成任务执行的时刻 this.time = System.currentTimeMillis() + delay; } public void run(){ runnable.run(); } public long getTime(){ return time; } @Override public int compareTo(MyTimerTask o) { return (int) (this.time - o.time); } } class MyTimer { PriorityQueue<MyTimerTask> queue = new PriorityQueue<>(); public void schedule(Runnable runnable, long delay){ // 将传入的等待执行的代码和等待时间打包成任务 MyTimerTask task = new MyTimerTask(runnable,delay); // 将任务提交到优先级队列中,优先级队列会根据 time 的大小从快到慢排序。 queue.offer(task); } }
使用优先级队列时,任务类要实现
Comparable
接口并重写compareTo
方法 -
创建线程不断读取队列中等待执行的任务(读取机制)
准备好容器之后,就需要创建线程读取队列的首个任务,判断是否执行任务。
class MyTimer { PriorityQueue<MyTimerTask> queue = new PriorityQueue<>(); public MyTimer(){ Thread t = new Thread(() -> { while(true) { if(queue.isEmpty()) { //若队列中还没有任务,则跳过以下步骤 continue; } // 若队列不为空,则判断任务是否已经到达指定的执行时间 // 如果执行时间到达,则执行任务 MyTimerTask task = queue.peek(); if(task.getTime() <= System.currentTimeMillis()){ task.run(); queue.poll(); } } }); t.start(); } public void schedule(Runnable runnable, long delay){ // 将传入的等待执行的代码和等待时间打包成任务 MyTimerTask task = new MyTimerTask(runnable,delay); // 将任务提交到优先级队列中,优先级队列会根据 time 的大小从快到慢排序。 queue.offer(task); } }
做到这一步,在单线程下,定时器就已经基本实现了,但是在多线程下,这样的定时器存在着很大的漏洞:线程安全。因此,我们还需要将代码进行进一步的改进以适应多线程机制。
-
线程安全:加锁
在该定时器中,任务提交和任务读取都得保证其操作的原子性,可能出现线程安全的有两个地方:任务读取机制(t 线程)和 任务提交机制(
schedule
方法)。因此必须在这两个地方加上锁。(关于线程安全与加锁操作请参考线程安全)class MyTimer { PriorityQueue<MyTimerTask> queue = new PriorityQueue<>(); // 可以使用this作为锁对象 Object locker = new Object(); public MyTimer(){ Thread t = new Thread(() -> { while (true) { synchronized (locker) { if (queue.isEmpty()) { //若队列中还没有任务,则跳过以下步骤 continue; } // 若队列不为空,则判断任务是否已经到达指定的执行时间 // 如果执行时间到达,则执行任务 MyTimerTask task = queue.peek(); if (task.getTime() <= System.currentTimeMillis()) { task.run(); queue.poll(); } } } }); t.start(); } public void schedule(Runnable runnable, long delay){ synchronized (locker) { // 将传入的等待执行的代码和等待时间打包成任务 MyTimerTask task = new MyTimerTask(runnable, delay); // 将任务提交到优先级队列中,优先级队列会根据 time 的大小从快到慢排序。 queue.offer(task); } } }
-
优化读取机制
实现线程安全后,我们再看回到读取机制的代码中来,会发现这里仍然存在着一些问题:假如现在时刻是 14:00 ,这时我们提交一个任务,设置为半小时后执行,就没出现如下图所示的状况:
所谓“ 忙等 ”,就是在此时刻到最先执行的任务时刻的时间段内,重复做着无意义的“ 判读工作 ”。也就是说,在 14:00 时刻,程序进行一次判读后,又在接下来的半小时内进行着重复的判读,而我们期望的是:在执行一次判读操作后,线程就阻塞等待,直到任务开始执行。class MyTimer { PriorityQueue<MyTimerTask> queue = new PriorityQueue<>(); // 可以使用this作为锁对象 Object locker = new Object(); public MyTimer(){ Thread t = new Thread(() -> { while (true) { try { synchronized (locker) { if (queue.isEmpty()) { //若队列中还没有任务,就阻塞等待任务提交 locker.wait(); } // 若队列不为空,则判断任务是否已经到达指定的执行时间 MyTimerTask task = queue.peek(); // 记录下当前时刻 long curTime = System.currentTimeMillis(); // 如果执行时刻到来,则执行任务 if (task.getTime() <= curTime) { task.run(); queue.poll(); } else { // 判读一次后,等待对应的delay时间后再次判读 locker.wait(task.getTime() - curTime); } } } catch (InterruptedException e) { e.printStackTrace(); } } }); t.start(); } public void schedule(Runnable runnable, long delay){ synchronized (locker) { // 将传入的等待执行的代码和等待时间打包成任务 MyTimerTask task = new MyTimerTask(runnable, delay); // 将任务提交到优先级队列中,优先级队列会根据 time 的大小从快到慢排序。 queue.offer(task); // 提交任务后对线程进行唤醒 locker.notify(); } } }
优化完的代码不仅能保证线程安全,也提高了运行效率。
-
测试
提交三个任务进行测试,观察结果。public class Demo17 { public static void main(String[] args) { MyTimer myTimer = new MyTimer(); myTimer.schedule(new Runnable() { @Override public void run() { System.out.println("hello 3000"); } }, 3000); myTimer.schedule(new Runnable() { @Override public void run() { System.out.println("hello 2000"); } }, 2000); myTimer.schedule(new Runnable() { @Override public void run() { System.out.println("hello 1000"); } }, 1000); } }
输出结果:
hello 1000 hello 2000 hello 3000