引言
Top-K 问题,用 PriorityQueue 一行代码就能解决。
Top-K 问题,用 PriorityQueue 一行代码就能解决。
找出最大的 K 个数、最小的 K 个数、第 K 大的元素——这些面试高频题,本质上都归结为同一个数据结构:二叉堆。PriorityQueue 就是 JDK 对二叉堆的实现。
不同于普通的 FIFO 队列,PriorityQueue 每次 poll() 返回的都是优先级最高(最小或最大)的元素。插入和删除的时间复杂度都是 O(log n),比排序数组的 O(n) 高效得多。更重要的是,它不仅是理论工具——在实际开发中,堆排序、事件驱动模拟、图算法(Dijkstra、Prim)都依赖它。
本文将从源码级别深入剖析 PriorityQueue 的核心实现,带你理解:
- 二叉堆的数组映射规则(siftUp 上浮与 siftDown 下滤)
- 分阶段扩容策略(<64 时 2 倍,>=64 时 1.5 倍)的设计原因
- 删除任意元素的双重调整策略(先下滤、再上浮)
- 为什么 PriorityQueue 不实现 BlockingQueue 接口?
由于 PriorityQueue 跟前几个阻塞队列不一样,它并没有实现 BlockingQueue 接口,只是一个普通的非阻塞队列,只实现了 Queue 接口。
💡 核心提示:为什么 PriorityQueue 不实现 BlockingQueue 接口?
BlockingQueue的核心语义是"当队列满时阻塞等待空间、当队列空时阻塞等待元素"。但PriorityQueue是无界队列(底层数组可无限扩容),永远不会满,所以不需要"满时阻塞"的语义。同时,优先队列的设计目标是按优先级消费而非按到达顺序消费,与阻塞队列的"生产者-消费者"协作模型在场景上正交。因此 JDK 的设计者有意将PriorityQueue定位为"非阻塞的优先级队列",如果需要阻塞的优先级队列,应该使用DelayQueue或PriorityBlockingQueue。
Queue 接口中定义了几组放数据和取数据的方法,来满足不同的场景。
| 操作 | 抛出异常 | 返回特定值 |
|---|---|---|
| 放数据 | add() | offer() |
| 取数据(同时删除) | remove() | poll() |
| 查看数据(不删除) | element() | peek() |
这两组方法的区别是:
- 当队列满的时候,再次添加数据:
add()会抛出异常,offer()会返回false。 - 当队列为空的时候,再次取数据:
remove()会抛出异常,poll()会返回null。
不过 PriorityQueue 是无界队列,底层数组会自动扩容,所以实际上不会出现队列满的情况,add() 和 offer() 的行为完全相同。
类结构
先看一下 PriorityQueue 类里面有哪些属性:
public class PriorityQueue<E>
extends AbstractQueue<E>
implements java.io.Serializable {
/**
* 数组初始容量大小,默认 11
*/
private static final int DEFAULT_INITIAL_CAPACITY = 11;
/**
* 数组,用于存储堆元素(二叉堆的数组表示)
*/
transient Object[] queue;
/**
* 元素个数
*/
private int size = 0;
/**
* 比较器,用于排序元素优先级(null 时使用自然排序)
*/
private final Comparator<? super E> comparator;
}
可以看出 PriorityQueue 底层是基于数组实现的,使用 Object[] 数组以二叉堆的方式存储元素,并且定义了比较器 comparator,用于排序元素的优先级。
PriorityQueue 继承关系图
二叉堆在数组中的映射
二叉堆在数组中的映射规则:
- 节点
i的左子节点下标为2*i + 1 - 节点
i的右子节点下标为2*i + 2 - 节点
i的父节点下标为(i - 1) >>> 1
最小堆结构示意图
💡 核心提示:为什么不允许 null 元素?
PriorityQueue在offer()入口处就做了e == null判空并抛出NullPointerException。根本原因是堆调整过程中需要调用compareTo()或Comparator.compare(),如果允许 null 进入队列,在siftUp/siftDown时一旦与 null 比较就会触发 NPE。与其在复杂的堆调整逻辑中处处防御,不如在入口处统一拦截——这是典型的 fail-fast 设计。
初始化
PriorityQueue 常用的初始化方法有 4 个:
- 无参构造方法
- 指定容量大小的有参构造方法
- 指定比较器的有参构造方法
- 同时指定容量和比较器的有参构造方法
/**
* 无参构造方法
*/
PriorityQueue<Integer> queue1 = new PriorityQueue<>();
/**
* 指定容量大小的构造方法
*/
PriorityQueue<Integer> queue2 = new PriorityQueue<>(10);
/**
* 指定比较器的有参构造方法
*/
PriorityQueue<Integer> queue3 = new PriorityQueue<>(Integer::compareTo);
/**
* 同时指定容量和比较器的有参构造方法
*/
PriorityQueue<Integer> queue4 = new PriorityQueue<>(10, Integer::compare);
再看一下对应的源码实现:
/**
* 无参构造方法
*/
public PriorityQueue() {
// 使用默认容量大小 11,不指定比较器
this(DEFAULT_INITIAL_CAPACITY, null);
}
/**
* 指定容量大小的构造方法
*/
public PriorityQueue(int initialCapacity) {
this(initialCapacity, null);
}
/**
* 指定比较器的有参构造方法
*/
public PriorityQueue(Comparator<? super E> comparator) {
this(DEFAULT_INITIAL_CAPACITY, comparator);
}
/**
* 同时指定容量和比较器的有参构造方法
*/
public PriorityQueue(int initialCapacity, Comparator<? super E> comparator) {
if (initialCapacity < 1) {
throw new IllegalArgumentException();
}
this.queue = new Object[initialCapacity];
this.comparator = comparator;
}
可以看出 PriorityQueue 的无参构造方法使用默认容量 11,直接初始化数组,并且没有指定比较器(使用元素的自然排序)。所有构造方法最终都会委托给四参数的构造方法完成初始化。
放数据源码
放数据的方法有 2 个:
| 操作 | 抛出异常 | 返回特定值 |
|---|---|---|
| 放数据 | add() | offer() |
offer 方法源码
先看一下 offer() 方法源码,其他放数据方法逻辑也是大同小异。 由于 PriorityQueue 是无界队列,offer() 方法始终返回 true(永远不会返回 false)。
/**
* offer 方法入口
*
* @param e 元素
* @return 始终返回 true
*/
public boolean offer(E e) {
// 1. 判空,传参不允许为 null
if (e == null) {
throw new NullPointerException();
}
modCount++;
int i = size;
// 2. 当数组满的时候,执行扩容
if (i >= queue.length) {
grow(i + 1);
}
size = i + 1;
// 3. 如果是第一次插入,就直接把元素放入数组下标 0
if (i == 0) {
queue[0] = e;
} else {
// 4. 否则通过 siftUp 上浮到合适位置,保持最小堆性质
siftUp(i, e);
}
return true;
}
offer() 方法逻辑:判空 → 记录 modCount → 检查是否需要扩容 → 插入元素。如果是第一个元素,直接放在数组下标 0(堆顶);否则调用 siftUp 将新元素上浮到合适位置,保持最小堆的性质。
再看一下扩容的源码:
/**
* 扩容
*/
private void grow(int minCapacity) {
int oldCapacity = queue.length;
// 1. 容量 < 64 时,新容量 = old + (old + 2) ≈ 2 倍
// 容量 >= 64 时,新容量 = old + (old >> 1) = 1.5 倍
int newCapacity = oldCapacity +
((oldCapacity < 64) ? (oldCapacity + 2) : (oldCapacity >> 1));
// 2. 校验新容量是否超过上限
if (newCapacity - MAX_ARRAY_SIZE > 0) {
newCapacity = hugeCapacity(minCapacity);
}
// 3. 扩容为原数组的副本
queue = Arrays.copyOf(queue, newCapacity);
}
💡 核心提示:为什么扩容阈值是 64?为什么小容量 2x、大容量 1.5x?
这是一个经过实践验证的平衡点:
- 小容量阶段(< 64):队列刚创建时元素少,频繁扩容的成本高于"多分配一点空间"的浪费。2 倍增长可以让小队列快速到达合理容量,减少早期扩容次数。
- 大容量阶段(≥ 64):当数组已经较大时,每次扩容分配的绝对内存量已经很大(64 → 96 增加了 32,512 → 768 增加了 256)。如果继续用 2 倍增长,一次性分配过多内存,在内存紧张的场景下容易触发 OOM 或频繁的 GC。1.5 倍增长是一个经过验证的折中值——既不会增长过慢导致频繁
Arrays.copyOf,也不会一次分配过多。- 64 这个阈值:恰好是二叉堆深度约为 log₂(64) = 6 层,此时堆操作 O(log n) 的性能已经比较可观,不再需要激进扩容。
扩容策略的设计比较务实:容量较小时(< 64)采用近似 2 倍扩容,减少扩容次数;容量较大时采用 1.5 倍扩容,避免一次性分配过多空间。超过 MAX_ARRAY_SIZE(Integer.MAX_VALUE - 8)时,调用 hugeCapacity 处理边界情况。
PriorityQueue 为了快速插入和删除,采用了最小堆(min-heap)结构,而不是直接使用有序数组。最小堆的定义是:每个节点的值都小于等于其子节点的值(根节点是最小值)。这样既能保证插入和删除的时间复杂度都是 O(log n),又能避免移动过多元素。
最小堆工作原理图
siftUp 上浮操作源码
下面看一下 siftUp() 方法源码,是怎么保证插入元素后堆仍然是有序的?
核心思路就是:新元素从当前位置不断与父节点比较,如果比父节点小就上移,直到不比父节点小为止,找到合适的位置插入。
// 把元素上浮到合适的位置
private void siftUp(int k, E x) {
// 1. 如果自定义了比较器,就使用自定义比较器的方式
if (comparator != null) {
siftUpUsingComparator(k, x);
} else {
// 2. 否则使用元素的自然排序(Comparable)
siftUpComparable(k, x);
}
}
// 自定义比较器的上浮方法
private void siftUpUsingComparator(int k, E x) {
while (k > 0) {
// 1. 找到父节点下标
int parent = (k - 1) >>> 1;
Object e = queue[parent];
// 2. 如果当前元素 >= 父节点元素,说明已满足最小堆性质,停止上浮
if (comparator.compare(x, (E) e) >= 0) {
break;
}
// 3. 否则将父节点元素下移到当前位置
queue[k] = e;
k = parent;
}
// 4. 把当前元素插入到最终位置
queue[k] = x;
}
// 默认比较器(自然排序)的上浮方法
private void siftUpComparable(int k, E x) {
// 1. 将元素转为 Comparable 类型
Comparable<? super E> key = (Comparable<? super E>) x;
while (k > 0) {
// 2. 找到父节点下标
int parent = (k - 1) >>> 1;
Object e = queue[parent];
// 3. 如果当前元素 >= 父节点元素,停止上浮
if (key.compareTo((E) e) >= 0) {
break;
}
// 4. 否则将父节点元素下移
queue[k] = e;
k = parent;
}
// 5. 把当前元素插入到最终位置
queue[k] = key;
}
两种方式逻辑完全一致,只是比较的方式不同。
💡 核心提示:为什么采用"父节点下移 + 最终位置插入"而不是直接 swap?
传统的堆调整思路是"交换"(swap)当前元素与父/子节点,每次 swap 需要 3 次赋值操作(temp = a; a = b; b = temp)。而 JDK 的做法是:
- 循环中只将父节点下移覆盖到子节点位置:
queue[k] = queue[parent](1 次赋值)- 循环结束时才将目标元素插入最终位置:
queue[k] = x(1 次赋值)假设上浮路径长度为 h(二叉堆高度 h ≈ log₂n),传统 swap 需要 3h 次赋值,而 JDK 的"下移 + 最终插入"只需 h + 1 次赋值。当 n 较大时,这几乎减少了一半的写操作,对缓存友好度更高。
add 方法源码
add() 方法底层直接调用的是 offer() 方法,作用相同。
/**
* add 方法入口
*
* @param e 元素
* @return 是否添加成功
*/
public boolean add(E e) {
return offer(e);
}
取数据源码
取数据(取出并删除)的方法有 2 个:
| 操作 | 抛出异常 | 返回特定值 |
|---|---|---|
| 取数据(同时删除) | remove() | poll() |
poll 方法源码
看一下 poll() 方法源码,其他取数据方法逻辑大同小异,都是从堆顶(数组头部)弹出元素。 poll() 方法在取元素的时候,如果队列为空,直接返回 null,表示取元素失败。
/**
* poll 方法入口
*/
public E poll() {
// 1. 如果数组为空,返回 null
if (size == 0) {
return null;
}
int s = --size;
modCount++;
// 2. 暂存堆顶元素,最后返回
E result = (E) queue[0];
// 3. 暂存堆尾元素,用于后续的下滤调整
E x = (E) queue[s];
// 4. 将堆尾位置置 null(帮助 GC 回收)
queue[s] = null;
// 5. 如果还有剩余元素,用堆尾元素下滤调整最小堆
if (s != 0) {
siftDown(0, x);
}
return result;
}
poll() 的逻辑:保存堆顶元素 → 将堆尾元素移到堆顶 → 堆尾置 null(GC 友好) → 调用 siftDown 将新的堆顶元素下滤到合适位置,恢复最小堆性质。
poll() 中使用的 siftDown 方法与 offer() 中的 siftUp 对称:新元素从堆顶不断与较小的子节点比较,如果比子节点大就下移,直到不比子节点大为止。
siftDown 下滤操作源码
// 把元素下滤到合适的位置
private void siftDown(int k, E x) {
if (comparator != null) {
siftDownUsingComparator(k, x);
} else {
siftDownComparable(k, x);
}
}
private void siftDownUsingComparator(int k, E x) {
int half = size >>> 1; // 只需检查到最后一个非叶子节点
while (k < half) {
// 1. 找到左子节点
int child = (k << 1) + 1;
Object c = queue[child];
// 2. 找到右子节点(如果存在),并取左右子节点中较小的一个
int right = child + 1;
if (right < size && comparator.compare((E) c, (E) queue[right]) > 0) {
child = right;
c = queue[child];
}
// 3. 如果当前元素 <= 较小子节点,说明已满足最小堆性质,停止下滤
if (comparator.compare(x, (E) c) <= 0) {
break;
}
// 4. 否则将较小子节点上移到当前位置
queue[k] = c;
k = child;
}
// 5. 把当前元素插入到最终位置
queue[k] = x;
}
remove 方法源码
再看一下 remove() 方法源码。remove() 先调用 poll() 尝试取堆顶元素,如果取到直接返回;如果没取到(队列为空),poll() 返回 null,remove() 会抛出 NoSuchElementException 异常。
/**
* remove 方法入口
*/
public E remove() {
// 1. 直接调用 poll 方法
E x = poll();
// 2. 如果取到数据,直接返回,否则抛出异常
if (x != null) {
return x;
} else {
throw new NoSuchElementException();
}
}
除了 remove() 取堆顶元素外,PriorityQueue 还提供了删除任意元素的方法 remove(Object o):
/**
* 删除指定元素的第一次出现
*/
public boolean remove(Object o) {
// 1. 在数组中线性查找元素
int i = indexOf(o);
if (i == -1) {
return false;
}
// 2. 找到后调用 removeAt 删除
removeAt(i);
return true;
}
private int indexOf(Object o) {
if (o != null) {
for (int i = 0; i < size; i++) {
if (o.equals(queue[i])) {
return i;
}
}
}
return -1;
}
remove(Object o) 需要先在数组中线性查找目标元素(O(n)),然后调用 removeAt 删除并调整堆结构:
private E removeAt(int i) {
modCount++;
int s = --size;
if (s == i) {
// 删除的是最后一个元素,直接置 null 即可
queue[i] = null;
} else {
// 用最后一个元素填补删除位置
E moved = (E) queue[s];
queue[s] = null;
// 下滤调整
siftDown(i, moved);
// 如果下滤后元素仍在原位,说明需要上浮
if (queue[i] == moved) {
siftUp(i, moved);
}
}
return null;
}
这里的设计很巧妙:删除任意元素后,用堆尾元素填补空缺,先尝试下滤。如果下滤后元素还在原位(说明它不比子节点大),再尝试上浮(说明它可能比父节点小)。这样无论填补元素是大是小,都能正确恢复最小堆性质。
查看数据源码
再看一下查看数据的源码,只查看,不删除。
| 操作 | 抛出异常 | 返回特定值 |
|---|---|---|
| 查看数据(不删除) | element() | peek() |
peek 方法源码
先看一下 peek() 方法源码,如果数组为空,直接返回 null。
/**
* peek 方法入口
*/
public E peek() {
// 返回堆顶元素
return (size == 0) ? null : (E) queue[0];
}
element 方法源码
再看一下 element() 方法源码,如果队列为空,则抛出异常,底层直接调用 peek() 方法。
/**
* element 方法入口
*/
public E element() {
// 1. 调用 peek 方法查询数据
E x = peek();
// 2. 如果查到数据,直接返回
if (x != null) {
return x;
} else {
// 3. 如果没找到,则抛出异常
throw new NoSuchElementException();
}
}
💡 核心提示:为什么 iterator() 不保证按优先级顺序遍历?
PriorityQueue.iterator()返回的是底层Object[] queue数组的直接迭代器。二叉堆只保证父子节点的偏序关系(父 ≤ 子),不保证同一层元素之间的大小关系,更不保证整个数组的有序性。例如堆
[1, 2, 3, 4, 5, 6]对应的树结构是合法的堆,但数组本身不是有序的。如果需要按优先级顺序遍历,应该不断调用poll()逐个弹出元素,或者先new ArrayList<>(queue)然后Collections.sort()。
生产环境避坑指南
在使用 PriorityQueue 时,以下 8 个陷阱是生产环境中最容易踩的坑:
坑 1:插入 null 元素直接 NPE
PriorityQueue<String> pq = new PriorityQueue<>();
pq.offer(null); // 直接抛出 NullPointerException
原因:如前所述,堆调整时需要调用 compareTo 或 compare,null 会导致 NPE。JDK 选择在入口处统一拦截。 建议:业务层做好 null 过滤,或者用 Optional 包装。
坑 2:误以为 iterator() 有序,遍历结果"乱序"
PriorityQueue<Integer> pq = new PriorityQueue<>();
pq.addAll(Arrays.asList(3, 1, 4, 1, 5, 9, 2, 6));
for (Integer e : pq) {
System.out.print(e + " "); // 输出不是 1 1 2 3 4 5 6 9!
}
原因:iterator() 遍历的是底层数组,数组只满足堆的偏序性质,不保证全局有序。 建议:需要有序遍历时,使用 while (!pq.isEmpty()) pq.poll()。
坑 3:多线程环境下 ConcurrentModificationException 或数据错乱
PriorityQueue<Integer> pq = new PriorityQueue<>();
// 线程 A: pq.offer(1);
// 线程 B: pq.poll();
// 可能导致数据丢失、数组越界或 CME
原因:PriorityQueue 没有任何内部同步机制,size++、queue[k] = x 等操作都不是原子操作。 建议:多线程场景使用 PriorityBlockingQueue,或外部加锁。
坑 4:未实现 Comparable 的元素插入时 ClassCastException
class Task { int id; } // 没有实现 Comparable
PriorityQueue<Task> pq = new PriorityQueue<>();
pq.add(new Task()); // ClassCastException: Task cannot be cast to Comparable
原因:没有指定 Comparator 时,siftUpComparable 会将元素强转为 Comparable。 建议:自定义类要么实现 Comparable,要么构造时传入 Comparator。
坑 5:元素入队后修改字段值,破坏堆性质
PriorityQueue<Task> pq = new PriorityQueue<>(Comparator.comparingInt(t -> t.priority));
Task t = new Task(1, 5);
pq.add(t);
t.priority = 0; // 入队后修改优先级!堆性质被破坏
pq.poll(); // 取出的可能不是真正的最小元素
原因:PriorityQueue 只在插入/删除时维护堆性质,不会监听元素内部变化。 建议:使用不可变对象作为队列元素;或者需要"更新优先级"时先 remove 再 offer。
坑 6:remove(Object) 的 O(n) 性能陷阱
// 循环内反复调用 remove
for (int i = 0; i < 100000; i++) {
pq.remove(target); // 每次都是 O(n) 线性查找 + O(log n) 调整
}
原因:remove(Object) 需要线性扫描整个数组找目标位置,时间复杂度 O(n)。 建议:如果频繁删除任意元素,考虑使用 TreeSet(O(log n))或维护额外的索引结构。
坑 7:序列化时 comparator 可能丢失
PriorityQueue<Task> pq = new PriorityQueue<>(Comparator.comparingInt(t -> t.id));
// 序列化后再反序列化
ObjectOutputStream oos = new ObjectOutputStream(...);
oos.writeObject(pq);
// 如果 comparator 不是序列化安全的,反序列化后比较器可能为 null
原因:comparator 字段是 transient 的,不会参与默认序列化。JDK 通过 writeObject/readObject 特殊处理来保存比较器信息,但如果比较器本身不可序列化,就会丢失。 建议:确保自定义 Comparator 实现 Serializable。
坑 8:误用 PriorityQueue 做"最近 N 个元素"的场景
// 想用 PriorityQueue 维护 top-K 最大元素
PriorityQueue<Integer> pq = new PriorityQueue<>(); // 默认最小堆
// 需要的是最大堆!但 PriorityQueue 没有内置的 max-heap 构造方式
原因:PriorityQueue 默认是最小堆,如果需要最大堆,必须传入 (a, b) -> b.compareTo(a)。 建议:维护 top-K 最大元素时,构造为 PriorityQueue<>(k, Collections.reverseOrder())。
总结
这篇文章讲解了 PriorityQueue 优先队列的核心源码,了解到 PriorityQueue 具有以下特点:
PriorityQueue实现了Queue接口,是一个非阻塞的优先队列,提供了两组放数据和取数据的方法。PriorityQueue底层基于数组实现,按照最小堆(min-heap)存储,通过siftUp和siftDown操作保持堆性质。- 初始化时可以指定数组长度和自定义比较器。不指定比较器时使用元素的自然排序(
Comparable)。 - 初始容量是 11,当容量小于 64 时采用近似 2 倍扩容,否则采用 1.5 倍扩容。
- 每次都是从堆顶(数组下标 0)取元素,取之后用
siftDown调整最小堆。
关键操作时间复杂度对比
| 操作 | 方法 | 时间复杂度 | 说明 |
|---|---|---|---|
| 插入 | offer/add | O(log n) | siftUp 上浮操作 |
| 取堆顶 | poll/remove | O(log n) | siftDown 下滤操作 |
| 查看堆顶 | peek/element | O(1) | 直接返回 queue[0] |
| 删除任意元素 | remove(Object) | O(n) | 需线性查找 + siftDown/siftUp 调整 |
| 建堆 | heapify(有参构造传入集合) | O(n) | 批量构建堆 |
PriorityQueue vs DelayQueue vs TreeSet 对比
| 特性 | PriorityQueue | DelayQueue | TreeSet |
|---|---|---|---|
| 底层数据结构 | 数组 + 二叉堆(最小堆) | 数组 + 二叉堆(内部委托 PriorityQueue) | 红黑树(平衡二叉搜索树) |
| 排序方式 | 自然排序或自定义 Comparator | 元素的 getDelay() 返回值 | 自然排序或自定义 Comparator |
| 是否有序遍历 | 否(iterator 无序) | 否(iterator 无序) | 是(in-order 有序遍历) |
| 是否阻塞 | 否(非阻塞) | 是(take() 等待元素到期) | 否(非阻塞) |
| 线程安全 | 否 | 是(内部使用 ReentrantLock) | 否(可用 Collections.synchronizedSortedSet 包装) |
| 是否允许 null | 否 | 否 | 否 |
| 插入时间复杂度 | O(log n) | O(log n) | O(log n) |
| 获取最小元素 | O(1)(peek) | O(1)(peek),O(log n)(take,含到期等待) | O(log n)(first) |
| 删除任意元素 | O(n) | O(n) | O(log n) |
| 是否支持范围查询 | 否 | 否 | 是(subSet, headSet, tailSet) |
| 典型使用场景 | Top-K 问题、Dijkstra 最短路径、任务调度按优先级排序 | 定时任务调度、延迟消息队列、缓存过期清理 | 需要有序遍历和范围查询的排序集合 |
使用建议
- 不支持 null 元素:
PriorityQueue不允许插入null,会抛出NullPointerException。如果元素可能为空,需要在插入前做判空处理。 - 无序迭代:
PriorityQueue的iterator()方法不保证按优先级顺序遍历,因为它返回的是底层数组的迭代器。如果需要按优先级顺序消费元素,应使用poll()而不是iterator()。 - 非线程安全:
PriorityQueue没有做任何同步处理,在多线程环境下使用需要外部加锁,或者改用线程安全的PriorityBlockingQueue。 - 正确使用比较器:使用自然排序时,元素类型必须实现
Comparable接口,否则插入时会抛出ClassCastException。如果元素不实现Comparable,必须在构造时传入自定义Comparator。 - 元素入队后不要修改影响比较的字段:这会破坏堆的偏序性质,导致
poll()取出的不是真正的最小/最大元素。
行动清单
- 检查生产代码:搜索项目中使用
PriorityQueue的地方,确认没有遗漏 null 检查、没有在不恰当的并发场景直接使用。 - 替换无序遍历:如果代码中有
for (E e : priorityQueue)的用法,确认是否真的不关心顺序。如果需要有序消费,改为while (!pq.isEmpty()) pq.poll()。 - 自定义类入队前:确保元素类实现了
Comparable或在构造PriorityQueue时传入了Comparator,避免运行时的ClassCastException。 - 线程安全升级:如果当前在多线程环境下使用
PriorityQueue,替换为PriorityBlockingQueue或在外层加锁。 - 性能评估:如果需要频繁删除任意元素(而非只删堆顶),评估是否应改用
TreeSet(O(log n) 删除)或其他数据结构。 - Top-K 场景:使用
PriorityQueue做 Top-K 时,记住:求最大 K 个元素用最小堆(丢弃比堆顶更小的),求最小 K 个元素用最大堆(丢弃比堆顶更大的)。 - Comparator 序列化:如果
PriorityQueue需要序列化传输,确保自定义的Comparator实现了Serializable接口。 - 扩展阅读:推荐阅读《算法(第 4 版)》第 2.4 节"优先队列",深入理解二叉堆的理论基础;推荐阅读 JDK 中
PriorityBlockingQueue源码,了解阻塞优先级队列的锁机制。