目录
一、前言
二、ScheduledThreadPoolExecutor线程池
三、DelayedWorkQueue延迟阻塞队列
四、工作原理
五、源码分析
5.1 定义
5.2 成员属性
5.3 构造函数
5.4 入队方法
5.4.1 offer添加元素
5.4.2 扩容grow()
5.4.3 向上堆化siftUp
5.5 出队方法
5.5.1 take() 消费元素
5.5.2 finishPoll() 出队列
5.5.3 向下堆化siftDown
5.5.4 poll()
5.5.5 poll(long timeout, TimeUnit unit)
5.5.6 remove() 删除指定元素
六、总结
线程池运行时,会不断从任务队列中获取任务,然后执行任务。如果我们想实现延时或者定时执行任务,重要一点就是任务队列会根据任务延时时间的不同进行排序,延时时间越短的就排在队列的前面,先被获取执行。
队列是先进先出的数据结构,就是先进入队列的数据,先被获取。但是有一种特殊的队列叫做优先级队列,它会对插入的数据进行优先级排序,保证优先级越高的数据首先被获取,与数据的插入顺序无关。
实现优先级队列高效常用的一种方式就是使用堆。
ScheduledThreadPoolExecutor继承自ThreadPoolExecutor,所以其内部的数据结构和ThreadPoolExecutor基本一样,并在其基础上增加了按时间调度执行任务的功能,分为延迟执行任务和周期性执行任务。
ScheduledThreadPoolExecutor的构造函数只能传3个参数corePoolSize、ThreadFactory、RejectedExecutionHandler,默认maximumPoolSize为Integer.MAX_VALUE。
工作队列是高度定制化的延迟阻塞队列DelayedWorkQueue,其实现原理和DelayQueue基本一样,核心数据结构是二叉最小堆的优先队列,队列满时会自动扩容,所以offer操作永远不会阻塞,maximumPoolSize也就用不上了,所以线程池中永远会保持至多有corePoolSize个工作线程正在运行。
public ScheduledThreadPoolExecutor(int corePoolSize,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
// 调用父类ThreadPoolExecutor的构造方法来创建定时任务线程池
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue(), threadFactory, handler); // 传入DelayedWorkQueue阻塞队列
}
DelayedWorkQueue 也是一种设计为定时任务的延迟队列,它的实现和DelayQueue一样,不过是将优先级队列和DelayQueue的实现过程迁移到本身方法体中,从而可以在该过程当中灵活的加入定时任务特有的方法调用。
ScheduledThreadPoolExecutor之所以要自己实现阻塞的工作队列,是因为 ScheduleThreadPoolExecutor 要求的工作队列有些特殊。
DelayedWorkQueue是一个基于堆的数据结构,类似于DelayQueue和PriorityQueue。在执行定时任务的时候,每个任务的执行时间都不同,所以DelayedWorkQueue的工作就是按照执行时间的升序来排列,执行时间距离当前时间越近的任务在队列的前面(注意:这里的顺序并不是绝对的,堆中的排序只保证了子节点的下次执行时间要比父节点的下次执行时间要大,而叶子节点之间并不一定是顺序的)。
堆结构如下图:
可见,DelayedWorkQueue是一个基于最小堆结构的队列。堆结构可以使用数组表示,可以转换成如下的数组:
在这种结构中,可以发现有如下特性: 假设“第一个元素” 在数组中的索引为 0 的话,则父结点和子结点的位置关系如下:
为什么要使用DelayedWorkQueue呢?
static class DelayedWorkQueue extends AbstractQueue
implements BlockingQueue {
/*
* A DelayedWorkQueue is based on a heap-based data structure
* like those in DelayQueue and PriorityQueue, except that
* every ScheduledFutureTask also records its index into the
* heap array. This eliminates the need to find a task upon
* cancellation, greatly speeding up removal (down from O(n)
* to O(log n)), and reducing garbage retention that would
* otherwise occur by waiting for the element to rise to top
* before clearing. But because the queue may also hold
* RunnableScheduledFutures that are not ScheduledFutureTasks,
* we are not guaranteed to have such indices available, in
* which case we fall back to linear search. (We expect that
* most tasks will not be decorated, and that the faster cases
* will be much more common.)
*
* All heap operations must record index changes -- mainly
* within siftUp and siftDown. Upon removal, a task's
* heapIndex is set to -1. Note that ScheduledFutureTasks can
* appear at most once in the queue (this need not be true for
* other kinds of tasks or work queues), so are uniquely
* identified by heapIndex.
*/
/* 借助Google翻译:
DelayedWorkQueue基于堆的数据结构,如DelayQueue和PriorityQueue中的数据结构,除了每个
ScheduledFutureTask还将其索引记录到堆数组中。这消除了在取消时找到任务的需要,大大加快了移除(从
O(n)到O(log n)),并减少了垃圾保留,否则通过等待元素在清除之前升至顶部而发生垃圾保留。但是因为
队列也可能包含不是ScheduledFutureTasks的RunnableScheduledFutures,所以我们不能保证有这样的索引可
用,在这种情况下我们会回到线性搜索。 (我们希望大多数任务都不会被装饰,而且更快的情况会更常见。)
所有堆操作都必须记录索引更改 - 主要在siftUp和siftDown中。删除后,任务的heapIndex设置为-1。请注
意,ScheduledFutureTasks最多可以出现在队列中一次(对于其他类型的任务或工作队列,这不一定是这样),
因此由heapIndex唯一标识。
*/
DelayedWorkQueue 的类继承关系如下:
其包含的方法定义如下:
// 初始时,数组长度大小。
private static final int INITIAL_CAPACITY = 16;
// 使用数组来储存队列中的元素,根据初始容量创建RunnableScheduledFuture类型的数组
private RunnableScheduledFuture>[] queue = new RunnableScheduledFuture>[INITIAL_CAPACITY];
// 使用ReentrantLock来保证多线程并发安全问题。
private final ReentrantLock lock = new ReentrantLock();
// 队列中储存元素的大小
private int size = 0;
// 特指队列头任务所在leader线程。也就是等待要去执行队列头任务的线程
private Thread leader = null;
// 当队列头的任务延时时间到了,或者新线程可能需要成为leader,用来唤醒等待线程
private final Condition available = lock.newCondition();
DelayedWorkQueue是用数组来储存队列中的元素,数组类型为RunnableScheduledFuture,其实就类似于Runnable,只不过加了一些定时任务的属性,核心数据结构是二叉最小堆的优先队列,队列满时会自动扩容。
数组类型是一个接口,本质的实现还是Runnable:
public interface RunnableScheduledFuture extends RunnableFuture, ScheduledFuture {
// 该任务是否是周期性的
boolean isPeriodic();
}
注意这里的leader,它是Leader-Follower模式的变体,用于减少不必要的定时等待。这什么意思呢?
对于多线程的网络模型来说:所有线程会有三种身份中的一种:leader和follower,以及一个干活中的状态:proccesser。它的基本原则就是,永远最多只有一个leader。而所有follower都在等待成为leader。线程池启动时会自动产生一个Leader负责等待网络IO事件,当有一个事件产生时,Leader线程首先通知一个Follower线程将其提拔为新的Leader,然后自己就去干活了,去处理这个网络事件,处理完毕后加入Follower线程等待队列,等待下次成为Leader。这种方法可以增强CPU高速缓存相似性,及消除动态内存分配和线程间的数据交换。
DelayedWorkQueue 是 ScheduledThreadPoolExecutor 的静态类部类,默认只有一个无参构造方法。
static class DelayedWorkQueue extends AbstractQueue
implements BlockingQueue {
// ...
}
public void put(Runnable e) {
offer(e);
}
public boolean add(Runnable e) {
return offer(e);
}
// timeout和unit两个参数并没有用
public boolean offer(Runnable e, long timeout, TimeUnit unit) {
return offer(e);
}
DelayedWorkQueue 提供了三个插入元素方法:
通过源码我们发现与普通阻塞队列相比,这三个添加方法都是调用offer方法。那是因为它没有队列已满的条件,也就是说可以不断地向DelayedWorkQueue添加元素,当元素个数超过数组长度时,会进行数组扩容。
ScheduledThreadPoolExecutor提交任务时调用的是DelayedWorkQueue.add,而add、put等一些对外提供的添加元素的方法都调用了offer。
public boolean offer(Runnable x) {
if (x == null)
throw new NullPointerException();
// 将要加入队列的任务转换为RunnableScheduledFuture类型,因为存储任务的堆数组就是这个类型
RunnableScheduledFuture> e = (RunnableScheduledFuture>) x;
// 使用lock保证并发操作安全
final ReentrantLock lock = this.lock;
// 加锁
lock.lock();
try {
// 获取当前队列中有多少个元素
int i = size;
// 如果要超过数组长度,就要进行数组扩容
if (i >= queue.length)
// 数组扩容
grow();
// 将队列中元素个数加一
size = i + 1;
// 如果是第一个元素,那么就不需要排序,直接赋值就行了
if (i == 0) {
queue[0] = e;
setIndex(e, 0);
} else {
// 调用siftUp方法,使插入的元素变得有序。
siftUp(i, e);
}
// 表示新插入的元素是队列头,那么就要唤醒正在等待获取任务的线程来获取这个队列头中的任务
if (queue[0] == e) {
// 准备设置新的队列头
leader = null;
// 唤醒正在等待获取任务的线程
available.signal();
}
} finally {
// 释放锁
lock.unlock();
}
// 成功入队则返回true
return true;
}
其基本流程如下:
offer基本流程图如下:
可以看到,当队列满时,不会阻塞等待,而是继续扩容。新容量newCapacity在旧容量oldCapacity的基础上扩容50%(oldCapacity >> 1相当于oldCapacity /2)。最后Arrays.copyOf(将旧数组拷贝到一块新的数组空间中),Arrays.copyOf的作用就是先根据newCapacity创建一个新的空数组,然后将旧数组的数据复制到新数组中。
private void grow() {
int oldCapacity = queue.length;
// 每次扩容增加原来数组的一半数量。
// grow 50%
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity < 0) // overflow
newCapacity = Integer.MAX_VALUE;
// 使用Arrays.copyOf来复制一个新数组
queue = Arrays.copyOf(queue, newCapacity);
}
新添加的元素先会加到堆底,然后一步步和上面的父亲节点比较,若小于父亲节点则和父亲节点互换位置,循环比较直至大于父亲节点才结束循环。通过循环,来查找元素key应该插入在堆二叉树哪个节点位置,并交互父节点的位置。
private void siftUp(int k, RunnableScheduledFuture> key) {
// 当k==0时,就到了堆二叉树的根节点了,跳出循环
while (k > 0) {
// 父节点位置坐标, 相当于(k - 1) / 2
int parent = (k - 1) >>> 1;
// 获取父节点位置元素
RunnableScheduledFuture> e = queue[parent];
// 如果key元素大于父节点位置元素,满足条件,那么跳出循环
// 因为是从小到大排序的。
if (key.compareTo(e) >= 0)
break;
// 否则就将父节点元素存放到k位置
queue[k] = e;
// 这个只有当元素是ScheduledFutureTask对象实例才有用,用来快速取消任务。
setIndex(e, k);
// 重新赋值k,寻找元素key应该插入到堆二叉树的那个节点
k = parent;
}
// 循环结束,k就是元素key应该插入的节点位置
queue[k] = key;
// 将key插入到k位置
setIndex(key, k);
}
代码很好理解,就是循环的根据key节点与它的父节点来判断,如果key节点的执行时间早于父节点,则将两个节点交换,使执行时间靠前的节点排列在队列的前面。
假设新入队的节点的延迟时间(调用getDelay()方法获得)是 5 ,执行过程如下:
1、先将新的节点添加到数组的尾部,这时新节点的索引k为7
2、计算新父节点的索引:parent = (k - 1) >>> 1,parent = 3,那么queue[3]的时间间隔值为8,因为 5 < 8 ,将执行queue[7] = queue[3]
3、这时将k设置为3,继续循环,再次计算parent为1,queue[1]的时间间隔为3,因为 5 > 3 ,这时退出循环,最终k为3
可见,每次新增节点时,只是根据父节点来判断,而不会影响兄弟节点。
DelayedWorkQueue 提供了以下几个出队方法
Worker工作线程启动后就会循环消费工作队列中的元素,因为ScheduledThreadPoolExecutor的keepAliveTime=0,所以消费任务其只调用了DelayedWorkQueue.take。take()方法的基本流程如下:
public RunnableScheduledFuture> take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
// 自旋,只有任务到了要执行的时间才将任务出队返回
for (;;) {
RunnableScheduledFuture> first = queue[0];
// 如果没有任务,就让线程在available条件下等待。
if (first == null)
available.await();
else {
// 获取任务的剩余延时时间
long delay = first.getDelay(NANOSECONDS);
// 如果延时时间到了,就返回这个任务,用来执行。
if (delay <= 0)
return finishPoll(first);
// 如果任务还没有到时间,则将first设置为null,当线程等待时,不持有first的引用
first = null; // don't retain ref while waiting
// 如果前面有线程在等待,当前线程直接进入等待状态
if (leader != null)
// 条件锁
available.await();
// 如果前面没有有线程在等待
else {
// 记录一下当前等待队列头任务的线程
Thread thisThread = Thread.currentThread();
// 将当前线程作为leader
leader = thisThread;
try {
// 当任务的延时时间到了时,能够自动超时唤醒。
available.awaitNanos(delay);
} finally {
// 唤醒后再次获得锁后把leader再置空
if (leader == thisThread)
leader = null;
}
}
}
}
} finally {
// 唤醒等待任务的线程
if (leader == null && queue[0] != null)
available.signal();
ock.unlock();
}
}
take基本流程图如下:
take线程阻塞等待:
可以看出这个生产者take线程会在两种情况下阻塞等待:
take方法是什么时候调用的呢?
在ThreadPoolExecutor中,getTask方法,工作线程会循环地从workQueue中取任务。但定时任务却不同,因为如果一旦getTask方法取出了任务就开始执行了,而这时可能还没有到执行的时间,所以在take方法中,要保证只有在到指定的执行时间的时候任务才可以被取走。
leader线程
再来说一下leader的作用,这里的leader是为了减少不必要的定时等待。leader线程的设计,是Leader-Follower模式的变种,旨在于为了不必要的时间等待。当一个take线程变成leader线程(可以最先拿到队列中弹出任务去执行的线程)时,只需要等待下一次的延迟时间,而不是leader线程的其他take线程则需要等leader线程拿到队列中任务后,才唤醒其他take线程,其他的take线程唤醒后再去争抢锁,谁抢到锁了,谁就是新的leader线程。
举例来说,如果没有leader,那么在执行take时,所有的线程都要执行available.awaitNanos(delay),假设当前线程执行了该段代码,这时还没有signal,第二个线程也执行了该段代码,则第二个线程也要被阻塞。多个线程执行该段代码是没有作用的,因为只能有一个线程会从take中返回queue[0](因为有lock),其他线程这时再返回for循环执行时取的queue[0],已经不是之前的queue[0]了,然后又要继续阻塞。
所以,为了不让多个线程频繁的做无用的定时等待,这里增加了leader,如果leader不为空,则说明队列中第一个节点已经在等待出队,这时其它的线程会一直阻塞,减少了无用的阻塞(注意,在finally中调用了signal()来唤醒一个线程,而不是signalAll())。也就是说我们保证只会有一个线程在等待队列中的任务出队即可,其他的线程直接全部阻塞,当第一个线程成功获取到队列中的任务后再去唤醒其他的线程,来成为新的leader线程。
堆顶元素delay<=0,执行时间到,出队列就是一个向下堆化的过程siftDown。
// 移除队列头元素
private RunnableScheduledFuture> finishPoll(RunnableScheduledFuture> f) {
// 将队列中元素个数减一
int s = --size;
// 获取队列末尾元素x
RunnableScheduledFuture> x = queue[s];
// 原队列末尾元素设置为null
queue[s] = null;
if (s != 0)
// 因为移除了队列头元素,所以进行重新排序。
siftDown(0, x);
setIndex(f, -1);
return f;
}
堆的删除方法主要分为三步:
由于堆顶元素出队列后,就破坏了堆的结构,需要组织整理下,将堆尾元素移到堆顶,然后向下堆化:
private void siftDown(int k, RunnableScheduledFuture> key) {
// 无符号右移,相当于size/2
int half = size >>> 1;
// 通过循环,保证父节点的值不能大于子节点。
while (k < half) {
// 左子节点, 相当于 (k * 2) + 1
int child = (k << 1) + 1;
// 左子节点位置元素
RunnableScheduledFuture> c = queue[child];
// 右子节点, 相当于 (k * 2) + 2
int right = child + 1;
// 如果左子节点元素值大于右子节点元素值,那么右子节点才是较小值的子节点。
// 就要将c与child值重新赋值
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方法执行时包含两种情况,一种是没有子节点,一种是有子节点(根据half判断)。
例如:
没有子节点的情况:
假设初始的堆如下:
1、假设 k = 3 ,那么 k = half ,没有子节点,在执行siftDown方法时直接把索引为3的节点设置为数组的最后一个节点:
有子节点的情况:
假设 k = 0 ,那么执行以下步骤:
1、获取左子节点,child = 1 ,获取右子节点, right = 2 :
2、由于 right < size ,这时比较左子节点和右子节点时间间隔的大小,这里 3 < 7 ,所以 c = queue[child] ;
3、比较key的时间间隔是否小于c的时间间隔,这里不满足,继续执行,把索引为k的节点设置为c,然后将k设置为child;
4、因为 half = 3 ,k = 1 ,继续执行循环,这时的索引变为:
5、这时再经过如上判断后,将k的值为3,最终的结果如下:
6、最后,如果在finishPoll方法中调用的话,会把索引为0的节点的索引设置为-1,表示已经删除了该节点,并且size也减了1,最后的结果如下:
可见,siftdown方法在执行完并不是有序的,但可以发现,子节点的下次执行时间一定比父节点的下次执行时间要大,由于每次都会取左子节点和右子节点中下次执行时间最小的节点,所以还是可以保证在take和poll时出队是有序的。
立即获取队列头元素,当队列头任务是null,或者任务延时时间没有到,表示这个任务还不能返回,因此直接返回null。否则调用finishPoll方法,移除队列头元素并返回。
public RunnableScheduledFuture> poll() {
final ReentrantLock lock = this.lock;
lock.lock();
try {
RunnableScheduledFuture> first = queue[0];
// 队列头任务是null,或者任务延时时间没有到,都返回null
if (first == null || first.getDelay(NANOSECONDS) > 0)
return null;
else
// 移除队列头元素
return finishPoll(first);
} finally {
lock.unlock();
}
}
超时等待获取队列头元素,与take方法相比较,就要考虑设置的超时时间,如果超时时间到了,还没有获取到有用任务,那么就返回null。其他的与take方法中逻辑一样。
public RunnableScheduledFuture> poll(long timeout, TimeUnit unit)
throws InterruptedException {
long nanos = unit.toNanos(timeout);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
for (;;) {
RunnableScheduledFuture> first = queue[0];
// 如果没有任务。
if (first == null) {
// 超时时间已到,那么就直接返回null
if (nanos <= 0)
return null;
else
// 否则就让线程在available条件下等待nanos时间
nanos = available.awaitNanos(nanos);
} else {
// 获取任务的剩余延时时间
long delay = first.getDelay(NANOSECONDS);
// 如果延时时间到了,就返回这个任务,用来执行。
if (delay <= 0)
return finishPoll(first);
// 如果超时时间已到,那么就直接返回null
if (nanos <= 0)
return null;
// 将first设置为null,当线程等待时,不持有first的引用
first = null; // don't retain ref while waiting
// 如果超时时间小于任务的剩余延时时间,那么就有可能获取不到任务。
// 在这里让线程等待超时时间nanos
if (nanos < delay || leader != null)
nanos = available.awaitNanos(nanos);
else {
Thread thisThread = Thread.currentThread();
leader = thisThread;
try {
// 当任务的延时时间到了时,能够自动超时唤醒。
long timeLeft = available.awaitNanos(delay);
// 计算剩余的超时时间
nanos -= delay - timeLeft;
} finally {
if (leader == thisThread)
leader = null;
}
}
}
}
} finally {
if (leader == null && queue[0] != null)
// 唤醒等待任务的线程
available.signal();
lock.unlock();
}
}
删除指定元素一般用于取消任务时,任务还在阻塞队列中,则需要将其删除。当删除的元素不是堆尾元素时,需要做堆化处理。
public boolean remove(Object x) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
int i = indexOf(x);
if (i < 0)
return false;
//维护heapIndex
setIndex(queue[i], -1);
int s = --size;
RunnableScheduledFuture> replacement = queue[s];
queue[s] = null;
if (s != i) {
//删除的不是堆尾元素,则需要堆化处理
//先向下堆化
siftDown(i, replacement);
if (queue[i] == replacement)
//若向下堆化后,i位置的元素还是replacement,说明四无需向下堆化的,
//则需要向上堆化
siftUp(i, replacement);
}
return true;
} finally {
lock.unlock();
}
}
假设初始的堆结构如下:
这时要删除8的节点,那么这时 k = 1,key为最后一个节点:
这时通过上文对siftDown方法的分析,siftDown方法执行后的结果如下:
这时会发现,最后一个节点的值比父节点还要小,所以这里要执行一次siftUp方法来保证子节点的下次执行时间要比父节点的大,所以最终结果如下:
使用优先级队列DelayedWorkQueue,保证添加到队列中的任务,会按照任务的延时时间进行排序,延时时间少的任务首先被获取。
相关文章:【线程池】Java的线程池
【线程池】Java线程池的核心参数
【线程池】Executors框架创建线程池
【线程池】ScheduledExecutorService接口和ScheduledThreadPoolExecutor定时任务线程池使用详解 【线程池】线程池的拒绝策略(饱和策略)
【线程池】线程池的ctl属性详解
【线程池】史上最全的ThreadPoolExecutor源码详解