PriorityBlockingQueue 知根知底

424 阅读13分钟

开篇语

在医疗领域中,医生需要根据患者病情的严重程度来安排就诊顺序,此时队列将不是按照先来先服务,而是根据不同的优先级进行服务。

在搜索引擎中,搜索结果需要根据相关度进行排序,相关度高的结果需要排在前面展示给用户。优先级队列可以帮助搜索引擎快速地对搜索结果进行排序并展示给用户。

介绍

PriorityBlockingQueue 是一个无界阻塞队列,无界阻塞队列意味着无需关心容量,内部将会进行自动扩容。但是这也存在内存溢出的风险。使用堆作为存储结构。

它具有线程安全、性能好、公平锁选项的特点:

  1. 线程安全:使用锁和条件变量实现线程安全,无需额外的同步措施。
  2. 阻塞操作:当队列空时,删除操作阻塞。这有助于避免忙等待和减少无意义的资源消耗。
  3. 高性能:基于数组和堆实现,内存连续分配,访问性能较高,插入与删除操作更快收敛为正确的优先级排序。

堆是一种常见的数据结构,它是一种树形结构。在堆中,每个节点都有一个值。通常,堆用于实现优先队列和堆排序,也可以用来解决 TopK 问题。

堆分为两种:最大堆和最小堆。在最大堆中,根节点的值最大,每个父节点的值都大于或等于子节点的值。在最小堆中,根节点的值最小,每个父节点的值都小于或等于子节点的值。

image.png

image.png

堆还有一些特殊的性质。在堆中,如果节点的索引为 i,则其左子节点的索引为 2i+1,其右子节点的索引为2i+2。此外,堆还是一种完全二叉树,其中除了最后一层,其他层都是满的,并且最后一层从左到右填充。

堆是一种用数组实现的特殊二叉树,也叫完全二叉树。

堆化

如果拿到了一个无序的数组,我们可以通过堆化的方法,将无序的树转变为一颗合法的堆。

堆化的方式很简单:我们只需要找到前半段的元素,然后依次将这些元素与子节点进行比较,如果我们是需要一个最大堆,那么就将根节点与子节点相比较,将较大的元素作为根节点。

我们以下面的最大堆为例:

image.png

我们需要做的就是将所有元素都进行比较,然后将最大值作为根节点。因为我们需要将所有元素都进行比较,那么我们只需要从 20、47、78、100,这四个元素开始,也就是对应着数组中的前四个元素分别与它们的子元素进行比较,就可以完成堆化。

首先我们从 100 开始,将 100、1、40 进行比较,将最大值作为根节点,因为 100 在三个元素中最大,所以 100 作为根节点

image.png

接着我们对 78 也进行同样的操作,将 78、10、90 进行比较,因为 90 在三个元素中最大,因为 90 作为根节点。

在元素交换之后,需要继续对子节点再次比较,因为子节点有可能会出现比 90 大,但是比 78 小的数字,78 有可能需要继续往下交换。但是这里因为没有子节点,因此不需要继续比较。

image.png

接着对 47 进行同样的操作,将 47、100、2 进行比较。比较后,100 是最大值将作为根节点,将 47 与 100 进行交换。交换后,因为 47 下面存在子节点,因此 47 还需要与下面的子节点继续比较,比较后 47 是最大值,保持不变。

image.png

接着对 20 进行同样的操作,将 20、100、90 进行比较。100 是最大值将作为根节点。

image.png

交换之后,20 下面还有子节点,需要继续进行比较。将 20、47、2 进行比赛。47 作为最大值将作为根节点。

image.png

交换之后,20 下面还有子节点,需要继续进行比较。将 20、1、40 进行比赛。40 作为最大值将作为根节点。

image.png

至此,20 下面没有子节点可以继续比较。完成整个堆化操作。

堆化操作可以总结为以下几步:

  1. 以坐标为 arr.length / 2 -1 作为起点
  2. 从计算出的坐标,倒序遍历每一个节点。
  3. 每一个节点都与子节点进行比较,将符合条件的两个节点进行交换。交换后如果还有子节点,需要继续进行比较,直到没有子节点为止。

上浮

