前言
已经9个月没有写文章了,不能在继续废物下去。
在工作中用到了ScheduledThreadPoolExecutor,但是以前的文章对java.util.concurrent
包下的工具大部分都分析过了,唯独少一个ScheduledThreadPoolExecutor,这个工具用到的频率很大,SpringBoot的调度器ThreadPoolTaskScheduler也是用它实现的,也就是我们常用的@Scheduled
注解。
最小化测试
一个工具类内部是由多个小组件构成的,我们可以先学习这些最小的组件,看看他运行是怎么样的效果,然后在整体看,但是对于ScheduledThreadPoolExecutor,他的逻辑也是很复杂,第一是他继承ThreadPoolExecutor,就是我们常说的线程池,向线程池中提交一个任务的分支会有很多,这里我们不说线程池了,知道一个关键点就行,就是线程池怎么是怎么获取任务的。
线程池内部有一个阻塞队列,这个队列存放我们提交的任务,当线程池中的线程执行完当前任务时,会从这个阻塞队列中获取新的任务,并调用他的run方法。
而ScheduledThreadPoolExecutor的实现关键,就是自定义了一个阻塞队列,那我们是不是先搞清楚内部的这个阻塞队列是怎么工作的,运行结果是怎么样的,很大程度就理解了ScheduledThreadPoolExecutor。
但是很可惜,内部的这个阻塞队列我们无法直接访问,因为他是非public的。我们new不出来它。
java
static class DelayedWorkQueue extends AbstractQueue<Runnable> implements BlockingQueue<Runnable> {}
但是可以从getQueue()方法获取到。
java
ScheduledThreadPoolExecutor scheduledThreadPoolExecutor = new ScheduledThreadPoolExecutor(1);
BlockingQueue<Runnable> queue = scheduledThreadPoolExecutor.getQueue();
但还有一个问题,向这个队列添加元素需要是Runnable的一个实现类ScheduledFutureTask
,因为内部需要提取这个任务的一些信息,比如时间信息,而这些信息就封装在这里面,但这个实现类我们也new不出来他,因为也是非public的。
那就没办法了吗?不是的,重新改一下jdk源码就好,因为我是有环境的,改一下源码不费劲,但如果你没有源码,直接分析ScheduledThreadPoolExecutor可能更快,因为搭建好openjdk的源码也需要时间,在windows上搭建可能还有其他意想不到错误。
所以当改完访问控制符为public的时候,我们就可以这样使用。
java
private static long triggerTime(long delay, TimeUnit unit) {
return triggerTime(unit.toNanos((delay < 0) ? 0 : delay));
}
static long triggerTime(long delay) {
return System.nanoTime() +delay;
}
public static void main(String[] args) {
ScheduledThreadPoolExecutor.DelayedWorkQueue delayedWorkQueue =new ScheduledThreadPoolExecutor.DelayedWorkQueue();
ScheduledThreadPoolExecutor scheduledThreadPoolExecutor = new ScheduledThreadPoolExecutor(1);
Runnable command = new Runnable() {
@Override
public void run() {
System.out.println("command");
}
};
TimeUnit timeUnit = TimeUnit.SECONDS;
ScheduledThreadPoolExecutor.ScheduledFutureTask sft =scheduledThreadPoolExecutor.new
ScheduledFutureTask(command, null, triggerTime(0, timeUnit), timeUnit.toNanos(-5), 1);
delayedWorkQueue.add(sft);
}
任务取出
向队列中添加元素后,就可以取出了,注意的是,既然是阻塞队列(假设你已经了解过了阻塞队列),这种队列有一个特点,当队列满了的时候,去添加元素,会发生阻塞,相反队列没有元素时,获取的时候也会阻塞,而当调用DelayedWorkQueue
的take方法获取任务时,如果当前任务集合中没有一个符合当前时间能调度的任务时,将会使用另一个并发工具Condition
去让当前线程等待,而具体要等多长时间,取决于最近一个任务到期的时间。
当等待完毕后,内部会进行下一轮获取(因为这里必须设计为一个循环),而这时候就可以获取到要调度的任务了,但是,阻塞队列的take方法一般情况下获取到任务后会删除此元素,那么,这不是我们想要的,因为调度器是循环不断的,既然取出后,且调度完毕(调用了run方法),应到在某一个时刻计算好下一次调度的时间,把他在重新放回阻塞队列中,准备下一次调度。
你看,代码就出来了,下面这段代码也算是调度器的核心了。
java
while (true){
RunnableScheduledFuture<?> take = delayedWorkQueue.take();
take.run();
delayedWorkQueue.add(take);
}
这里的while true
是相当于线程池中的getTask方法,当然他们是有条件的,这里忽略一下,然后从阻塞队列中take一个任务,并调用run方法,之后在重新添加到队列,不然队列中没有任务,无法下一次调度。
但问题是,在哪里计算下一次调度的时间呢?
在此之前,了解一下ScheduledThreadPoolExecutor内部的时间计算。
首先,有一个初始时间,这个参数是我们指定的,他会把传入的初始时间加上System.nanoTime()
的时间,就是这个任务下一次要调度的时间,这个可以看一下ScheduledFutureTask
的构造方法参数。
而下一次调度的时间,是通过这个函数计算的。
java
private void setNextRunTime() {
long p = period;
if (p > 0)
time += p;
else
time = triggerTime(-p);
}
period字段就是我们传入的第二个参数,就是间隔,但是这里你会发现,为什么要判断这个大于0还是小于0的情况呢?,在我们上面的代码中,我们传入的是一个负数,而此时这个分支会走time = triggerTime(-p)
计算下一次调度时间。
这个判断其实就是scheduleWithFixedDelay
和scheduleAtFixedRate
的区别了,这很好理解,假设你有一个任务,这个任务很急,且只能一个线程调度,但是你的任务可能会发生阻塞,本来你是1秒一调,但是由于内部花了2秒的网络IO,那么下一次调度怎么算?
是立即执行吗?因为理论下一次调度的时候被网络IO所占了。
还是继续等1秒执行?可能你的任务特殊,必须间隔一秒调度,不能出现例外。
而这时候ScheduledThreadPoolExecutor也不知道了,所以提供了上面两个方法供你选择,而内部实现的方式,就是setNextRunTime
。
- 当是正数的时候(
scheduleAtFixedRate
),下一次调度的时间是上一次调度时间加上间隔。 - 当是负数的时候(
scheduleWithFixedDelay
),下一次调度的时间是当前时间加上间隔。
就这么简单。
下面就是见证核心的代码,这段代码执行后的效果就实现了一个每隔2秒调度一次command的效果。
java
public static void main(String[] args) {
ScheduledThreadPoolExecutor.DelayedWorkQueue delayedWorkQueue =new ScheduledThreadPoolExecutor.DelayedWorkQueue();
ScheduledThreadPoolExecutor scheduledThreadPoolExecutor = new ScheduledThreadPoolExecutor(1);
Runnable command = new Runnable() {
@Override
public void run() {
System.out.println("command1");
}
};
TimeUnit timeUnit = TimeUnit.SECONDS;
ScheduledThreadPoolExecutor.ScheduledFutureTask sft1 =scheduledThreadPoolExecutor.new
ScheduledFutureTask(command, null, triggerTime(0, timeUnit), timeUnit.toNanos(-2), 1);
delayedWorkQueue.add(sft1);
Executors.newFixedThreadPool(1).submit(new Runnable() {
@Override
public void run() {
try {
while (true){
RunnableScheduledFuture<?> take = delayedWorkQueue.take();
take.run();
delayedWorkQueue.add(take);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
}
任务执行
在回去说,什么时候调用setNextRunTime
?其实ScheduledFutureTask
就是做这个事情的,我们知道ScheduledFutureTask
包裹着我们的Runnable
,最先被调用run
方法的还是他。
下面是他的实现。
java
public void run() {
boolean periodic = isPeriodic();
if (!canRunInCurrentRunState(periodic))
cancel(false);
else if (!periodic)
ScheduledFutureTask.super.run();
else if (ScheduledFutureTask.super.runAndReset()) {
setNextRunTime();
reExecutePeriodic(outerTask);
}
}
我们知道调度任务是可以取消的,而上面代码的一些分支就是判断能不能被继续调度,最后一个分支才是执行我们的业务逻辑,执行成功后,计算下一次调度的时间,并且reExecutePeriodic
方法重新将这个任务添加到队列。
任务排序
DelayedWorkQueue
还有一个特点,任务可以有多个,那么在take时,有两种选择,第一种遍历这个任务集合,找出最近要调度的任务。
第二种,在add任务时,计算出当前任务的下一次调度时间,排放在队列最前面,take的时候直接获取第一个元素即可。
(内部存放任务的数据结构是一个基于数组的二叉堆)
很显然第二种效率高,DelayedWorkQueue
也是采用这种方法,当add队列时候,会进行排序。
java
private void siftUp(int k, RunnableScheduledFuture<?> key) {
while(true) {
if (k > 0) {
int parent = k - 1 >>> 1;
RunnableScheduledFuture<?> e = this.queue[parent];
if (key.compareTo(e) < 0) {
this.queue[k] = e;
setIndex(e, k);
k = parent;
continue;
}
}
this.queue[k] = key;
setIndex(key, k);
return;
}
}
如果你看不懂上面代码,尤其k - 1 >>> 1;
是因为不了解二叉堆,这里就不说了,这里明白这个队列会在插入任务的时候会按优先级排列就行。
判断任务到期
那最后一个问题,take的时候,怎么计算任务到期的呢?
首先他内部都是使用纳秒计算,为了方便理解,我们直接用秒说,假如每次实例化ScheduledThreadPoolExecutor
,世界的时间都归零,开始一秒一秒增加。
我们在10秒的时候设置了一个任务,那么当系统启动后,获取到你这个任务还需要10秒执行,那么他会使用Condition
等待10秒,10秒过后被唤醒,再次判断,使用《任务需要执行的时间》-《当前时间》,如果<=0,那么表示这个任务可以被调度。
而下面getDelay()方法就是获取任务的到期时间,如果是正数,表示还有多长时间到期,如果是负数,表示已经到期,且到期了多长时间。
java
public long getDelay(TimeUnit unit) {
return unit.convert(this.time - System.nanoTime(), TimeUnit.NANOSECONDS);
}
综上所述,ScheduledThreadPoolExecutor的核心之一是线程池,利用线程池来执行任务,另外一个核心是存取任务的数据结构,二叉堆。