JDK实现的线程池之四-2:jdk实现的ScheduledThreadPoolExecutor之DelayedWorkQueue队列(最小堆数据结构)

DelayedWorkQueue优先队列

该队列是定制的优先级队列,只能用来存储RunnableScheduledFutures任务。堆是实现优先级队列的最佳选择,而该队列正好是基于堆数据结构的实现。

1.关于堆的一些知识

堆结构是用数组实现的二叉树,数组下标可以表明元素节点的位置,所以省去指针的内存消耗;堆内元素节点的位置取决于节点的某一个属性的大小值,根据父节点是否大于左右节点分为最小堆和最大堆。即二叉树根节点最小则为最小堆,二叉树根节点最大则为最大堆;下面是最小堆和最大堆的示例:

最小堆中,父节点都小于左右节点;其数组形式:[1, 5, 8, 6, 10, 11, 20]

最大堆总,父节点都大于左右节点;其数组形式:[20, 10, 15, 6, 9, 10, 12]

2.堆的一些属性

堆都是满二叉树.因为满二叉树会充分利用数组的内存空间;
最小堆是指父节点比左节点和右节点都小的结构,所以整个最小堆中,根节点是最小的节点;
最大堆是指父节点比左节点和右节点都大的结构,所以整个最大堆中,根节点是最大的节点;
最大堆和最小堆的左节点和右节点没有关系,只能判断父节点和左右两节点的大小关系;
基于堆的这些属性,堆适用于找到集合中的最大或者最小值;另外,堆结构记录任务及其索引的关系,便于插入数据或者删除数据后重新排序,所以堆适用于优先队列。

3.堆和数组

堆结构是二叉树,节点实际存储在数组中。如果i是节点的索引,那么就可以用下面的公式计算父节点和子节点在数组中的位置:

// 父节点
parent(i) =floor( (i – 1) / 2);
// 左节点
left(i) = 2 * i + 1;
// 右节点
right(i) = 2 * i + 2;
1

左节点和右节点在数组中永远是相邻的.例如上面的最小堆,数组为:[1, 5, 8, 6, 10, 11, 20],数组中的元素在满二叉树中就是从上到下从左到右分布着。

4.堆的插入和移除

堆元素的插入siftUp操作和移除siftDown操作。
比如在最小堆[1, 5, 8, 6, 10, 11, 20]中再插入一个元素4,下面用图示分析插入的过程:
插入之前的二叉树结构:

插入元素4首先放置在数组末尾,也就是二叉树的末尾:

插入元素4(索引为7),首先与其父类(元素为6,索引为3)比较大小,如果是最小堆则交换其与父类的位置,如果是最大堆则不用交换直接结束。我们这里是最小堆,4比6小,所以需要交换其与父类的位置:

交换后如上图所示,找到此时元素4(索引为3)的父类(元素为5,索引为1),比较两者的元素大小,4小于5,所以交换两者的位置:

交换后如上图所示,找到此时元素4(索引为1)的父类(元素为1,索引为0),比较两者的元素大小,4大于1,所以不需要交换。此时确定插入元素4的索引为1的位置。插入的二叉树如图所示:

插入后最小堆的数组形式:[1,4,8,5,10,11,20,6]

整个插入的过程就是一个不断循环比较的过程,通过比较插入元素与父节点元素的大小,最小堆则把较小的元素放置在父节点处,最大堆则把较大的元素放置在父节点处,直到确认插入元素的节点位置。可以看出,每插入一个元素都会对堆进行重排序,且每次排序都是二分排序,所以时间复杂度为logn。

在最大堆[20, 10, 15, 6, 9, 10, 12]中移除元素后,下面用图示分析重排的过程:
最大堆原始二叉树结构:

移除堆的根节点(元素值为20,索引为0)后,取二叉树的最后一个节点,即数组中的最后一个元素12(二叉树中索引为6)放入根节点位置:

需要对新二叉树进行重排序;首先获取根节点的左、右节点,然后比较其大小(最大堆找到较大的节点,最小堆找到较小的节点),我们这里是最大堆,所以比较左节点10和右节点15的大小,找到较大的右节点,并比较右节点(元素为15,索引为2)与根节点(元素为12,索引为0)的大小,把较大的元素放入根节点(最小堆是把较小的元素放入根节点),交换后的二叉树图为:

重复上面的步骤,比较元素12(索引为2)与其左节点(元素为10,索引为5)的大小,把较大的元素放置在父节点上。最后的二叉树图为:

移除根节点后最大堆的数组形式:[15,10,12,6,9,10]

从堆中获取节点都是默认获取根节点,因为根节点是整个队列中最大或者最小的元素。获取后,需要对队列重排寻找新的根节点元素。重排的顺序跟新增后重排的顺序相反,它是从根节点往下重排,最大堆则把较大元素放置在父节点上,最小堆则把较小元素放置在父节点上;它的时间复杂度同样为logn。

5.DelayedWorkQueue

DelayedWorkQueue是基于堆结构的等待队列。

5.1 类的重要属性

// 数组的初始容量为16 设置为16的原因跟hashmap中数组容量为16的原因一样

// 数组的初始容量为16 设置为16的原因跟hashmap中数组容量为16的原因一样
private static final int INITIAL_CAPACITY = 16;
// 用于记录RunableScheduledFuture任务的数组
private RunnableScheduledFuture<?>[] queue =
            new RunnableScheduledFuture<?>[INITIAL_CAPACITY];
private final ReentrantLock lock = new ReentrantLock();
// 当前队列中任务数,即队列深度
private int size = 0;
// leader线程用于等待队列头部任务,
private Thread leader = null;
// 当线程成为leader时,通知其他线程等待
private final Condition available = lock.newCondition();

延迟队列是基于数组实现的,初始容量为16;获取延迟队列中头部节点的线程称为leader,说明leader线程是不断变化的,但leader线程在等待,则其他线程也会等待,直到leader线程获取根节点,且从等待线程中产生新的leader线程。

5.2 新增元素到DelayedWorkQueue

1) offer(Runnable)-新增元素

给外界提供一个插入元素的方法.

public boolean offer(Runnable x) {
    if (x == null)
        throw new NullPointerException();
    // 只能存放RunnableScheduledFuture任务
    RunnableScheduledFuture<?> e = (RunnableScheduledFuture<?>)x;
    // 为了保证队列的线程安全,offer()方法为线程安全方法
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
    // 当前队列实际深度,即队列中任务个数
        int i = size;
        // 如果任务数已经超过数组长度,则扩容为原来的1.5倍
        if (i >= queue.length)
            grow();
        // 队列实际深度+1
        size = i + 1;
        // 如果是空队列 新增任务插入到数组头部;
        if (i == 0) {
            queue[0] = e;
            // 设置该任务在堆中的索引,便于后续取消或者删除任务;免于查找
            setIndex(e, 0);
        } else {
            // 如果不是空队列 则调用siftUp()插入任务
            siftUp(i, e);
        }
        // 如果作为首个任务插入到数组头部
        if (queue[0] == e) {
            // 置空当前leader线程
            leader = null;
            // 唤醒一个等待的线程 使其成为leader线程
            available.signal();
        }
    } finally {
        lock.unlock();
    }
    return true;
}

这个方法理解的难点在于leader线程。若新增任务插入空队列中,首先清空leader线程,并唤醒等待线程中的某一个线程,把唤醒的线程作为leader线程;若新增任务插入前,队列中已经存在任务,则说明已经有leader线程在等待获取根节点,此时无需设置leader线程。leader线程的作用就是用来监听队列的根节点任务,如果leader线程没有获取到根节点任务则通知其他线程等待,这表明leader线程决定着等待线程的状态。
用leader-before这种机制,可以减少线程的等待时间,而每一个等待的线程都有可能成为leader线程。注意:这里还不太清除哪些线程会等待。

2) grow()-数组动态扩容

任务数超过数组长度,则执行扩容.