当我们往堆里面插入元素时,会破坏堆的结构,此时我们就需要通过上浮操作,将刚刚插入的元素替换到合适的位置上去。

我们继续以上面堆化后的数组插入一个元素 50。在堆中插入数据,我们直接插入在数组最后一位添加数据即可。

image.png

很明显,我们构造的最大堆中,50 所在的位置是不对的。因此我们需要通过上浮操作将 50 放到正确的位置上。上浮操作就是跟父节点进行比较,如果符合条件就跟父节点进行交换,直到不再满足条件为止。

我们首先 50、2 比较。50 比 2 大,因此将 2 与 50 进行交换

image.png

交换之后,我们继续与父节点比较,50 比 47 大,因此继续交换

image.png

交换之后,继续与父节点进行比较,50 比 100 小,上浮操作结束。50 找到了它的位置,因此堆依然符合规则。

下沉

当我们往堆里面删除元素时,会破坏堆的结构,此时我们就需要通过下沉操作,找到一个合适的元素顶替被删除元素的位置。

我们继续以上面堆化后的数组删除元素 100。

image.png

我们首先将数组最后一个元素暂时替换到被删除元素的位置上。这里就是将元素 2 替换到 100 的位置上。

image.png

然后对 2 做下沉操作,下沉操作就是与所有子节点进行比较,直到没有子节点或者子节点都不符合交换的条件。

首先 2、50、90 比较。最大值为 90,因此 90 与 2进行交换

image.png

交换完之后,2 继续与子节点进行比较。[2, 10, 78],78 最大,因此将 78 与 2 进行交换。

image.png

交换完之后,元素 2 下面已经没有子节点可以继续比较了。结束下沉操作,2 找到了它的位置,因此堆依然符合规则。

实现原理

优先级队列通过堆,对每一个插入的元素都进行比较,将优先级高的元素放在堆顶,优先级低的元素放在堆底。

构造函数

public PriorityBlockingQueue(int initialCapacity,
                             Comparator<? super E> comparator) {
    if (initialCapacity < 1)
        throw new IllegalArgumentException();
    this.comparator = comparator;
    this.queue = new Object[Math.max(1, initialCapacity)];
}

构造函数中,需要指定队列的初始容量和比较器。一个合理的初始容量,可以在运行过程中减少扩容的次数,提高性能。比较器则是为了比较元素的优先级。

内部属性

DEFAULT_INITIAL_CAPACITY,该值为 11,表示在构造函数中,如果没有传 initialCapacity,那么则会使用该值作为数组的初始容量。

MAX_ARRAY_SIZE,值为 Integer.MAX_VALUE **- 8,表示数组的最大容量。这里减去 8,是因为部分虚拟机会在数组对象中写入对象头数据,为了避免内存溢出,空出 8 个元素的容量来存储对象头信息。对象头的大小为 32 byte,实际上只需要空出 32 byte 的空间即可避免内存溢出。

queue,存储数据的数组

size,记录队列中有多少个元素

comparator,决定元素优先级的比较器,如果为 null,则会使用自然排序

lock,独占锁

notEmpty,条件队列。队列为空为时,获取元素则会调用该条件队列的 await() 方法阻塞获取操作。插入元素后,则会调用条件队列的 signal() 方法,通知被阻塞的线程继续获取元素。

allocationSpinLock:扩容时使用的自旋锁

插入函数

public boolean offer(E e) {
    if (e == null)
        throw new NullPointerException();
    final ReentrantLock lock = this.lock;
    lock.lock();
    int n, cap;
    Object[] es;
    while ((n = size) >= (cap = (es = queue).length))
        tryGrow(es, cap);
    try {
        final Comparator<? super E> cmp;
        if ((cmp = comparator) == null)
            siftUpComparable(n, e, es);
        else
            siftUpUsingComparator(n, e, es, cmp);
        size = n + 1;
        notEmpty.signal();
    } finally {
        lock.unlock();
    }
    return true;
}
  1. 获取锁
  2. 判断当前队列是否已经满了 ,如果满了,则通过 tryGrow 尝试扩容。
  3. 将元素插入到堆中,并通过上浮的方式调整堆结构
  4. 通知条件队列
  5. 解锁

