PriorityQueue如何实现优先级,默认最小堆?

文章导读
PriorityQueue 是 Java 集合框架里一个很有特点的工具,它基于二叉堆结构,默认按照元素的自然顺序升序排列(也就是最小堆,堆顶永远是最小元素)。很多开发者初次接触时容易把它和有序集合混淆,实际上它只保证取出(poll/remove)时得到优先级最高的元素,内部并不是完全排序的。下面结合我日常排查问题的经验,梳理一下关键实现细节和容易踩的坑。
📋 目录
  1. 堆结构如何保证优先级
  2. 入队和出队流程
  3. 自定义优先级顺序
  4. 使用时的注意事项
A A

PriorityQueue 是 Java 集合框架里一个很有特点的工具,它基于二叉堆结构,默认按照元素的自然顺序升序排列(也就是最小堆,堆顶永远是最小元素)。很多开发者初次接触时容易把它和有序集合混淆,实际上它只保证取出(poll/remove)时得到优先级最高的元素,内部并不是完全排序的。下面结合我日常排查问题的经验,梳理一下关键实现细节和容易踩的坑。

堆结构如何保证优先级

PriorityQueue在Java中基于数组实现的二叉堆结构,默认是最小堆(小根堆)。数组第0个元素作为堆顶,对于任意位置i的节点,左子节点在2i+1,右子节点在2i+2,父节点在(i-1)/2。初始化时默认为容量11的自然顺序最小堆,所有元素必须实现Comparable或在构造时传入Comparator。

这段描述覆盖了三个关键点:底层是动态数组(不是链表)、节点位置映射是固定公式、比较逻辑由元素自身或外部比较器决定。日常排查堆序问题时,我通常会先打印数组内容看看元素相对位置,确认是否违反堆序(比如父节点大于子节点)。如果是从老版本 JDK 升级上来的项目,要注意构造时如果指定初始容量为 0,某些版本会抛出 IllegalArgumentException,建议初始容量至少为 1。

入队和出队流程

入队操作

当调用offer或add插入元素时,首先检查容量是否需要扩容,然后将元素添加到数组末尾,再通过siftUp方法向上调整堆。siftUp将新元素与父节点比较,若小于父节点则交换,重复直到满足堆序(新元素不小于父节点)或到达根。若使用Comparator,则比较逻辑由Comparator定义。此操作时间复杂度为O(log n)。

实际使用中扩容是个隐蔽的耗性能点。默认初始容量 11,如果插入大量元素会触发多次扩容(数组拷贝)。建议如果预估数据量较多,在构造时指定一个合适的初始容量,比如 new PriorityQueue<>(10000)。siftUp 的比较逻辑里如果用了自定义 Comparator,要确保 comparator 实现了反自反性(即 compare(a,a) 返回 0),否则在调整时可能出现死循环或 ClassCastException。

出队操作

删除堆顶元素(即优先级最高的最小元素)时,先记录堆顶值,然后将数组末尾元素移至堆顶,再调用siftDown方法向下调整。siftDown将当前节点与左右子节点中较小者比较,若大于子节点则交换,重复直到当前节点不大于子节点或成为叶子。此操作同样为O(log n),且保证弹出的是当前最小元素。

siftDown 的向下调整有个容易忽视的细节:它只和左右子节点中“更小的”比较。所以如果自定义比较器返回的结果不满足传递性(比如比较器有 bug),堆可能被破坏——这不一定会报错,但 poll 出来的顺序会乱。我遇到过好几次因为比较器写反导致堆变成最大堆的情况,最后通过打印每次 poll 的结果才定位到。出队后数组末尾元素会变成 null(实际上是删除了引用),不会有内存泄漏,但在多线程环境下如果直接访问底层数组会看到空洞,这是正常现象。

PriorityQueue如何实现优先级,默认最小堆?

自定义优先级顺序

通过构造时传入Comparator可以改变优先级顺序,实现最大堆:只需实现Comparator返回相反的比较结果,例如(a,b)->b.compareTo(a)。注意Comparator必须具有自反性、传递性和一致性,否则堆序可能被破坏。若元素本身未实现Comparable且未提供Comparator,插入时会抛出ClassCastException。

这段素材里提到的“自反性、传递性和一致性”是《Java 规范》里对 Comparator 的要求。实际写代码时最容易违反的是自反性:compare(a, a) 必须返回 0,如果写成 return a > b ? 1 : -1 就会漏掉相等情况(应该返回 0),堆序会不稳定。另外,如果想实现一个根据多个字段排序的优先级队列,建议先定义一个 Pojo 实现 Comparable 或用 Comparator 链式组合(比如 Comparator.comparing(T::getPriority).thenComparing(T::getId)),不要在一个比较器里写复杂的 if-else,否则后面维护时很难保证一致性。

使用时的注意事项

PriorityQueue允许重复元素,但不允许null。它不是线程安全的,多线程环境下需使用PriorityBlockingQueue。迭代器遍历元素时不保证任何顺序,因为遍历的是底层数组,而非堆序。若需有序遍历,应使用while循环不断poll。另外,remove方法移除指定元素需要线性扫描,时间复杂度为O(n),因为堆不支持高效随机删除。

这条素材总结了几个常见坑。我补充一个典型场景:如果在迭代过程中调用 remove(Object) 移除元素,会导致并发修改异常(ConcurrentModificationException),因为迭代器的 modCount 会变。解决办法是用显式的 while(poll) 遍历或者用 PriorityBlockingQueue 的 drainTo 方法。另外,堆不支持高效删除任意元素,如果业务上频繁需要删除非堆顶元素,可以考虑使用 TreeSet 或自定义堆+Map(参考 Dijkstra 算法里的索引堆)。线程安全方面,PriorityBlockingQueue 内部用了 ReentrantLock,但要注意它的 size() 方法返回的是精确值(因为有锁),而 drainTo 操作是阻塞的。

最后给一点小建议:如果你需要的是“每次取出最小的元素”这种典型场景,PriorityQueue 是非常合适的选择(比如任务调度、Dijkstra 算法)。但如果需要“有序遍历所有元素”,用 while(poll) 虽然能保证顺序但会清空队列,不如先复制一份再排序。另外,如果你的元素是可变对象(比如 List 或自定义 class 且字段会变),一定要在改变字段后重新插入(先 remove 再 add),否则堆序可能被破坏。因为堆只比较引用,不会监听内部状态变化。