private void grow() {
    // 当前数组的深度
    int oldCapacity = queue.length;
    // 在原来的基础上,扩大1.5倍,注意每次扩的大小不一样
    int newCapacity = oldCapacity + (oldCapacity >> 1); // grow 50%
    // 如果溢出则取Integer的最大值2^31-1
    if (newCapacity < 0) // overflow
        newCapacity = Integer.MAX_VALUE;
    // 新建长度为newCapacity的数组,并把旧queue数组copy到新数组中
    queue = Arrays.copyOf(queue, newCapacity);
}

延迟队列中的数组支持动态扩容,可以理解为延迟队列的容量接近无穷大,即该队列适用放置那种短期的任务。

3) siftUp(int,RunnableScheduledFuture)-新增任务后重排

新增任务插入队列(数组),首先插入到数组的尾部,然后对比其与该位置的父节点的大小,如果新增任务大于父节点任务(此处是最小堆),则新增任务位置不变,否则改变其与父节点的位置,并再比较父节点与父父节点的大小,直到根节点。插入的过程可以结合上面堆的二叉树变化过程图一起理解。
插入流程图:

 

 

private void siftUp(int k, RunnableScheduledFuture<?> key) {
    // 循环,当k为根节点时结束循环
    while (k > 0) {
        // 获取k的父节点索引,相当于(k-1)/2
        int parent = (k - 1) >>> 1;
        // 获取父节点位置的任务
        RunnableScheduledFuture<?> e = queue[parent];
        // 判断key任务与父节点任务time属性的大小,即延迟时间
        if (key.compareTo(e) >= 0)
            break; // 父节点任务延迟时间小于key任务延迟时间,则退出循环
        // 否则交换父节点parent与节点k的任务
        queue[k] = e;
        // 更新任务e在堆中的索引值
        setIndex(e, k);
        // 更新k的值 比较其与父父节点的大小
        k = parent;
    }
    // 任务key放入数组k的位置,k的值是不断更新的
    queue[k] = key;
    // 设置任务key在堆中的索引值
    setIndex(key, k);
}

 

这里compareTo()方法在ScheduledFutureTask类中有定义,本质上是比较任务的time属性大小,即延迟时间的长短。我们后面分析ScheduledFutureTask类的时候会学习到。

4) put(Runnable)/add(Runnable)/offer(Runnable,long,TimeUnit)

这三个方法的作用都是往队列中添加元素.

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(Runnable);即使第三个方法offer()传递超时时间,该时间也被忽略掉,表明过来的任务不会因为超时而丢弃,反而都会放入延迟队列中。另外,上面分析过offer(Runnable)方法,过来的任务不会因为队列满而拒绝任务,反而队列的大小接近Integer.MAX_VALUE,能保证任务放入成功。由于队列的这个特性,使得线程池ScheduledThreadPoolExecutor的最大线程数也设置为Integer.MAX_VALUE。

5.3 移除元素

1) poll()-获取队列根节点

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
            // 否则执行finishPoll()方法
            return finishPoll(first);
    } finally {
        lock.unlock();
    }
}

获取根节点元素后,需要对队列重新排序.具体看finishPoll(RunnableScheduledFuture)方法的分析。该方法获取队列根节点,可能返回任务,也可能返回null。

2) finishPoll(RunnableScheduledFuture)-获取根节点后重排序

private RunnableScheduledFuture<?> finishPoll(RunnableScheduledFuture<?> f) {
    // 因为取出根节点 所以队列深度减1 并赋值给s
    int s = --size;
    // 获取队列最后一个任务
    RunnableScheduledFuture<?> x = queue[s];
    queue[s] = null; // 该位置元素置空
    // 如果s已经根节点则直接返回,否则堆重排序
    if (s != 0)
        siftDown(0, x);
    // 取出来的任务 设置其堆索引为-1
    setIndex(f, -1);
    return f; // 返回任务
}

3) siftDown(int,RunnableScheduledFuture)-移除元素后重排序

