java并发编程——PriorityBlockingQueue原理探究

PriorityBlockingQueue原理探究

一、介绍

        PriorityBlockingQueue是带优先级的无界阻塞队列,每次出队都返回优先级最高或者最低的元素,其内部是使用平衡二叉树堆实现的,所以直接遍历队列元素不保证有序。默认使用对象的compareTo方法提供比较规则,如果你需要自定义比较规则则可以自定义comparators。

二、PriorityBlockingQueue类图结构

        下面首先通过类图结构来从全局了解PriorityBlockingQueue的原理。

        如下可知,PriorityBlockingQueue内部有一个数组queue,用来存放队列元素,size用来存放队列元素个数。allocationSpinLock是一个自旋锁,其使用CAS操作来保证同时只有一个线程可以扩容队列,状态为0或者1,其中0表示当前没有进行扩容,1表示当前正在扩容。

        由于这是一个优先级队列,所以有一个比较器comparator用来比较元素大小。lock独占锁对象用来控制同时只有一个线程可以进行入队、出队操作。notEmpty条件变量用来实现take方法阻塞模式。这里没有notFull条件变量是因为这里put操作是非阻塞的,为啥要设计为非阻塞的,是因为这是无界队列。

        在如下构造函数中,默认队列容量为11,默认比较器为null,也就是使用元素的compareTo方法进行比较来确定元素的优先级,这意味着队列元素必须实现了Comparable接口。
在这里插入图片描述
在这里插入图片描述

三、原理介绍

1、offer操作

        offer操作的作用是在队列中插入一个元素,由于是无界队列,所以一直返回true。如下是offer操作的代码。
在这里插入图片描述
        如上代码比较简单,下面主要看看如果进行扩容和在内部建堆。首先看看扩容逻辑。
在这里插入图片描述
        tryGrow的作用是扩容。这里为啥在扩容前要先释放锁,然后使用CAS控制只有一个线程可以扩容成功?其实这里不先释放锁也是可行的,也就是在整个扩容期间一直持有锁,但是扩容是需要花时间的,如果扩容时还占用锁那么其他线程在这个时候是不能进行出队和入队操作的,这大大降低了并发性。所以为了提高性能,使用CAS控制只有一个线程可以进行扩容,并且在扩容前释放锁,让其他线程可以进行入队和出队操作。

        spinlock锁使用CAS控制只有一个线程可以进行扩容,CAS失败的线程会调用Thread.yield()方法让出CPU,目的是让扩容线程扩容后优先调用lock重新获取锁,但是这得不到保证。有可能yield的线程在扩容线程扩容完成前已经退出,并执行代码(6)获取了锁,这时候获取到锁的线程发现newArray为null就会执行代码(1).如果当前数组扩容还没完毕,当前线程会再次调用tryGrow方法,然后释放锁,这又给扩容线程获取锁提供了机会,如果这时候扩容线程还没扩容完毕,则当前线程释放锁后又调用yield方法让出CPU。所以当扩容线程进行扩容时,其他线程原地自旋通过代码(1)检查当前扩容是否完毕,扩容完毕后才退出代码(1)的循环。

        扩容线程扩容完毕后会重置自旋锁变量allocationSpinLock为0,这里并没有使用UNSAFE方法的CAS进行设置是因为同时只可能有一个线程获取到该锁,并且allocationSpinLock被修饰为volatile的。当扩容线程扩容完毕后会执行代码(6)获取锁,获取锁后复制当前queue里面的元素到新数组。

        然后看下面的具体建堆算法。
在这里插入图片描述
        下面用图来解释上面算法的过程,假设队列初始化容量为2,创建的优先级队列的泛型参数为Integer。

  • I、首先调用队列的offer(2)方法,希望向队列插入元素2,插入前队列状态如下。
    在这里插入图片描述
            首先执行代码(1),从图中的变量值可以判断结果为false,所以紧接着执行代码(2)。由于k=n=size=0,所以代码(7)的判断结果为false,因此会执行代码(8)直接把元素2入队。最后执行代码(9)将size的值加1,这时候队列状态如下:
    在这里插入图片描述
  • II、第二次调用队列的offer(4)时,首先执行代码(1),从图中的变量值可知判断结果为false,所以执行代码(2)。由于k=1,所以进入while循环,由于parent=0;e=2;key=4;默认元素的比较器使用元素的compareTo方法,可知key>e,所以执行break退出siftUpComparable中的循环,然后把元素存到数组下标为1的地方。最后执行代码(9)将size的值加1,这时候队列状态如下所示:
    在这里插入图片描述
  • III、第三次调用队列的offer(6)时,首先执行代码(1),从图中变量值知道,这时候判断结果为true,所以调用tryGrow进行数组扩容。由于2 < 64,所以执行newCap = 2 + (2 + 2) = 6,然后创建新数组并复制,之后调用siftUpComparable方法。由于k = 2 > 0,故进入while循环,由于parent=0;e=2;key=6;key>e,所以执行break后退出while循环,并把元素6放入数组下标为2的地方。最后将size的值加1,现在队列状态如下所示:
  • 在这里插入图片描述
  • IV、第四次调用队列的offer(1)时,首先执行代码(1)、从图中的变量值知道,这次判断结果为false,所以执行代码(2)。由于k = 3,所以进入while循环,由于parent = 1;e=4;key=1;key<e,所以把元素4复制都数组下标为3的地方。然后执行方法k = 1,再次循环,发现e=2,key=1,key<e,所以复制元素2到数组下标1处,然后k = 0退出循环,最后把元素存放到下标为0的地方,现在的状态如下所示:
    在这里插入图片描述
            由此可见,堆的根元素是1,也就是这是一个最小堆,那么当调用这个优先级队列的poll方法时,会依次返回堆里面值最小的元素

