源码分析与实战——深入理解ScheduledThreadPool线程池延时执行机制(二)

1、前言

在上篇博文中《源码分析与实战——深入理解ScheduledThreadPool线程池延时执行机制(一)》中,其实我们留下几个问题:ScheduledThreadPool是如何保证任务delay到时准时执行的任务执行顺序会不会出问题?毕竟按照siftUp()方法的源代码,是无法保证队列是按time有序的。我们今天来探讨一下。

2、复习

上篇博文中我们学习了ScheduledThreadPool的add()方法,知道无论当前线程池里面是否有空闲线程,都会将新任务加入队列,确定插入位置的方法就是:从数组尾部位置开始,循环进行减一除以二,比较2个位置哪个time值更大一些,将time值小的换到前面去。关键方法siftUp()的源码再贴一次吧:

        private void siftUp(int k, RunnableScheduledFuture<?> key) {
            while (k > 0) {
                int parent = (k - 1) >>> 1;
                RunnableScheduledFuture<?> e = queue[parent];
                if (key.compareTo(e) >= 0)
                    break;
                queue[k] = e;
                setIndex(e, k);
                k = parent;
            }
            queue[k] = key;
            setIndex(key, k);
        }

这里k的初始值就是队列尾的下标。compareTo()方法不再重复了。

key.compareTo(e) >= 0意味着当前任务比parent对应的任务更晚,所以位置不动,直接break出来;否则,通过while循环不停的向上查找,直到找到一个合适的位置为止。在这个过程中,每一次比较都会交换2个任务的位置。通过这种方式,将新任务插入到合适的位置。大家可以看到,这个不是一个逐个比较的过程,其实是二分查找,目的是为了提高效率。

3、分析

3.1 入列分析

我们接下来通过画图来描述一下整个入列、出列过程。queue数组我们可以先画成这样:
在这里插入图片描述
如果我们每次插入的新任务的time都比前面的任务的time大,毫无疑问,key.compareTo(e) >= 0每次都成立,那么任务会被依次放到列队尾,假设我们先依次放入若干任务,delay时间分别设成:1秒、3秒、5秒、7秒、9秒、11秒,我们简化一下任务的表示,直接以delay时间来表示它,那么队列肯定是这样子:
在这里插入图片描述
接下来加入一个新任务,delay为6秒。此时新任务需要和下标(6-1)>>>1=2的位置的任务进行比较,即queue[2],6大于5,所以siftUp()代码里面执行了break,新任务放到了尾部,:
在这里插入图片描述
此时我们发现:队列已经开始不是按time有序排列了!

通过siftUp()方法的源码的二分查找逻辑,其实队列是进行了分段排序,每一段里面的任何一个任务的time,都会比下一段里面任何一个任务的time要小。对于上面的新任务来说,只需要保证它放到下标为2的任务后面即可;以下标2为界,前面的任务的time都比它要小,后面的暂时先不管。

如果新任务delay为2秒,我们也分析一下:
在这里插入图片描述
这个过程我就不描述了,直接对着siftUp()方法体逐句比对即可得到上面结果。加入delay为2秒的任务时,它进行了2轮比较,放到了下标为2的位置。整个队列里面任何一个任务,都保证它自己执行的时间,比自己下标减一除以二的任务要晚!感觉有点归并的思想。

总结:

入列的时候,并不保证队列里面所有任务都按他们执行时间的先后顺序排列,但是保证每个任务的执行时间,都比(i-1)/2位置的任务要靠后

2.2 示例代码

我们先看看上面这个程序执行的示例吧,非常简单:

public class TestSchedule {
    public static void main(String[] args) {
        //此时不可再用ExecutorService
        ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
        System.out.println("scheduledExecutorService start ...");

        scheduledExecutorService.schedule(()-> System.out.println("exec no.1 task,delay 1s ..."),1, TimeUnit.SECONDS );
        scheduledExecutorService.schedule(()-> System.out.println("exec no.2 task,delay 3s  ..."),3, TimeUnit.SECONDS );
        scheduledExecutorService.schedule(()-> System.out.println("exec no.3 task,delay 5s  ..."),5, TimeUnit.SECONDS );
        scheduledExecutorService.schedule(()-> System.out.println("exec no.4 task,delay 7s  ..."),7, TimeUnit.SECONDS );
        scheduledExecutorService.schedule(()-> System.out.println("exec no.5 task,delay 9s  ..."),9, TimeUnit.SECONDS );
        scheduledExecutorService.schedule(()-> System.out.println("exec no.6 task,delay 11s  ..."),11, TimeUnit.SECONDS );

        //测试线程
        scheduledExecutorService.schedule(()-> System.out.println("exec no.7 task,delay 2s  ..."),2, TimeUnit.SECONDS );
    }
 }

执行结果:

scheduledExecutorService start ...
exec no.1 task,delay 1s ...
exec no.7 task,delay 2s  ...
exec no.2 task,delay 3s  ...
exec no.3 task,delay 5s  ...
exec no.4 task,delay 7s  ...
exec no.5 task,delay 9s  ...
exec no.6 task,delay 11s  ...

很明显,任务的执行,还是完全有序的。一个无序队列,如何做到有序执行的呢?关键要看一下出列逻辑。

2.3 出列分析
2.3.1出列源码