private void siftDown(int k, RunnableScheduledFuture<?> key) {
    // 取队列当前深度的一半 相当于size / 2
    int half = size >>> 1;
    // 索引k(初值为0)的值大于half时 退出循环
    while (k < half) {
    // 获取左节点的索引
        int child = (k << 1) + 1;
        // 获取左节点的任务
        RunnableScheduledFuture<?> c = queue[child];
        // 获取右节点的索引
        int right = child + 1;
        // 如果右节点在范围内 且 左节点大于右节点,
        if (right < size && c.compareTo(queue[right]) > 0)
            // 更新child的值为右节点索引值 且更新c为右节点的任务
            c = queue[child = right];
        // 如果任务key小于任务c 则退出循环(最小堆)
        if (key.compareTo(c) <= 0)
            break;
        // 否则把任务c放到k上(较小的任务放到父节点上)
        queue[k] = c;
        // 设置任务c的堆索引
        setIndex(c, k);
        // 更新k的值为child
        k = child;
    }
    // 任务key插入k的位置
    queue[k] = key;
    // 设置任务key的堆索引k
    setIndex(key, k);
}

 

执行的流程图为:

 

 


4) take()-等待获取根节点

public RunnableScheduledFuture<?> take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        // 自循环,实现对队列的监控 保证返回根节点
        for (;;) {
            // 获取根节点任务
            RunnableScheduledFuture<?> first = queue[0];
            // 如果队列为空,则通知其他线程等待
            if (first == null)
                available.await();
            else {
                // 获取根节点任务等待时间与系统时间的差值
                long delay = first.getDelay(NANOSECONDS);
                // 如果等待时间已经到,则返回根节点任务并重排序队列
                if (delay <= 0)
                    return finishPoll(first);
                // 如果等待时间还没有到,则继续等待且不拥有任务的引用
                first = null; // don't retain ref while waiting
                // 如果此时等待根节点的leader线程不为空则通知其他线程继续等待
                if (leader != null)
                    available.await();
                else {
                    // 如果此时leader线程为空,则把当前线程置为leader
                    Thread thisThread = Thread.currentThread();
                    leader = thisThread;
                    try {
                        // 当前线程等待延迟的时间
                        available.awaitNanos(delay); 
                    } finally {
                        // 延迟时间已到 则把当前线程变成非leader线程
                        // 当前线程继续用于执行for循环的逻辑
                        if (leader == thisThread)
                            leader = null;
                    }
                }
            }
        }
} finally {
    // 如果leader为null 则唤醒一个线程成为leader
        if (leader == null && queue[0] != null)
            available.signal();
        lock.unlock();
    }
}

此方法势必会返回根节点的任务,常用于从队列中循环获取任务。

5) poll(long,TimeUnit)-超时等待获取根节点

允许等待超时,其他过程同poll()方法的注释.

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) {
                if (nanos <= 0)
                    return null;
                else
                    nanos = available.awaitNanos(nanos);
            } else {
                long delay = first.getDelay(NANOSECONDS);
                if (delay <= 0)
                    return finishPoll(first);
                if (nanos <= 0)
                    return null;
                first = null; // don't retain ref while waiting
                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();
    }
}

 


6) peek()-直接返回根节点

public RunnableScheduledFuture<?> peek() {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        return queue[0];
    } finally {
        lock.unlock();
    }
}

peek()方法会立即返回根节点任务,而忽略延迟时间。

7) remove(Object)-删除任务

public boolean remove(Object x) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        // 获取任务x的heapIndex
        int i = indexOf(x);
        // 如果为-1 则表明不在队列内
        if (i < 0)
            return false;

        // 设置删除任务的heapIndex为-1
        setIndex(queue[i], -1);
        // 队列深度-1
        int s = --size;
        // 获取队列末尾的节点任务
        RunnableScheduledFuture<?> replacement = queue[s];
        queue[s] = null;
        // 如果删除的任务节点不是末尾的节点,则重排序
        if (s != i) {
            siftDown(i, replacement);
            if (queue[i] == replacement)
                siftUp(i, replacement);
        }
        return true;
    } finally {
        lock.unlock();
    }
}

 

6.总结
本篇围绕ScheduledThreadPoolExecutor类的延迟队列DelayedWorkQueue展开分析,着重分析堆数据结构以及DelayedWorkQueue队列的方法。

转载:https://blog.csdn.net/nobody_1/article/details/99684009

posted on 2015-05-09 00:29  duanxz  阅读(1769)  评论(0编辑  收藏  举报