2、poll操作

        poll操作的作用时获取队列内部堆树的根节点元素,如果队列为空,则返回null。poll函数的代码如下:
在这里插入图片描述
        如上代码所示,在进行出队操作时要先加锁,这意味着,当前线程在进行出队操作时,其他线程不能在进行入队和出队操作,但是前面介绍offer函数时,这时候其他线程可以进行扩容。下面看具体执行出队操作的dequeue方法的代码:
在这里插入图片描述
        在如上代码中,如果队列为空则直接返回null,否则代码(1)获取数组的第一个元素作为返回值存放到变量result中,这里需要注意,数组里面的第一个元素是优先级最小或者最大的元素,出队操作就是返回这个元素。然后代码(2)获取队列尾部元素并存放到变量x中,且置空尾部节点,然后执行代码(3)将变量x插入到数组下标为0的位置,之后重新调整堆为最大或者最小堆,然后返回。这里重要的是,去掉堆的根节点后,如何使用剩下的节点重新调整一个最大或者最小堆,下面我们看一下siftDownComparable代码。
在这里插入图片描述
        同样下面我们结合图来介绍上面调整堆的算法过程,接着上节队列的状态继续讲解,在上一节中队列元素序列为1、2、6、4。

  • I、第一次调用队列的poll方法时,首先执行代码(1)和代码(2),这时候变量size=4;n=3;result=1;x=4;此时队列状态如下所示。
    在这里插入图片描述
            然后执行代码(3)调整堆后的队列状态为
    在这里插入图片描述
  • II、第二次调用队列的poll()方法时,首先执行代码(1)和代码(2),这时候变量size = 3;n = 2;result = 2;x = 6;此时队列状态为
    在这里插入图片描述
            然后执行代码(3)调整堆后队列状态为
    在这里插入图片描述
  • III、第三次调用队列的poll方法时,首先执行代码(1)和代码(2),这时候变量size = 2;n = 1;result = 4;x = 6;此时队列状态为
    在这里插入图片描述
            然后执行代码(3)调整堆后队列状态为
            只有一个6了
  • IV、第四次直接返回元素6。

        下面重点说一下siftDownComparable调整堆的算法。首先介绍下堆调整的思路。由于队列数组第0个元素为树根,因此出队时要移除它。这时数组就不再是最小的堆了,所以需要调整堆。具体是从被移除的树根左右子树中找一个最小的值来当树根,左右子树又会找自己左右子树里面那个最小值,这是一个递归过程,直到树叶节点结束递归。如果不太明白,没关系,我们结合图来说明,加入当前队列内容如下:
在这里插入图片描述
        其对应的二叉堆树为:
在这里插入图片描述
        这时候如果调用了poll方法,那么result = 2;x = 11,并且队列末尾的元素被设置为null,然后对于剩下的元素,调整堆的步骤如下图所示:
在这里插入图片描述
        图(1)中树根的leftChildVal = 4;rightChildVal = 6;由于4 < 6;所以c = 4。然后由于11 > 4,也就是key > c,所以使用元素4覆盖树根节点的值,现在堆对应的树如图(2)所示。

        然后树根的左子树树根的左右孩子节点中的leftChildVal = 8;rightChildVal = 10;由于8 < 10,所以c = 8.然后由于11 > 8,也就是 key > c,所以元素 8 作为树根左子树的根节点,现在树的形状如图(3)所示。这时候判断是否k < half,结果为false,所以退出循环。然后把x = 11的元素设置到数组下标为3的地方,这时候堆树如图(4)所示,至此调整堆完毕。

3、put操作

        put操作内部调用了offer操作,由于是无界队列,所以不需要阻塞

4、take操作

        take操作的作用是获取队列内部堆树的根节点元素,如果队列为空则阻塞,如下代码所示。
在这里插入图片描述
        在如上代码中,首先通过 lock.lockInterruptibly获取独占锁,以这个方式获取的锁会对中断进行响应。然后调用dequeue方法返回堆树根节点元素,如果队列为空,则返回false。然后当前线程调用notEmpty.await阻塞挂起自己,直到有线程调用了offer()方法(在offer方法内添加元素成功后会调用notEmpty.signal方法,这会激活一个阻塞在notEmpty的条件队列的一个线程)。另外,这里使用while循环而不是if语句是为了避免虚假唤醒。

5、size操作

        计算队列元素的个数,如下代码在返回size前加了锁,以保证在调用size方法时不会有其他线程进行入队和出队操作。另外,由于size变量没有被修饰为volatile的,所以这里加锁也保证了在多线程下size变量的内存可见性。

在这里插入图片描述

三、总结

        PriorityBlockingQueue队列在内部使用二叉树维护元素优先级,使用数组作为元素存储的数据结构,这个数组是可以扩容的,当当前元素个数>=最大容量时会通过CAS算法扩容,出队时始终保证出队的元素是堆树的根节点,而不是队列里面停留时间最长的元素。使用元素的compareTo方法提供默认的元素优先级比较规则,用户可以自定义优先级的比较规则。

如下所示,PriorityBlockingQueue类似于ArrayBlockingQueue,在内部使用一个独占锁来控制同时只有一个线程可以进行入队和出队操作。另外前者只使用一个notEmpty条件变量而没有使用notFull,这是因为前者是无界队列,执行put操作时永远不会处于await状态,所以也不需要被唤醒。而take方法是阻塞方法,并且是可被中断的,当需要存放有优先级的元素时该队列比较有用。
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值