定时器就是一个闹钟。它可以设定一个时间,当时间到,就可以执行某个指定的代码。
定时器是实际开发中的一种非常常用的组件。比如网络通信中,如果对方 500ms 内没有返回数据,则要求断开连接尝试重连;又比如一个 Map,希望里面的某个 key 在 3s 之后过期(自动删除)。类似于这样的场景就需要用到定时器。
Java标准库(java.util)中提供了一个定时器类:Timer。Timer 类的核心方法为 schedule()。
目录
b. 定时器可能是有多个线程在执行 schedule 方法,那么就希望在多线程下操作 PriorityQueue 还能线程安全。
一、Timer类的使用
下面是使用Timer类创建定时器的代码示例,通过创建Timer对象timer,与调用timer的schedule()方法完成定时器的设定。
import java.util.Timer;
import java.util.TimerTask;
public class Test {
public static void main(String[] args) {
//1-创建Timer对象
Timer timer = new Timer();
//2-设定定时效果:5秒
timer.schedule(new TimerTask(){
@Override
public void run() {
System.out.println("hello");
}
},5000);
System.out.println("BBB");
}
}
运行代码结果如下:立即执行System.out.println("BBB");语句打印出BBB,等待5s后,执行定时器schedule()中设定的语句,打印"hello"。
1、schedule()方法
schedule()方法的作用是在某个特定的时间后,安排执行某个指定的操作。它有多个重载:
这些重载的共同点是,第一个参数为被安排的任务task,第二个参数(或后面的所有参数共同)表示设定的任务将要执行的时间。
以schedule(TimerTask task, long delay)为例,它的两个参数分别是:
- task:被安排的任务。
- delay:在任务执行之前的等待时间(毫秒数)。
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello");
}
}, 3000);
这里的第一个参数TimerTask,本质上就是一个Runnable。查看TimerTask的源码可以发现,它其实是一个实现了Runnable接口的抽象类。因此,当创建TimerTask对象时,也需要重写实现run()方法。run()方法中定义的行为,表示任务具体要干什么。
(new TimerTask() { ... }
:创建一个匿名内部类,继承自TimerTask
,并重写了run()
方法。)
![](https://img-blog.csdnimg.cn/88692bf087034583a40f904215a6dda4.png)
程序启动后,立即打印出BBB,但未立即执行计时器timer中的代码。等待5秒后,程序打印hello。
这里有一个小细节:打印完毕hello后,程序并未终止退出。这是因为Timer里内置的前台线程阻止了当前进程结束。事实上,run方法的执行正是靠Timer内部的线程控制在时间到了之后执行的。(后面会讲解如何自己实现一个Timer类,Timer内部的线程是如何控制定时的,在那里会有只管的感受。)
2、定时器的应用场景
定时器的应用场景很多,尤其是在网络编程中。
实际中,很多的“等待”并不是无期限的,而是应当指定一个超时时间。比如打开浏览器,访问CSDN。如果此时的网络状况不是很好,那么加载的时间就可能会非常久。所以,浏览器会设置一个超时时间,如果达到了当前的超时时间还没有等待结果,就会提醒用户不要再等待下去了(504 gateway time out)。
3、用Timer管理多个任务
定时器内部可以管理很多个任务。如以下代码中,同时给定时器注册5个任务:
import java.util.Timer;
import java.util.TimerTask;
public class Test {
public static void main(String[] args) {
//1-创建定时器对象timer
Timer timer = new Timer();
//2-多次调用schedule()方法,给定时器注册多个任务
timer.schedule(new TimerTask(){
@Override
public void run() {
System.out.println("hello5");
}
},5000);
timer.schedule(new TimerTask(){
@Override
public void run() {
System.out.println("hello4");
}
},4000);
timer.schedule(new TimerTask(){
@Override
public void run() {
System.out.println("hello3");
}
},3000);
timer.schedule(new TimerTask(){
@Override
public void run() {
System.out.println("hello2");
}
},2000);
timer.schedule(new TimerTask(){
@Override
public void run() {
System.out.println("hello1");
}
},1000);
System.out.println("hello0");
}
}
运行程序后,立即打印hello0,之后每隔一秒进行一次打印。
这里就像传说中时间复杂度为O(0)的休眠排序法(笑)(手动滑稽)
二、代码实现Timer
代码实现Timer主要要考虑到两个问题:
a. 如何实现Timer对象可以同时管理多个任务?
虽然任务有很多,但它们的触发事件是不同的。因此,只需要有一个或一组工作线程来负责每次找这些任务中时间最先达到的即可。
换句话说,一个线程先执行最早的的任务,做完了之后再执行第二早的任务,以此类推。而所有任务中,时间到了的就执行,没到的就再等等。
如何实现“每次找到这些任务中时间最先到达的任务”?——堆!
堆heap是这里要用到的核心数据结构!(排序的效率要低于堆,且插入新的元素时,要维持原序列的规律比较困难。)
Java标准库中就提供了 PriorityQueue.
b. 定时器可能是有多个线程在执行 schedule 方法,那么就希望在多线程下操作 PriorityQueue 还能线程安全。
要保证线程安全,就要更进一步:Java标准库中提供了带优先级的阻塞队列,能够解决线程安全这个问题:PriorityBlockingQueue
通过查看优先级阻塞队列的源码可知,它遵循和优先级队列一样的顺序。可以根据任务的执行时间来建小根堆。不断取队头元素,取出的就是其中最小的元素。需要注意的是,该结构只有阻塞的入队列和阻塞的出队列,没有阻塞的查看队头元素方法。
1、实现思路
a.初步代码
首先创建一个MyTimer来模拟Timer,是一个自定义的定时器类。该类中封装有核心数据结构PriorityBlockingQueue。另外,创建一个类MyTask用于描述一个要执行的任务。一个要执行的任务中包含两方面信息,即:具体要做什么+什么时候做。
要特别注意的是,为了后续的判定方便,MyTask中的时间属性设定为绝对时间。
MyTimer定时器类中定义schedule()方法。其中根据传入的参数构造Task,将其插入队列中即可。注意,schedule()参数中的delay指的是相对时间,如3000,表示的是任务要在3000毫秒后执行。
因此,在构造MyTask时,要有一步绝对时间与相对时间之间的转换:
this.time = System.currentTimeMillis() + delay;
System.currentTimeMillis() 用于获取当前毫秒级别的时间戳,即当前时刻和基准时刻的ms数之差(基准时刻是1970年1月1日00:00:00.000)
代码展现如下:
class MyTask{
public Runnable runnable;
public long time; //为了方便后续判定,使用的是绝对的时间戳
public MyTask(Runnable runnable, long delay) {
this.runnable = runnable;
this.time = System.currentTimeMillis() + delay; //转换:相对时间delay + 当前时间戳 => 绝对的时间戳
}
}
//自定义定时器类
class MyTimer {
//核心数据结构,带有优先级的阻塞队列
private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
//此处的delay是一个相对时间,表示间隔多少时间后执行该任务
public void schedule(Runnable runnable, long delay) {
//根据参数构造MyTask,插入队列即可
MyTask myTask = new MyTask(runnable,delay);
queue.put(myTask);
}
}
b.构造线程来具体执行任务
在计时器类的构造方法中构造扫描线程,用来扫描优先级阻塞队列中的各个任务是否到达可执行的时间。
逻辑很简单:先从queue中取出一个任务,这个任务应是所有任务中时间最小的。再获取当前时间curTime。通过比较curTime与myTask.time的大小来判断是否达到了该任务的执行时间。如果到达了,则执行myTask.runnable.run();如果没有达到,那么只能把刚才取出的任务放回队列中。
//注:该代码不是最终版,仅表明思路
class MyTimer {
... //其他代码
// 在这里构造线程,负责不停地扫描队首元素,判断该任务是否可以执行
public MyTimer() {
Thread t = new Thread(()->{
while (true) {
try {
MyTask myTask = queue.take();
long curTime = System.currentTimeMillis(); //获取当前时间
if(curTime < myTask.time) {
//当前时间小于任务时间:时间还没到
//暂不执行,要把刚才取出的任务塞回队列中
queue.put(myTask);
}else{
//时间到了,执行任务
myTask.runnable.run(); //执行任务
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
但是上面的代码存在两个严重的问题:
1、当前队列里的MyTask元素是按照什么规则来表示优先级的?
2、while(true)带来了CPU的忙等问题。
c.给队列中的MyTask元素设定优先级
PriorityBlockingQueue与PriorityQueue指定建堆顺序的方式是类似的。既可以通过将比较器Comparator传入构造器,也可以直接在元素中实现Comparable接口和compareTo方法。
这里我们只需让MyTask类实现Comparable接口,并以时间time为依据实现compareTo()方法即可。
回顾:PriorityQueue指定比较顺序的方式
在优先级队列中,元素的排序依赖于它们的比较结果。可以通过实现Comparable接口并定义compareTo()方法来指定对象之间的排序规则。这样,优先级队列就能根据对象的比较结果对元素进行自动排序。
如果不希望修改原始对象的类(或无法修改它),也可以创建一个实现了Comparator接口的单独类用于比较对象,并将该比较器作为参数传递给优先级队列的构造函数。使用比较器可以在不修改原始类的情况下定义对象之间的排序规则。
- 如果在构造Priorityqueue时没有提供比较器,而是使用实现了Comparable接口的对象的compareTo方法进行比较,那么优先级队列将根据该方法的比较结果进行排序。
- 如果对象没有实现Comparable接口或没有定义compareTo方法,也没有额外提供比较器,那么在添加元素时可能会抛出classCastException,因为优先级队列无法确定对象之间的顺序。
class MyTask implements Comparable<MyTask>{
public Runnable runnable;
public long time; //为了方便后续判定,使用的是绝对的时间戳
public MyTask(Runnable runnable, long delay) {
this.runnable = runnable;
this.time = System.currentTimeMillis() + delay; //转换:相对时间delay + 当前时间戳 => 绝对的时间戳
}
//指定比较规则
@Override
public int compareTo(MyTask o) {
return (int)(this.time-o.time);
}
}
(tips:在写compareTo()时,可以不可以记忆到底是谁减去谁。可以先随便写一种,然后运行看看效果。如果不对再改成另一种相减方式。)
d.解决CPU的忙等问题
忙等,即虽然CPU确实在“等待”,但它也没捞着休息。就好比原本规定早上8点出门,我早上醒来后看了一眼手表,此时是7点,发现时间还没到;而过1分钟我又看一次表,发现是7点01,时间还没到;又过了1分钟我又去看表,此时是7点02,时间还没到……剩下的时间里我光顾着看表,也没有好好休息。
忙等在上述代码中表现为,while (true) 转的太快了,造成了无意义的 CPU 浪费。比如第一个任务设定的是 1 min 之后执行某个逻辑,但是这里的 while (true) 会导致每秒钟访问队首元素几万次,而当前距离任务执行的时间还有很久,剩下的时间里CPU光顾着进进出出访问队首元素了。
我们需要在等待的过程中释放CPU。
有同学可能会提出使用sleep(),但sleep()不是一个好的选择,因为sleep()的时间必须是固定的。如果sleep()的时间过长,恰好错过了任务的执行时间(睡过头了),就不妙了。
使用wait()就比较合适,可以随时提前结束。在等待过程中随时有新的任务过来,CPU就可以随时去处理。
所以代码逻辑更改为,如果时间还没到,则将刚取出的队首元素放回队列,并进入wait()等待直到时间到。而在插入队列元素时,必须调用notify()方法唤醒锁对象。代码如下:
//自定义定时器类
class MyTimer {
//显式地指定锁对象:locker
private Object locker = new Object();
//核心数据结构,带有优先级的阻塞队列
private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
//此处的delay是一个相对时间,表示间隔多少时间后执行该任务
public void schedule(Runnable runnable, long delay) {
//根据参数构造MyTask,插入队列即可
MyTask myTask = new MyTask(runnable,delay);
queue.put(myTask);
//唤醒正在等待的线程*********************************
synchronized (locker) {
locker.notify();
}
}
// 在这里构造线程,负责不停地扫描队首元素,判断该任务是否可以执行
public MyTimer() {
Thread t = new Thread(()->{
while (true) {
try {
//wait 必加锁
synchronized (locker) {
MyTask myTask = queue.take();
long curTime = System.currentTimeMillis(); //获取当前时间
if(curTime < myTask.time) {
//当前时间小于任务时间:时间还没到
//暂不执行,要把刚才取出的任务塞回队列中
queue.put(myTask);
//等待该任务的时间到*****************************************
locker.wait(myTask.time - System.currentTimeMillis());
}else{
//时间到了,执行任务
myTask.runnable.run(); //执行任务
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
为什么要在schedule方法中唤醒线程?其实也不难理解。
打个比方,比如当前时刻是 14:00 ,约定了 14:30 要执行上课这个任务。取出队首元素,发现时间是14:00,还没到,按逻辑就需要 wait 等待 30 分钟。
而在等待过程中,一个新的任务来了,14:10 要去接水。这样一来,就不能放任刚才的 wait 继续等了,而是需要唤醒 wait,此时工作线程就会重新取队首元素,这时取到的元素就是14:10去接水这个任务。这样做能够保证无论什么时候插入新任务,工作线程都能正确地把最小时间的任务取到。
在上述代码中,在schedule 方法中使用notify的目的是通知等待在locker对象上的线程。这是为了确保当添加新任务时,如果有线程正在等待队列中的任务执行完成,它能够被唤醒并重新检查队列。
因为在定时器类中的线程通过locker.wait(myTask.timeSystem.currentTimeMillis())进行等待,以等待下一任务的执行时间到来。如果没有通知等待的线程,即使有新任务加入队列,等待的线程也会继续等待,而不会重新检查队列是否有更早需要执行的任务。
因此,在schedule方法中调用notify是为了确保正在等待的线程能够及时得到通知,以重新检查队列并执行更早的任务。
2、完整代码
import java.util.*;
import java.util.concurrent.PriorityBlockingQueue;
class MyTask implements Comparable<MyTask>{
public Runnable runnable;
public long time; //为了方便后续判定,使用的是绝对的时间戳
public MyTask(Runnable runnable, long delay) {
this.runnable = runnable;
this.time = System.currentTimeMillis() + delay; //转换:相对时间delay + 当前时间戳 => 绝对的时间戳
}
@Override
public int compareTo(MyTask o) {
return (int)(this.time-o.time);
}
}
//自定义定时器类
class MyTimer {
//显式地指定锁对象:locker
private Object locker = new Object();
//核心数据结构,带有优先级的阻塞队列
private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
//此处的delay是一个相对时间,表示间隔多少时间后执行该任务
public void schedule(Runnable runnable, long delay) {
//根据参数构造MyTask,插入队列即可
MyTask myTask = new MyTask(runnable,delay);
queue.put(myTask);
synchronized (locker) {
locker.notify();
}
}
// 在这里构造线程,负责不停地扫描队首元素,判断该任务是否可以执行
public MyTimer() {
Thread t = new Thread(()->{
while (true) {
try {
//wait 必加锁
synchronized (locker) {
MyTask myTask = queue.take();
long curTime = System.currentTimeMillis(); //获取当前时间
if(curTime < myTask.time) {
//当前时间小于任务时间:时间还没到
//暂不执行,要把刚才取出的任务塞回队列中
queue.put(myTask);
locker.wait(myTask.time - System.currentTimeMillis());
}else{
//时间到了,执行任务
myTask.runnable.run(); //执行任务
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
public class ThreadDemo {
public static void main(String[] args) {
MyTimer myTimer = new MyTimer();
//注册任务
myTimer.schedule(()->{
System.out.println("AAA");
},4000);
myTimer.schedule(()->{
System.out.println("BBB");
},3000);
myTimer.schedule(()->{
System.out.println("CCC");
},2000);
myTimer.schedule(()->{
System.out.println("DDD");
},1000);
System.out.println("EEE");
}
}
运行,查看结果:
三、MyTimer中加锁位置的问题
前面提到,要用wait()和notify()来解决CPU忙等的问题。但是,synchronized的书写位置是会影响结果的正确性的。
现在有以下两种加锁方式:
第一种:
第二种:
其中,第一种方式是正确的加锁方式,第二种方式则会引发线程安全问题。第二种方式会引发何种线程安全问题?为什么?
1、分析
分析这个问题的要点是:线程的调度是随机的、无序的。
在第二种代码书写的情况下,假设执行到put之后就切走了:
t2执行完后,再调度回t1,接下来t1再继续执行到wait()。此时t1将要wait的时间仍是30分钟!这里的wait就导致新加入的要求8:10执行的任务无法及时执行了!
也就是说,t1错过了notify,只会一直等下去直到时间到,但这样就完全忽略了新加入的比当前更早的任务。
而正常的情况下(第一种代码书写的情况下),还是假设执行到put就切走了:
加锁保证了黄色框中的所有操作都是原子的。
2、知识点补充
a.为什么有wait就必须加锁?
在Java中wait()方法是Object类的方法,用于将当前线程置于等待状态,并释放对象的锁。在使用wait()方法之前,必须先获得对象的锁(即在synchronized代码块中)。
wait()方法必须在synchronized代码块中调用:
1. 锁的拥有者才有资格等待和被唤醒:只有获得对象的锁,即当前线程是锁的拥有者,才能调用wait()方法。这是为了避免在没有持有锁的情况下调用wait()方法导致的不确定行为。
2. 释放对象的锁:wait()方法被调用后,当前线程会释放对象的锁,以便其他线程有机会获得锁并执行相应的同步代码。
3. 防止竞态条件:在多线程环境下,wait()方法和唤醒操作(notify()或notifyAll())之间可能存在竞态条件。通过将wait()方法的调用放在同步代码块中,可以确保只有一个线程能够调用wait()方法,避免了竞态条件的发生。
b.什么是竞态条件?
竞态条件(Race Condition)是指多个线程或进程在并发执行时,由于执行顺序的不确定性而导致的结果依赖于线程或进程执行的相对速度和时序的现象。
在并发编程中,竞态条件可能会导致意外的结果,甚至破坏程序的正确性。竞态条件通常发生在多个线程同时访问和操作共享资源时,其中至少一个线程进行写操作。
下面是一些常见的竞态条件情况:
1. 读-修改-写操作:多个线程同时读取某个共享变量的值,然后基于该值进行修改并写回。由于线程之间的执行时序不确定,可能会导致竞争条件和不一致的结果。
2. 检查-执行操作:多个线程同时检查某个条件,如果满足条件则执行相应的操作。如果多个线程同时检查条件,并根据条件结果进行操作,可能会导致竞态条件和操作冲突。
3. 线程间通信问题:当多个线程之间进行通信或协调时,如果没有适当的同步机制,可能会导致竞态条件。例如,一个线程等待另一个线程的完成信号,但无法确保在接收到信号之前线程已经完成。
竞态条件可能导致不正确的结果、数据损坏、死锁或其他意外行为。为了避免竞态条件,需要使用同步机制(如锁、互斥量、信号量等)来协调线程之间的访问和操作共享资源的顺序。同步机制可以确保在访问共享资源时,只有一个线程能够进行操作,从而避免竞态条件的发生。
c.对方法加锁,锁对象如何确定?
当一个方法被声明为synchronized时,它将被视为一个临界区,只有一个线程可以进入该方法执行,其他线程必须等待。
1、对于非静态方法,锁对象是实例对象(即调用该方法的对象),通常使用this关键字作为锁对象。当一个线程进入synchronized方法时,它会自动获取该方法所属对象的锁(即锁定当前实例对象),其他线程需要等待锁释放才能执行相同实例对象的synchronized方法。
public class MyClass { public synchronized void synchronizedMethod() { // 方法体 } }
在上面的示例中,锁对象是调用SynchronizedMethod()方法的实例对象(即this)。
2、对于静态方法,锁对象是该类的Class对象。静态方法属于类级别,与实例对象无关,因此使用类的Class对象作为锁对象。
示例代码如下:
public class MyClass { public static synchronized void synchronizedStaticMethod() { // 方法体 } }
在上面的示例中,锁对象是MyClass.class。
需要注意的是,锁对象的选择应根据具体的需求和同步策略来确定。有时候,需要使用特定的对象作为锁对象,而不是默认的实例对象或类对象。如多个方法共享同一个锁对象。
d.调用wait会释放锁
当线程在锁对象上调用wait()方法时,它会释放该对象上持有的锁(也称为监视器),从而允许其他线程获取该锁并执行synchronized代码。wait()方法还会将调用线程置于等待状态,直到被通知或中断。
调用wait()方法时涉及的步骤如下:
1. 线程释放它在对象上持有的锁。
2. 线程进入等待状态,直到被其他线程通知或中断。
3. 一旦线程被通知(通过同一对象上的notify()或notifyAll()方法)或被中断,它将尝试重新获取锁。
4. 当线程成功重新获取锁时,它可以从上次离开的地方继续执行。在wait()期间释放锁,允许其他线程获取锁并执行同步代码,从而促进线程之间的并发性和协调性。
需要注意的是,为了确保正确的同步并避免出现非法监视器状态异常,应该始终在同步块或同步方法中使用相同的对象作为锁,并在其中调用wait()方法。