扩容

在尝试扩容的代码中,我们会发现是使用 while 循环去判断是否应该尝试扩容,那么则可以推测 tryGrow 是可能会失败的,当失败之后,则需要再次判断容量是否已经扩容成功(可能正在被其他线程进行扩容),成功之后才会继续进行插入的操作。

private void tryGrow(Object[] array, int oldCap) {
    lock.unlock(); // must release and then re-acquire main lock
    Object[] newArray = null;
    if (allocationSpinLock == 0 &&
        ALLOCATIONSPINLOCK.compareAndSet(this, 0, 1)) {
        try {
            int newCap = oldCap + ((oldCap < 64) ?
                                   (oldCap + 2) : // grow faster if small
                                   (oldCap >> 1));
            if (newCap - MAX_ARRAY_SIZE > 0) {    // possible overflow
                int minCap = oldCap + 1;
                if (minCap < 0 || minCap > MAX_ARRAY_SIZE)
                    throw new OutOfMemoryError();
                newCap = MAX_ARRAY_SIZE;
            }
            if (newCap > oldCap && queue == array)
                newArray = new Object[newCap];
        } finally {
            allocationSpinLock = 0;
        }
    }
    if (newArray == null) // back off if another thread is allocating
        Thread.yield();
    lock.lock();
    if (newArray != null && queue == array) {
        queue = newArray;
        System.arraycopy(array, 0, newArray, 0, oldCap);
    }
}

我们可以看到第一行就是解锁,然后通过 CAS 设置 allocationSpinLock 属性,设置成功则开始进行扩容。否则,让出线程执行权,被其他正在扩容的线程获得执行权,尽快完成扩容操作。完成扩容操作之后,将旧数组中的元素全部复制到新数组中。

为什么这里需要解锁呢?然后再通过 CAS 设置一个轻量级锁?不是本身已经有锁了吗,为什么又要释放锁,再重新获取锁,这个看似多了一个无谓的步骤?

本质上是为了更快的对队列完成扩容。

如果线程不释放锁,而是通过之前获取的锁进行扩容,那么就会出现这种情况,在第一个线程到达扩容函数,并且扩容完成之前,其他线程都会被阻塞在获取锁上。在扩容的时候,线程的时间片可能会使用完,从而被挂起。

如果释放锁,通过 CAS 获取另外一个轻量级锁,那么可以在一定程度上减少这种资源的浪费。当释放锁后,其他线程可以通过 lock 方法继续获取锁,并且也进入到扩容的操作中来,这时候就看哪个线程可以更快的获取到扩容锁,没有获取到扩容锁的线程会执行 Thread.yield(); 让出执行权,让扩容线程可以更快的获取执行权从而完成扩容操作。

为什么需要判断 queue == array ?

我们发现有两个地方会使用这个判断,一个是使用新容量创建新数组时,一个是在将旧数组的数组复制到新数组时。我们来看这样一种情况:

  1. 线程 A 获取到扩容锁,并且完成了扩容。在执行 allocationSpinLock = 0;语句之前,线程 B 刚好进入到Object[] newArray = null;语句。
  2. 线程 A 执行allocationSpinLock = 0;,将 allocationSpinLock 属性设置为 0。并且因为 queue == array,此时就会将新数组赋值给 queue。此时 queue != array。
  3. 线程 B 继续执行,发现 allocationSpinLock == 0;,也进入到扩容,并使用新容量创建一个新的数组。此时这个数组的容量与线程 A 创建的数组容量是同样的。
  4. 线程 B 执行 queue == array 语句。但是此时 queue 已经被线程修改了,已经不等于 array 了,因此不会将创建的新数组赋值给 queue。

在执行替换操作时,通过 lock.lock();,保证只有一个线程是正在执行替换操作的,确保了线程的安全性。

本质上为了避免前一次的替换操作被后一次的替换操作覆盖。

入队

private static <T> void siftUpComparable(int k, T x, Object[] es) {
    Comparable<? super T> key = (Comparable<? super T>) x;
    while (k > 0) {
        int parent = (k - 1) >>> 1;
        Object e = es[parent];
        if (key.compareTo((T) e) >= 0)
            break;
        es[k] = e;
        k = parent;
    }
    es[k] = key;
}

