上一篇我们学到了了阻塞队列,这一篇我们将使用阻塞队列和以前提到的优先队列结合体阻塞优先队列来实现一个常用的定时器案例。
1. 什么是定时器?
定时器可以强制终止请求:浏览器内部都有一个定时器,发送了请求之后,定时器就开始计时。如果在打开浏览界面的时候,浏览器的响应时间过了响应时间,就会强制终止请求。
在日常生活中,闹钟就是一个常见的定时器,在我们设定好的时间,闹钟就会自动响起,并且无论设置闹钟时间的前后,设置的哪个时间先到就先响起。(比如先设置了一个14:00的闹钟,后设置了一个13:00的闹钟,13:00的闹钟就会先响起,而不是先设置的14:00的闹钟)
2. 如何实现定时器?
通过上面的分析,我们可以发现,必须使用阻塞优先队列这种数据结构来保证谁的时间早谁先执行,同时其他线程任务被阻塞。
定时器由以下几个部分组成:
- 用一个类来描述任务
- 用一个 阻塞优先队列来组织若干个任务,让队首元素就是时间最早的任务,只检测队首元素是否到了时间即可
- 用一个线程循环扫描当前阻塞队列的队首元素,如果时间到,就执行指定任务
- 给队列中添加任务
3. 代码实现
由于阻塞优先队列中任务间要比较谁的时间早,所以该类需要实现Comparable接口,重写compareTo方法。
/**
* 1.用一个类来描述任务
*/
static class Task implements Comparable<Task>
{
private Runnable command;//当前任务
private long time;//开始执行的时间
/**
* @param command 当前任务
* @param after 多少毫秒后执行
*/
public Task(Runnable command, long after)
{
this.command = command;
this.time = System.currentTimeMillis() + after;
}
/**
* 执行任务
*/
public void run()
{
command.run();
}
@Override
public int compareTo(Task o)
{
//谁的时间小先执行
return (int) (this.time - o.time);
}
}
/**
* 2.用一个 阻塞 优先队列来组织若干个任务,让队首元素就是时间最早的任务,只检测队首元素即可
*/
static class Timer
{
//锁对象
final Object locker = new Object();
//库中的阻塞优先队列
private PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue<>();
//为保证添加任务线程和扫描任务线程使用同一个锁(解决线程不安全问题),将同一锁对象传入构造方法
public Timer()
{
Worker worker = new Worker(queue, locker);
worker.start();
}
/**
* 4.给队列中添加任务
*/
public void schedule(Runnable runnable, long after)
{
Task task = new Task(runnable, after);
//将任务添加到阻塞优先队列
queue.put(task);
//当添加一个新任务后,唤醒等待的扫描线程
synchronized (locker)
{
locker.notify();
}
}
}
扫描线程为了判断当前队列中的最早任务是否到了时间
/**
* 3.用一个线程循环扫描当前阻塞队列的队首元素,如果时间到,就执行指定任务
*/
static class Worker extends Thread
{
final Object locker;
private PriorityBlockingQueue<Task> queue;
/**
* @param queue 阻塞 优先队列
* @param object 锁对象
*/
public Worker(PriorityBlockingQueue<Task> queue, Object object)
{
this.queue = queue;
this.locker = object;
}
@Override
public void run()
{
while (true)
{
try
{
//1.取出队首元素,判断时间是否到了
Task task = queue.take();//如果队列为空,则阻塞队列插入新元素后,notify方法将会唤醒
//2.检查当前任务是否时间到了
long curTime = System.currentTimeMillis();//获取当前时间
if (task.time > curTime)//如果当前时间小于任务时间,说明时间还未到,把任务塞回队列
{
queue.put(task);
synchronized (locker)
{
//为了避免忙等(时间未到一直判断时间),使用wait方法
//等到时间再循环,减少循环次数
locker.wait(task.time - curTime);
}
}
else//时间到了,执行对应任务
{
task.run();
}
}
catch (InterruptedException e)
{
e.printStackTrace();
break;//如果线程出现问题必须停止循环
}
}
}
}
4. 代码难点分析
4.1 忙等
扫描线程在扫描时会一直判断队首元素是否到了其发生时间,但是如果时间一直未到,就会一直循环扫描,浪费CPU资源,这就是忙等状态。举个例子,比如现在要等待下课,我看了一下表还有四十分钟,然后一直看表吗?每一秒看一次?每一分钟看一次?显然这样太浪费精力,我们的做法是等待一段时间再去看,比如等待10分钟再去看表,或者等待20分钟再去看一次表,这样就大大减少了消耗。
为此,为了避免忙等状态,我们使用wait方法,使扫描线程发现当前队首元素还未到指定时间时,使线程阻塞,减少不必要的循环扫描判断。等待时间为任务发生时间 减去 当前时间,但是如果这中间插入其他任务(插入任务可能比当前任务时间早),线程一直阻塞怎么办?下一处分析将解答~
long curTime = System.currentTimeMillis();//获取当前时间
if (task.time > curTime)//如果当前时间小于任务时间,说明时间还未到,把任务塞回队列
{
queue.put(task);
synchronized (locker)
{
//等到时间再循环,减少循环次数
locker.wait(task.time - curTime);
}
}
4.2 一处唤醒,两处阻塞
如下图所示,我们在扫描线程中可能出现两次阻塞
- 当阻塞队列为空时,出现阻塞,但是一旦schedule方法添加进了新任务,其中的notify方法将唤醒这个线程
- 当忙等时调用wait方法,出现阻塞,同样,一旦插入了新的任务,其中的notify方法将唤醒这个线程,扫描线程将进入下次循环
这里也会有两种情况
- 插入的任务早于当前队首任务时间,这时队首元素将变为新的任务,再次执行下面的判断,这就解决了之前提到的问题~
- 插入的任务等于或晚于当前队首任务时间,扫描线程继续休眠
新任务,其中的notify方法将唤醒这个线程
- 当忙等时调用wait方法,出现阻塞,同样,一旦插入了新的任务,其中的notify方法将唤醒这个线程,扫描线程将进入下次循环
这里也会有两种情况
- 插入的任务早于当前队首任务时间,这时队首元素将变为新的任务,再次执行下面的判断,这就解决了之前提到的问题~
- 插入的任务等于或晚于当前队首任务时间,扫描线程继续休眠