通过上篇博文分析,我们知道:这个线程池出列使用的是take()方法,里面最关键一个调用是:
在这里插入图片描述
delay<=0,意味着队列头的任务到了执行的时候了,通过finishPoll()方法取得这个任务并返回。

        private RunnableScheduledFuture<?> finishPoll(RunnableScheduledFuture<?> f) {
            int s = --size;
            RunnableScheduledFuture<?> x = queue[s];
            queue[s] = null;
            if (s != 0)
                siftDown(0, x);
            setIndex(f, -1);
            return f;
        }

s=–size,表面意思是队列里面现有任务的数量减1,即当前队列最后一个任务的下标。如果这个值等于0,说明队列空了;不为0,说明队列不空,需要执行siftDown()方法。这又是一个关键点了,是接下来要分析的重点。

先看siftDown()源代码:

 		private void siftDown(int k, RunnableScheduledFuture<?> key) {
            int half = size >>> 1;
            while (k < half) {
                int child = (k << 1) + 1;
                RunnableScheduledFuture<?> c = queue[child];
                int right = child + 1;
                if (right < size && c.compareTo(queue[right]) > 0)
                    c = queue[child = right];
                if (key.compareTo(c) <= 0)
                    break;
                queue[k] = c;
                setIndex(c, k);
                k = child;
            }
            queue[k] = key;
            setIndex(key, k);
        }

siftDown()方法有2个参数0和x:0代表队列头的下标,x是队列尾任务。因为我们要从队列头取出一个任务交给线程去执行,因此需要从队列里面找出一个合适的任务放到队列头,作为下一个即将被执行的任务。siftDown()本质上就是做了这件事情。

参数k初值为0,size是队列中现有任务数量,half相当于只考虑队列的前半段。我们结合上面例子来分析一下这个过程。

2.3.2 出列过程分析

上面例子中,初始状态的队列如下:
在这里插入图片描述
现在我们要开始执行任务,从队列头取出第一个任务,此时怎么样填充新的队列头呢?
在这里插入图片描述
根据siftDown()的业务逻辑,我们第一次比较示意图如下:
在这里插入图片描述
可以看到,3>2成立,因此 if (right < size && c.compareTo(queue[right]) > 0)为真,c = queue[child = right];会被执行。
在这里插入图片描述

因为5>2,所以key.compareTo© <= 0不成立,需要继续执行循环体,此时把c放到k的位置,k指向child,即:
在这里插入图片描述
接下来进行下一次循环:
在这里插入图片描述
此时right < size不成立,所以c = queue[child = right];不会被执行。继续判断key.compareTo© <= 0,5<11成立,因此跳出循环。

最后把key放到k的位置,流程结束。
在这里插入图片描述
此时,大家可以看到,我们从队列头取出一个任务后,提到队列头的任务肯定是最先需要执行的任务。

结论——

也就是说,整个队列并不需要时时都保证按执行的先后顺序排列,但是队列头永远是最先被执行的任务。而且取出队列头任务的同时会对队列进行筛选,保证新的队列头任务一定是剩余任务里面需要最先执行的任务。

3、阻塞与唤醒

线程被唤醒开始执行任务,其实有2种途径:

3.1 加入队列时唤醒

在这里插入图片描述
queue[0]==e,意思就是当前队列为空,新加入的这个任务直接放到了队列头。此时唤醒等待中的线程去队列中取任务并执行。此时有空闲线程会被唤醒,如果线程池里线程数量没有到达指定上线,会创建一个新线程去队列取任务并执行。

3.2 队列头任务还没到执行时间

此时要看take()方法的下半段,这几条语句最关键:

						if (leader != null)
                            available.await();
                        else {
                            Thread thisThread = Thread.currentThread();
                            leader = thisThread;
                            try {
                                available.awaitNanos(delay);
                            } finally {
                                if (leader == thisThread)
                                    leader = null;
                            }
                        }

这里使用了leader作为判断依据,什么意思呢?因为当前所有的任务都没有到可以执行的时间。这时,当第一个结束任务的线程试图取任务执行时,它将自己设为leader,然后调用available.awaitNanos(delay),即自旋阻塞等待delay时长。之后,其他任务结束当前任务,也试图取下一个任务执行时,leader已经有了,不为null,这个线程就直接调用available.await();将自己无限时的阻塞起来。也就是说,有一个线程在等待队列头的任务可以被执行,其他线程都被完全阻塞

当available.awaitNanos(delay)超时后,在finally代码块,leader被解除了,置为null。此时其实已经到队列头任务的执行时间。因此进行下一次循环是,if (delay <= 0)成立,return finishPoll(first);返回任务给线程开始执行。

3.3 总结

也就是说,整个线程池的阻塞与唤醒流程如下:

  1. 初建线程池ScheduledThreadPool,指定线程数量为n;
  2. 前n个任务添加时,n个线程被创建;即使有的线程已经完成了任务,线程也会被创建。也就是说,无论如何,n个线程先被创建再说;
  3. 当有新任务被加入时,会唤醒休眠中的线程,试图去取队列头任务并执行;
  4. 如果这个任务执行时间到了,则直接取出执行;
  5. 如果没到,则调用条件变量的awaitNanos()方法,进行自旋阻塞等待delay时长后,取出任务执行;在这个期间,所有结束任务的其他线程都直接进入阻塞状态。
  6. 更多的情况下,线程一直在忙碌,阻塞里面堆积了很多待执行任务,此时不需要新任务加入唤醒线程这个过程,线程会一直会调用available.awaitNanos()自我阻塞,并且超时唤醒自己去执行任务。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值