深入理解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的核心之一是线程池,利用线程池来执行任务,另外一个核心是存取任务的数据结构,二叉堆。

相关推荐
写bug写bug10 分钟前
深入理解MySQL binlog
数据库·后端·mysql
这里有鱼汤32 分钟前
AKShare被限IP、Tushare要积分?这才是最适合量化用的数据接口
后端·python
天天摸鱼的java工程师1 小时前
凌晨四点,掘金签到 bug 现场抓包,开发同学速来认领!
服务器·前端·后端
SimonKing1 小时前
揭秘自定义注解,背后的面向切面编程(AOP)的艺术
java·后端·架构
楽码2 小时前
概率算法的空乘就坐问题
后端·算法·机器学习
程序员岳焱2 小时前
Spring AI 2025重磅更新!Java程序员的AI时代正式开启
人工智能·后端·openai
程序员爱钓鱼2 小时前
Go语言 并发编程基础:Goroutine 的创建与调度
后端·go·排序算法
jym的梦之独白2 小时前
OAuth 2.0 客户端凭据授予流程
后端
闲情煮粥2 小时前
《记一次Chromadb踩坑实录:藏在源码里的"秘密通道"》
后端
EMQX2 小时前
驶向智能未来:车载 MCP 服务与边缘计算驱动的驾驶数据交互新体验
人工智能·后端