参考:https://www.imooc.com/article/34083
1. 前言
上一篇文章,我们详细的讲解了ScheduledThreadPoolExecutor,并给出了它与ThreadPoolExecutor的关系。ScheduledThreadPoolExecutor中有一个重点我们没有详细的介绍,那就是DelayedWorkQueue类,这个类实现了从队列中延迟取节点,下面我们将详细分析这个类具体实现。
2. 提出问题
我们带着以下问题看如何实现的。
- DelayedWorkQueue的数据结构是怎样的
- DelayedWorkQueue如何进行入队出队
- DelayedWorkQueue如何实现延迟取出队
3. 问题分析
我们先看下DelayedWorkQueue类定义
static class DelayedWorkQueue extends AbstractQueue<Runnable>
implements BlockingQueue<Runnable> {
/*
* 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基于堆的数据结构,对于堆的介绍虽然不是本文重点,但是由于后续的代码需要了解堆的存取,所以我们详细介绍下堆、以及如何进行数据的存取的
我们先来看下什么是堆
- 它是一个完全二叉树,即除了最后一层节点不是满的,其他层节点都是满的,即左右节点都有
- 它不是二叉搜索树,即左节点的值都比父节点值小,右节点的值都不比父节点值小,这样查找的时候,就可以通过二分的方式,效率是(log N)。
- 它是特殊的二叉树,它要求父节点的值不能小于子节点的值。这样保证大的值在上面,小的值在下面。所以堆遍历和查找都是低效的,因为我们只知道从根节点到子叶节点的每条路径都是降序的,但是各个路径之间都是没有联系的,查找一个值时,你不知道应该从左节点查找还是从右节点开始查找
- 它可以实现快速的插入和删除,效率都在(log N)左右。所以它可以实现优先级队列
堆是一个二叉树,但是它最简单的方式是通过数组去实现二叉树,而且因为堆是一个完全二叉树,就不存在数组空间的浪费。怎么使用数组来存储二叉树呢?
就是用数组的下标来模拟二叉树的各个节点 ,比如说根节点就是0,第一层的左节点是1,右节点是2。由此我们可以得出下列公式:
// 对于n位置的节点来说:
int left = 2 * n + 1; // 左子节点
int right = 2 * n + 2; // 右子节点
int parent = (n - 1) / 2; // 父节点,当然n要大于0,根节点是没有父节点的
对于堆来说,只有两个操作,插入insert和删除remove,不管插入还是删除保证堆的成立条件,1.是完全二叉树,2.父节点的值不能小于子节点的值。
public void insert(int value) {
// 第一步将插入的值,直接放在最后一个位置。并将长度加一
store[size++] = value;
// 得到新插入值所在位置。
int index = size - 1;
while(index > 0) {
// 它的父节点位置坐标
int parentIndex = (index - 1) / 2;
// 如果父节点的值小于子节点的值,你不满足堆的条件,那么就交换值
if (store[index] > store[parentIndex]) {
swap(store, index, parentIndex);
index = parentIndex;
} else {
// 否则表示这条路径上的值已经满足降序,跳出循环
break;
}
}
}
主要步骤:
- 直接将value插入到size位置,并将size自增,这样store数组中插入一个值了。
- 要保证从这个叶节点到根节点这条路径上的节点,满足父节点的值不能小于子节点。
- 通过int parentIndex = (index - 1) / 2得到父节点,如果比父节点值大,那么两者位置的值交换,然后再拿这个父节点和它的父父节点比较。
- 直到这个节点值比父节点值小,或者这个节点已经是根节点就退出循环。
因为我们每次只插入一个值,所以只需要保证新插入位置的叶节点到根节点路径满足堆的条件,因为其他路径没做操作,肯定是满足条件的。第二因为是直接在size位置插入值,所以肯定满足是完全二叉树这个条件。因为每次循环index都是除以2这种倍数递减的方式,所以它最多循环次数是(log N)次。
public int remove() {
// 将根的值记录,最后返回
int result = store[0];
// 将最后位置的值放到根节点位置
store[0] = store[--size];
int index = 0;
// 通过循环,保证父节点的值不能小于子节点。
while(true) {
int leftIndex = 2 * index + 1; // 左子节点
int rightIndex = 2 * index + 2; // 右子节点
// leftIndex >= size 表示这个子节点还没有值。
if (leftIndex >= size) break;
int maxIndex = leftIndex;
if (store[leftIndex] < store[rightIndex]) maxIndex = rightIndex;
if (store[index] < store[maxIndex]) {
swap(store, index, maxIndex);
index = maxIndex;
} else {
break;
}
}
return result;
}
在堆中最大值就在根节点,所以操作步骤:
- 将根节点的值保存到result中。
- 将最后节点的值移动到根节点,再将长度减一,这样满足堆成立第一个条件,堆是一个完全二叉树。
- 使用循环,来满足堆成立的第二个条件,父节点的值不能小于子节点的值。
- 最后返回result。
那么怎么样满足堆的第二个条件呢?
因为根点的值现在是新值,那么就有可能比它的子节点小,所以就有可能要进行交换。
- 我们要找出左子节点和右子节点那个值更大,因为这个值可能要和父节点值进行交换,如果它不是较大值的话,它和父节点进行交换之后,就会出现父节点的值小于子节点。
- 将找到的较大子节点值和父节点值进行比较。
- 如果父节点的值小于它,那么将父节点和较大子节点值进行交换,然后再比较较大子节点和它的子节点。
- 如果父节点的值不小于子节点较大值,或者没有子节点(即这个节点已经是叶节点了),就跳出循环。
- 每次循环我们都是以2的倍数递增,所以它也是最多循环次数是(log N)次。
所以通过堆这种方式可以快速实现优先级队列,它的插入和删除操作的效率都是O(log N)。
以上是对堆结构的简单介绍,为什么要介绍堆呢,因为我们接下来要说的DelayedWorkQueue就是基于最小堆(即父节点小于等于子节点,根节点元素,是所以元素中最小的一个)来构造的,先看下DelayedWorkQueue的基本结构:
// 初始容量,当堆大小一溢出时,扩容按照1.5倍进行扩容
private static final int INITIAL_CAPACITY = 16;
// 堆存储数组,我们可以看到这里是通过数据实现对堆的存储,如果父节点下标为n,则左子节点下标为2*n+1, 又子节点是2*n+2
// 例如下标0的子节点分别为1和2
private RunnableScheduledFuture<?>[] queue = new RunnableScheduledFuture<?>[INITIAL_CAPACITY];
// 全局锁,用来对操作加锁
private final ReentrantLock lock = new ReentrantLock();
// 初始化堆大小,有别于堆容量,size小于等于queue.length
private int size = 0;
// 获取堆头结点的线程
private Thread leader = null;
// 维护一个等待条件,当新的节点变成头节点时,或一个新线程成为leader时,进行singal
private final Condition available = lock.newCondition();
DelayedWorkQueue是用数组来储存队列中的元素,那么我们看看它是怎么实现优先级队列的。
插入元素排序siftUp方法
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;
setIndex(key, k);
}
通过循环,来查找元素key应该插入在堆二叉树那个节点位置,并交互父节点的位置。具体流程在前面已经介绍过了。
移除元素排序siftDown方法
private void siftDown(int k, RunnableScheduledFuture<?> key) {
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);
}
通过循环,保证父节点的值不能小于子节点。
插入元素方法
public void put(Runnable e) {
offer(e);
}
public boolean add(Runnable e) {
return offer(e);
}
public boolean offer(Runnable e, long timeout, TimeUnit unit) {
return offer(e);
}
我们发现与普通阻塞队列相比,这三个添加方法都是调用offer方法。那是因为它没有队列已满的条件,也就是说可以不断地向DelayedWorkQueue添加元素,当元素个数超过数组长度时,会进行数组扩容。
public boolean offer(Runnable x) {
if (x == null)
throw new NullPointerException();
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();
}
return true;
}
主要是三步:
- 元素个数超过数组长度,就会调用grow()方法,进行数组扩容。
- 将新元素e添加到优先级队列中对应的位置,通过siftUp方法,保证按照元素的优先级排序。
- 如果新插入的元素是队列头,即更换了队列头,那么就要唤醒正在等待获取任务的线程。这些线程可能是因为原队列头元素的延时时间没到,而等待的
数组扩容方法
private void grow() {
int oldCapacity = queue.length;
// 每次扩容增加原来数组的一半数量。
int newCapacity = oldCapacity + (oldCapacity >> 1); // grow 50%
if (newCapacity < 0) // overflow
newCapacity = Integer.MAX_VALUE;
// 使用Arrays.copyOf来复制一个新数组
queue = Arrays.copyOf(queue, newCapacity);
}
立即获取队列头元素
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();
}
}
当队列头任务是null,或者任务延时时间没有到,表示这个任务还不能返回,因此直接返回null。否则调用finishPoll方法,移除队列头元素并返回。
// 移除队列头元素
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;
}
这个方法与我们在第一节中,介绍堆的删除方法一样。
- 先将队列中元素个数减一。
- 将原队列末尾元素设置成队列头元素,再将队列末尾元素设置为null。
- 调用siftDown(0, x)方法,保证按照元素的优先级排序。
等待获取队列头元素
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 = thisThread;
try {
// 当任务的延时时间到了时,能够自动超时唤醒。
available.awaitNanos(delay);
} finally {
if (leader == thisThread)
leader = null;
}
}
}
}
} finally {
if (leader == null && queue[0] != null)
// 唤醒等待任务的线程
available.signal();
lock.unlock();
}
}
如果队列中没有任务,那么就让当前线程在available条件下等待。如果队列头任务的剩余延时时间delay大于0,那么就让当前线程在available条件下等待delay时间。
如果队列插入了新的队列头,它的剩余延时时间肯定小于原来队列头的时间,这个时候就要唤醒等待线程,看看它是否能获取任务。
超时等待获取队列头元素
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();
}
}
与take方法相比较,就要考虑设置的超时时间,如果超时时间到了,还没有获取到有用任务,那么就返回null。其他的与take方法中逻辑一样。
问题总结
- DelayedWorkQueue的数据结构是基于堆实现的
- DelayedWorkQueue采用数组实现堆,根节点出队,用最后叶子节点替换,然后下推至满足堆成立条件
- 最后叶子节点入队,然后向上推至满足堆成立条件
- DelayedWorkQueue取根节点延迟时间,然后等待,直到延迟结束,从新取根节点达到延迟取元素