深入理解ScheduledThreadPoolExecutor底层实现原理

前言

已经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)计算下一次调度时间。

这个判断其实就是scheduleWithFixedDelayscheduleAtFixedRate的区别了,这很好理解,假设你有一个任务,这个任务很急,且只能一个线程调度,但是你的任务可能会发生阻塞,本来你是1秒一调,但是由于内部花了2秒的网络IO,那么下一次调度怎么算?

是立即执行吗?因为理论下一次调度的时候被网络IO所占了。

还是继续等1秒执行?可能你的任务特殊,必须间隔一秒调度,不能出现例外。

而这时候ScheduledThreadPoolExecutor也不知道了,所以提供了上面两个方法供你选择,而内部实现的方式,就是setNextRunTime

  1. 当是正数的时候(scheduleAtFixedRate),下一次调度的时间是上一次调度时间加上间隔。
  2. 当是负数的时候(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的核心之一是线程池,利用线程池来执行任务,另外一个核心是存取任务的数据结构,二叉堆。

相关推荐
追逐时光者2 小时前
免费、简单、直观的数据库设计工具和 SQL 生成器
后端·mysql
初晴~2 小时前
【Redis分布式锁】高并发场景下秒杀业务的实现思路(集群模式)
java·数据库·redis·分布式·后端·spring·
盖世英雄酱581362 小时前
InnoDB 的页分裂和页合并
数据库·后端
小_太_阳2 小时前
Scala_【2】变量和数据类型
开发语言·后端·scala·intellij-idea
直裾3 小时前
scala借阅图书保存记录(三)
开发语言·后端·scala
星就前端叭3 小时前
【开源】一款基于Vue3 + WebRTC + Node + SRS + FFmpeg搭建的直播间项目
前端·后端·开源·webrtc
小林coding4 小时前
阿里云 Java 后端一面,什么难度?
java·后端·mysql·spring·阿里云
AI理性派思考者4 小时前
【保姆教程】手把手教你在Linux系统搭建早期alpha项目cysic的验证者&证明者
后端·github·gpu
从善若水5 小时前
【2024】Merry Christmas!一起用Rust绘制一颗圣诞树吧
开发语言·后端·rust