在介绍堆的时候已经了解过,当往堆中插入元素时,我们先将元素插入到最后的位置上,然后通过与父元素进行比较,判断是否需要与父元素进行交换。

获取函数

public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    E result;
    try {
        while ( (result = dequeue()) == null)
            notEmpty.await();
    } finally {
        lock.unlock();
    }
    return result;
}
  1. 获取锁

  2. 判断当前队列是否为空

    1. 如果队列没有元素,调用 notEmpty 条件队列的 await() 方法,将该线程阻塞,暂停该线程的获取操作。避免获取元素出错。
    2. 如果不为空,则直接调用出队函数 dequeue 移除队列第一个元素,并返回给客户端。
  3. 释放锁

image.png

出队

private E dequeue() {
    // assert lock.isHeldByCurrentThread();
    final Object[] es;
    final E result;

    if ((result = (E) ((es = queue)[0])) != null) {
        final int n;
        final E x = (E) es[(n = --size)];
        es[n] = null;
        if (n > 0) {
            final Comparator<? super E> cmp;
            if ((cmp = comparator) == null)
                siftDownComparable(0, x, es, n);
            else
                siftDownUsingComparator(0, x, es, n, cmp);
        }
    }
    return result;
}
private static <T> void siftDownComparable(int k, T x, Object[] es, int n) {
    // assert n > 0;
    Comparable<? super T> key = (Comparable<? super T>)x;
    int half = n >>> 1;           // loop while a non-leaf
    while (k < half) {
        int child = (k << 1) + 1; // assume left child is least
        Object c = es[child];
        int right = child + 1;
        if (right < n &&
            ((Comparable<? super T>) c).compareTo((T) es[right]) > 0)
            c = es[child = right];
        if (key.compareTo((T) c) <= 0)
            break;
        es[k] = c;
        k = child;
    }
    es[k] = key;
}

我们在堆中介绍时已经了解过,在删除堆顶元素时,我们只需要将数组中最后一个元素替换掉堆顶元素,然后进行下沉操作即可平衡堆的结构。

因为队列的特殊性,我们每一个获取元素时都一定是获取堆顶元素,因此这里只需要下沉操作即可以平衡堆结构。但是如果不考虑队列的特性,删除堆中任意元素,那么要平衡堆结构就不仅仅通过下沉操作就能实现,还需要结合上浮操作。我们看下面这个例子:

image.png

这是一个大顶堆,当我们删除元素 8 时,为了平衡堆的结构,我们会将元素 11 替换在元素 8 的位置上,然后进行下沉操作。

image.png

在 11 的子树中是符合大顶堆的结构的,但是对于它的父节点来说,9 小于 11,就不符合堆的结构。因此这里需要通过上浮操作,将 9 与 11 进行交换才符合堆的机构。

因此,在删除堆节点的时候,要同时使用下沉和上浮两个操作才能平衡堆的结构。

应用场景

适用场景

PriorityBlockingQueue 适用于任何需要按照优先级排序的场景,并且需要保证在多线程环境下的正确性。因此可以总结出以下几个应用场景:

  1. 任务调度:在多线程任务调度中,可以使用 PriorityBlockingQueue 来保存待执行的任务,并根据任务的优先级自动排序,保证高优先级的任务先被执行。
  2. 事件驱动:在事件驱动的系统中,可以使用 PriorityBlockingQueue 来保存待处理的事件,并根据事件的优先级自动排序,保证高优先级的事件先被处理。
  3. 并发编程:在并发编程中,PriorityBlockingQueue 可以作为一个线程安全的队列来使用,比如在生产者-消费者模型中,生产者将任务放入 PriorityBlockingQueue 中,消费者从队列中获取任务并执行。
  4. 数据缓存:PriorityBlockingQueue 可以作为一个数据缓存的实现,当缓存满时,可以根据元素的优先级来删除优先级较低的元素,从而保证缓存中始终保存优先级较高的数据。

总结

PriorityBlockingQueue 是一个基于优先级排序,并且采用阻塞方式的队列。内部采用堆进行排序,并且通过条件队列来保证线程获取元素的正确性。