【Java 并发】【十】【JUC数据结构】【十】PriorityBlockingQueue 原理
1 前言
这节我们继续看看另一个队列 PriorityBlockingQueue,优先级的哈。
2 PriorityBlockingQueue 介绍
PriorityBlockingQueue 是带优先级的无界阻塞队列,每次出队都返回优先级最高或者 最低的元素。其内部是使用平衡二叉树堆实现的,所以直接遍历队列元素不保证有序。默 认使用对象的compareTo方法提供比较规则,如果你需要自定义比较规则则可以自定义 comparators。
下面首先通过类图结构(见图7-32)来从全局了解PriorityBlockingQueue的原理。
由图7-32可知,PriorityBlockingQueue 内部有一个数组queue,用来存放队列元素, size 用来存放队列元素个数。allocationSpinLock 是个自旋锁,其使用CAS操作来保证同 时只有一个线程可以扩容队列,状态为0或者1,其中0表示当前没有进行扩容,1表示 当前正在扩容。
由于这是一个优先级队列,所以有一个比较器comparator用来比较元素大小。lock独 占锁对象用来控制同时只能有一个线程可以进行入队、出队操作。notEmpty条件变量用 来实现take方法阻塞模式。这里没有notFull 条件变量是因为这里的put操作是非阻塞的, 为啥要设计为非阻塞的,是因为这是无界队列。
在如下构造函数中,默认队列容量为11,默认比较器为null,也就是使用元素的 compareTo 方法进行比较来确定元素的优先级,这意味着队列元素必须实现了Comparable 接口。
private static final int DEFAULT_INITIAL_CAPACITY = 11; public PriorityBlockingQueue() { this(DEFAULT_INITIAL_CAPACITY, null); } public PriorityBlockingQueue(int initialCapacity) { this(initialCapacity, null); } public PriorityBlockingQueue(int initialCapacity, Comparator<? super E> comparator) { if (initialCapacity < 1) throw new IllegalArgumentException(); this.lock = new ReentrantLock(); this.notEmpty = lock.newCondition(); this.comparator = comparator; this.queue = new Object[initialCapacity]; }
3 PriorityBlockingQueue 源码分析
从源码来看看 PriorityBlockingQueue 的几个主要方法的实现原理。
3.1 offer 操作
offer 操作的作用是在队列中插入一个元素,由于是无界队列,所以一直返回true。如 下是offer 函数的代码。
public boolean offer(E e) { if (e == null) throw new NullPointerException(); //获取独占锁 final ReentrantLock lock = this.lock; lock.lock(); int n, cap; Object[] array; //(1)如果当前元素个数>=队列容量,则扩容 while ((n = size) >= (cap = (array = queue).length)) tryGrow(array, cap); try { Comparator<? super E> cmp = comparator; //(2)默认比较器为null if (cmp == null) siftUpComparable(n, e, array); else //(3)自定义比较器 siftUpUsingComparator(n, e, array, cmp); //(9)将队列元素数增加1,并且激活notEmpty的条件队列里面的一个阻塞线程 size = n + 1; notEmpty.signal();//激活因调用take()方法被阻塞的线程 } finally { //释放独占锁 lock.unlock(); } return true; }
如上代码的主流程比较简单,下面主要看看如何进行扩容和在内部建堆。首先看下面 的扩容逻辑。
private void tryGrow(Object[] array, int oldCap) { lock.unlock(); //释放获取的锁 Object[] newArray = null; //(4)CAS成功则扩容 if (allocationSpinLock == 0 && UNSAFE.compareAndSwapInt(this, allocationSpinLockOffset, 0, 1)) { try { //oldGap<64则扩容,执行oldcap+2,否则扩容50%,并且最大为MAX_ARRAY_SIZE 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; } } //(5)第一个线程CAS成功后,第二个线程会进入这段代码,然后第二个线程让出CPU,尽量让第一个线程获取锁,但是这得不到保证。 if (newArray == null) // back off if another thread is allocating Thread.yield(); lock.lock();//(6) if (newArray != null && queue == array) { queue = newArray; System.arraycopy(array, 0, newArray, 0, oldCap); } }
tryGrow 的作用是扩容。这里为啥在扩容前要先释放锁,然后使用CAS控制只有一个 线程可以扩容成功?其实这里不先释放锁,也是可行的,也就是在整个扩容期间一直持有 锁,但是扩容是需要花时间的,如果扩容时还占用锁那么其他线程在这个时候是不能进行出队和入队操作的,这大大降低了并发性。所以为了提高性能,使用CAS控制只有一个 线程可以进行扩容,并且在扩容前释放锁,让其他线程可以进行入队和出队操作。
spinlock 锁使用CAS 控制只有一个线程可以进行扩容,CAS失败的线程会调用 Thread.yield() 让出 CPU,目的是让扩容线程扩容后优先调用lock.lock重新获取锁,但是 这得不到保证。有可能yield的线程在扩容线程扩容完成前已经退出,并执行代码(6)获 取到了锁,这时候获取到锁的线程发现newArray为null就会执行代码(1)。如果当前数 组扩容还没完毕,当前线程会再次调用tryGrow方法,然后释放锁,这又给扩容线程获取 锁提供了机会,如果这时候扩容线程还没扩容完毕,则当前线程释放锁后又调用yield方 法让出CPU。所以当扩容线程进行扩容时,其他线程原地自旋通过代码(1)检查当前扩 容是否完毕,扩容完毕后才退出代码(1)的循环。
扩容线程扩容完毕后会重置自旋锁变量allocationSpinLock为0,这里并没有使 用UNSAFE方法的CAS进行设置是因为同时只可能有一个线程获取到该锁,并且 allocationSpinLock 被修饰为了 volatile 的。 当扩容线程扩容完毕后会执行代码(6)获取锁, 获取锁后复制当前queue里面的元素到新数组。
然后看下面的具体建堆算法。
private static <T> void siftUpComparable(int k, T x, Object[] array) { Comparable<? super T> key = (Comparable<? super T>) x; //队列元素个数>0则判断插入位置,否则直接入队(7) while (k > 0) { int parent = (k - 1) >>> 1; Object e = array[parent]; if (key.compareTo((T) e) >= 0) break; array[k] = e; k = parent; } array[k] = key;(8) }
下面用图来解释上面算法过程,假设队列初始化容量为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退出循环。最后把元素1存放到下标为0的地方,现在的状态如下所示:
这时候二叉树堆的树形图如下所示:
由此可见,堆的根元素是1,也就是这是一个最小堆,那么当调用这个优先级队列的 poll 方法时,会依次返回堆里面值最小的元素。
3.2 poll 操作
poll 操作的作用是获取队列内部堆树的根节点元素,如果队列为空,则返回null。poll 函数的代码如下。
public E poll() { final ReentrantLock lock = this.lock; lock.lock();//获取独占锁 try { return dequeue(); } finally { lock.unlock();//释放独占锁 } }
如以上代码所示,在进行出队操作时要先加锁,这意味着,当前线程在进行出队操作 时,其他线程不能再进行入队和出队操作,但是前面在介绍offer函数时介绍过,这时候 其他线程可以进行扩容。下面看下具体执行出队操作的 dequeue方法的代码:
private E dequeue() { //队列为空,则返回null int n = size - 1; if (n < 0) return null; else { //(1)获取队头元素 Object[] array = queue; E result = (E) array[0]; //(2)获取队尾元素,并赋值为null E x = (E) array[n]; array[n] = null; Comparator<? super E> cmp = comparator; if (cmp == null)//(3) siftDownComparable(0, x, array, n); else siftDownUsingComparator(0, x, array, n, cmp); size = n;//(4) return result; } }
在如上代码中,如果队列为空则直接返回null,否则执行代码(1)获取数组第一个 元素作为返回值存放到变量Result中,这里需要注意,数组里面的第一个元素是优先级最 小或者最大的元素,出队操作就是返回这个元素。 然后代码(2)获取队列尾部元素并存 放到变量x中,且置空尾部节点,然后执行代码(3)将变量x插入到数组下标为0的位置, 之后重新调整堆为最大或者最小堆,然后返回。这里重要的是,去掉堆的根节点后,如何 使用剩下的节点重新调整一个最大或者最小堆。下面我们看下siftDownComparable的实现 代码。
private static <T> void siftDownComparable(int k, T x, Object[] array, int n) { if (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 = array[child];(5) int right = child + 1;(6) if (right < n && ((Comparable<? super T>) c).compareTo((T) array[right]) > 0)(7) c = array[child = right]; if (key.compareTo((T) c) <= 0)(8) break; array[k] = c; k = child; } array[k] = key;(9) } }
同样下面我们结合图来介绍上面调整堆的算法过程。接着上节队列的状态继续讲解, 在上一节中队列元素序列为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)调整堆后队列状态为
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)所示,至此调整堆完毕。siftDownComparable 返回的 result=2,所以 poll 方法也返回了。
3.3 put 操作
put 操作内部调用的是offer操作,由于是无界队列,所以不需要阻塞。
public void put(E e) { offer(e); // never need to block }
3.4 take 操作
take 操作的作用是获取队列内部堆树的根节点元素,如果队列为空则阻塞,如以下代 码所示。
public E take() throws InterruptedException { //获取锁,可被中断 final ReentrantLock lock = this.lock; lock.lockInterruptibly(); E result; try { //如果队列为空,则阻塞,把当前线程放入notEmpty的条件队列 while ( (result = dequeue()) == null) notEmpty.await();//阻塞当前线程 } finally { lock.unlock();//释放锁 } return result; }
在如上代码中,首先通过 lock.lockInterruptibly() 获取独占锁,以这个方式获取的锁 会对中断进行响应。然后调用dequeue方法返回堆树根节点元素,如果队列为空,则返回 false。然后当前线程调用notEmpty.await()阻塞挂起自己,直到有线程调用了offer()方法(在 offer 方法内添加元素成功后会调用 notEmpty.signal方法,这会激活一个阻塞在notEmpty 的条件队列里面的一个线程)。另外,这里使用while循环而不是if语句是为了避免虚假 唤醒。
3.5 size 操作
计算队列元素个数。如下代码在返回size前加了锁,以保证在调用size()方法时不会 有其他线程进行入队和出队操作。另外,由于size变量没有被修饰为volatie的,所以这 里加锁也保证了在多线程下size变量的内存可见性。
public int size() { final ReentrantLock lock = this.lock; lock.lock(); try { return size; } finally { lock.unlock(); } }
3.6 案例介绍
下面我们通过一个案例来体会PriorityBlockingQueue的使用方法。在这个案例中,会 把具有优先级的任务放入队列,然后从队列里面逐个获取优先级最高的任务来执行。
public class TestPriorityBlockingQueue { static class Task implements Comparable<Task> { public int getPriority() { return priority; } public void setPriority(int priority) { this.priority = priority; } public String getTaskName() { return taskName; } public void setTaskName(String taskName) { this.taskName = taskName; } private int priority = 0; private String taskName; @Override public int compareTo(Task o) { if (this.priority >= o.getPriority()) { return 1; } else { return -1; } } public void doSomeThing(){ System.out.println(taskName + ":" + priority); } } public static void main(String[] args) { //创建任务,并添加到队列 PriorityBlockingQueue<Task> priorityQueue = new PriorityBlockingQueue<Task>(); Random random = new Random(); for(int i=0;i<10;++i){ Task task = new Task(); task.setPriority(random.nextInt(10)); task.setTaskName("taskName" +i); priorityQueue.offer(task); } //取出任务执行 while(!priorityQueue.isEmpty()){ Task task = priorityQueue.poll(); if(null != task){ task.doSomeThing(); } } } }
如上代码首先创建了一个Task类,该类继承了Comparable方法并重写了compareTo 方法,自定义了元素优先级比较规则。然后在main函数里面创建了一个优先级队列,并 使用随机数生成器生成10个随机的有优先级的任务,并将它们添加到优先级队列。最后 从优先级队列里面逐个获取任务并执行。运行上面代码,一个可能的输出如下所示。
taskName7:0 taskName6:1 taskName9:1 taskName1:2 taskName5:3 taskName0:3 taskName3:4 taskName8:5 taskName2:7 taskName4:7
从结果可知,任务执行的先后顺序和它们被放入队列的先后顺序没有关系,而是和它 们的优先级有关系。
4 小结
PriorityBlockingQueue 队列在内部使用二叉树堆维护元素优先级,使用数组作为元素 存储的数据结构,这个数组是可扩容的。当当前元素个数>=最大容量时会通过CAS算法 扩容,出队时始终保证出队的元素是堆树的根节点,而不是在队列里面停留时间最长的元 素。使用元素的compareTo方法提供默认的元素优先级比较规则,用户可以自定义优先级 的比较规则。
如图7-33所示,PriorityBlockingQueue 类似于ArrayBlockingQueue,在内部使用一个独占锁来控制同时只有一个线程可以进行入队和出队操作。另外,前者只使用了一个 notEmpty 条件变量而没有使用notFull,这是因为前者是无界队列,执行put操作时永远不 会处于await状态,所以也不需要被唤醒。而take方法是阻塞方法,并且是可被中断的。 当需要存放有优先级的元素时该队列比较有用。