Java PriorityQueue 源码解析:二叉堆、入队出队与 TopK 实战
2026/9/22 9:49:14 网站建设 项目流程

写这篇东西的起因,是我在梳理 Java 集合框架时,发现很多人的认知停留在"PriorityQueue 就是个会自动排序的队列",但真被问到"它底层是数组还是链表""入队时元素怎么找到自己的位置""为什么迭代器遍历出来的顺序不是有序的"就卡壳了。这些问题恰恰是面试和实际编码中最容易暴露理解深度的点。我花了几个晚上把 JDK 源码里 PriorityQueue 相关的方法逐个读了一遍,结合调试和实战场景做了验证,这篇就把我的完整理解写出来。

1. 队列底层不是有序数组,而是一棵隐式二叉堆

先说结论:PriorityQueue 底层是一个 Object 数组,但这个数组在逻辑上被看成一棵完全二叉树。我们平时说的"优先级队列",本质上是"用数组实现的二叉堆"(Binary Heap),Java 的这个实现属于小顶堆——堆顶元素永远是队列中优先级最高的那个,也就是最小的元素。

1.1 数组下标里的父子关系

堆的性质决定了数组下标之间存在固定的换算关系。对于下标为i的元素:

  • 父节点下标:(i - 1) >>> 1(等价于(i - 1) / 2
  • 左子节点下标:(i << 1) + 1
  • 右子节点下标:(i << 1) + 2

我用一个实际例子演示,假设队列中有这些元素:[3, 8, 5, 12, 9, 7],它们在数组里是连续存放的,逻辑堆的结构是这样的:

3 下标0 / \ 8 5 下标1、2 / \ / 12 9 7 下标3、4、5

这棵树保证父节点不大于它的子节点,但兄弟节点之间、不同分支之间没有大小约束。比如85谁大谁小无所谓,129也不需要跟5比较。这是理解 PriorityQueue 所有操作的基础——它只保证局部偏序,不做全局排序

1.2 为什么用数组不用链表

用链表实现二叉堆不是不行,但数组有天然优势:完全二叉树用数组存储不会浪费空间,而且通过下标访问父子和兄弟节点是 O(1) 操作,CPU 缓存命中率也远高于散落的链表节点。代价就是扩容时需要搬移数组,但队列扩容的频率远低于元素入队的频率,均摊成本完全可接受。

这个设计带来的直接推论是:PriorityQueue 不支持 null 元素,因为compareTo或者Comparator.compare无法处理 null。这一点后面讲入队逻辑时还会再验证。

2. 构造与扩容:那些容易被忽略的边界条件

2.1 初始容量的计算

PriorityQueue 有多个构造方法,最常用的两个:

public PriorityQueue() { this(DEFAULT_INITIAL_CAPACITY, null); } public PriorityQueue(int initialCapacity) { this(initialCapacity, null); }

DEFAULT_INITIAL_CAPACITY是 11。值得注意的是,如果你传入的initialCapacity小于 1,会直接抛IllegalArgumentException。这里有个不为人注意的细节:如果通过集合构造(比如new PriorityQueue<>(collection)),实际初始容量是Math.max(1, 集合大小),而不是简单地取集合的size()

2.2 grow 方法的扩容策略

扩容逻辑在grow(int minCapacity)里:

private void grow(int minCapacity) { int oldCapacity = queue.length; // Double size if small; else grow by 50% int newCapacity = oldCapacity + ((oldCapacity < 64) ? (oldCapacity + 2) : (oldCapacity >> 1)); // overflow-conscious code if (newCapacity - MAX_ARRAY_SIZE > 0) newCapacity = hugeCapacity(minCapacity); queue = Arrays.copyOf(queue, newCapacity); }

JDK 8 的扩容策略是:如果旧容量小于 64,扩容后容量是oldCapacity * 2 + 2;如果大于等于 64,扩容 50%。为什么是 64 这个阈值?因为小数组扩容翻倍增长的摊销成本更低,大数组按 50% 增长可以减少内存浪费。这个思路和ArrayList不一样,ArrayList 是固定 1.5 倍,PriorityQueue 对小容量更激进。

扩容的本质是用Arrays.copyOf生成新数组并迁移元素,这是一个 O(n) 操作,但均摊到每次入队就是 O(1)。如果你能预估元素量,最好在构造时就指定容量,避免中途扩容。

这里还有个溢出保护逻辑:如果计算出的newCapacity超过MAX_ARRAY_SIZEInteger.MAX_VALUE - 8),会调用hugeCapacity处理,容量上限是Integer.MAX_VALUE。这个保护很重要,因为数组长度在 JVM 里是带符号 int 的,超过Integer.MAX_VALUE - 8就可能触发 OOM。

3. 入队源码走读:siftUp 上浮让新元素找到位置

入队操作对外是offer(E e),内部核心是siftUp。我先把完整链路贴出来,再逐行拆解。

3.1 offer 方法整体逻辑

public boolean offer(E e) { if (e == null) throw new NullPointerException(); modCount++; int i = size; if (i >= queue.length) grow(i + 1); size = i + 1; if (i == 0) queue[0] = e; else siftUp(i, e); return true; }

几个关键动作依次是:查空、记录结构性修改次数、扩容检查、放入元素或执行上浮。注意modCount++这个细节,它服务于 fail-fast 迭代器,可以理解成一个版本号,迭代期间队列结构被修改就会抛ConcurrentModificationException

如果你向一个空队列插入第一个元素,不需要比较,直接放在queue[0]即可。这也解释了为什么 PriorityQueue 的offer永远返回true——它不像ArrayBlockingQueue有容量限制,除非 OOM,否则不会拒绝元素。

3.2 siftUp 为什么是"上浮"而不是"下沉"

看核心方法:

private void siftUp(int k, E x) { if (comparator != null) siftUpUsingComparator(k, x); else siftUpComparable(k, x); } @SuppressWarnings("unchecked") private void siftUpComparable(int k, E x) { Comparable<? super E> key = (Comparable<? super E>) x; while (k > 0) { int parent = (k - 1) >>> 1; Object e = queue[parent]; if (key.compareTo((E) e) >= 0) break; queue[k] = e; k = parent; } queue[k] = key; }

新元素追加到数组尾部时,它的下标是k,它可能比父节点小。上浮的过程就是:不断拿新元素跟父节点比较,如果新元素更小,就把父节点下移到当前空位,新元素继续往上走;直到新元素不小于父节点,或者已经走到堆顶。

用生活化类比:就像往一个已按身高排好的队伍里插入一个新队员,他个子矮,就要一路往前挤,挤到前面比他高的人后面为止。

3.3 构造器选择:Comparable 还是 Comparator

PriorityQueue 支持两种比较方式:

  • 无参构造:要求元素实现Comparable,走siftUpComparable
  • Comparator构造:走siftUpUsingComparator,逻辑完全一样,只是比较动作从key.compareTo(e)变成comparator.compare(x, (E) e)

我的经验是:业务对象作为队列元素时,优先用Comparator。理由很实际:一个类通常只有一个自然的compareTo语义,但不同业务场景对"最小"的定义不同——可能是最早过期时间、最低价格、最高评分。用Comparator可以做到一个对象多套排序维度,还不用改类定义。

顺带提醒一个常见误解:PriorityQueue 的offer时间复杂度是 O(log n),不是 O(1)。虽然它尾插是 O(1),但上浮调整最坏要比较到根节点,比较次数是树高log2(n)

4. 出队源码走读:siftDown 下沉维持堆结构

出队操作是poll(),它跟入队对称,但调整方向相反,逻辑也更复杂一些。

4.1 poll 方法的完整执行路径

public E poll() { if (size == 0) return null; int s = --size; modCount++; E result = (E) queue[0]; E x = (E) queue[s]; queue[s] = null; if (s != 0) siftDown(0, x); return result; }

执行过程拆成四步:

  1. 队列为空直接返回null(这也意味着 PriorityQueue 不能用poll()判空后再插入null
  2. 记录堆顶元素作为返回值
  3. 把数组最后一个元素取出来,原来的位置置空
  4. 把最后一个元素放到堆顶位置,然后执行下沉调整

为什么要拿"最后一个元素"去填补堆顶?因为要保证完全二叉树的形状不被破坏。如果直接把中间的某个元素挪到堆顶,树可能就不"完全"了,数组中间也会出现空洞。

4.2 siftDown 的具体过程

private void siftDownComparable(int k, E x) { Comparable<? super E> key = (Comparable<? super E>)x; int half = size >>> 1; // loop while a non-leaf while (k < half) { int child = (k << 1) + 1; // assume left child is least Object c = queue[child]; int right = child + 1; if (right < size && ((Comparable<? super E>) c).compareTo((E) queue[right]) > 0) c = queue[child = right]; if (key.compareTo((E) c) <= 0) break; queue[k] = c; k = child; } queue[k] = key; }

half = size >>> 1是一个很巧妙的边界:只有下标小于 half 的节点才有子节点。比如 size 是 7,half 是 3,下标 0、1、2 有子节点,下标 3、4、5、6 都是叶子节点。如果待调整位置已经到叶子层,就不需要再比较了。

下沉策略是"两子取小":先默认左子节点较小,然后看右子节点是否存在且更小,如果右子更小就切换;接着拿待插入元素跟这个较小的子节点比较,如果待插入元素更小,说明它找到了合适位置,直接停;否则把较小的子节点上移,自己继续往下走。

这个"两子取小"很关键——小顶堆的父节点必须小于等于两个子节点,所以只需要跟较小的子节点比。如果跟较大的子节点比,即使比不过较大子节点,也不能保证比小子节点小,堆性质就乱了。

4.3 peek 为什么是 O(1)

peek()就更简单了:

public E peek() { return (size == 0) ? null : (E) queue[0]; }

直接返回数组首元素,不做任何调整。这是堆设计的红利——最小元素永远在树根。但要注意,peek在队列为空时返回null,所以调用方要自己处理空队列场景。

5. 批量建堆:heapify 如何做到 O(n)

如果你用一个无序集合去构造 PriorityQueue,比如new PriorityQueue<>(existingList),JDK 不会逐个offer,而是调用heapify方法直接原地建堆。

5.1 从最后一个非叶子节点开始下沉

heapify的代码非常短:

private void heapify() { for (int i = (size >>> 1) - 1; i >= 0; i--) siftDown(i, (E) queue[i]); }

从最后一个非叶子节点开始,依次向前对每个节点执行siftDown。为什么不是从头开始?因为叶子节点不需要下沉,从最后倒数第二层开始做,能保证处理某个节点时,它的左右子树已经是合法堆。

这个算法在数据结构里叫Floyd 建堆法,时间复杂度是 O(n) 而不是 O(n log n)。直觉理解是:层数越低的节点,需要下沉的距离越短,大部分节点都集中在树的底部,它们几乎不需要移动,整体工作量近似线性的。

如果你在代码里用"循环 offer"的方式初始化一个 n 个元素的队列,复杂度是 O(n log n);而直接用集合构造,复杂度 O(n)。数据量一大,这个差距立刻体现出来。我在构造 100 万元素队列的测试里,heapify 方式大概快了一个数量级。

5.2 为什么面试题常问"建堆为什么是 O(n)"

很多人不信这个复杂度,因为siftDown看起来是 O(log n),外层循环 n/2 次,怎么会是 O(n)?

关键在于绝大部分节点的下沉深度很小。做一个简单的数学估算:堆里第 h 层的节点有2^h个,但它们最多只需要下沉log2(n) - h层。把所有层的下沉工作量加起来,是一个收敛的级数,总和是 O(n)。

这是理解堆性能分水岭的重要一关。建议你对比一下"逐个上浮"和"统一下沉"两种建堆方式的差异,理解透了,面试时就能讲得比别人深一层。

6. remove(Object) 与迭代器:PriorityQueue 的查找软肋与弱一致遍历

PriorityQueue 在查找任意元素这件事上没有任何优化,因为它不是为随机访问设计的。

6.1 从任意位置移除元素的两个动作

public boolean remove(Object o) { int i = indexOf(o); if (i == -1) return false; else { removeAt(i); return true; } }

indexOf就是线性扫描数组,时间复杂度 O(n)。真正有意思的是removeAt

private E removeAt(int i) { modCount++; int s = --size; if (s == i) // removed last element queue[i] = null; else { E moved = (E) queue[s]; queue[s] = null; siftDown(i, moved); if (queue[i] == moved) { siftUp(i, moved); if (queue[i] != moved) return moved; } } return null; }

这里有个非常巧妙的处理:先用最后一个元素填补空缺,执行siftDown如果下沉后元素没动过位置,说明它比所有子节点都小,但它可能比父节点还小,这时需要反过来执行siftUp上浮。

很多人在分析 remove 时只提下沉,不提这个"下沉失败后补一次上浮"的分支——但实际场景里,拿尾部元素补到中间位置时,很可能遇到"比子节点小但比父节点也小"的情况,缺了这步堆性质就坏了。

6.2 迭代器的弱一致性体现在哪

PriorityQueue 的迭代器是基于数组快照的?不,它不是快照,而是直接遍历内部数组,同时通过modCount做 fail-fast。但它有个特殊行为:迭代器内部维护了一个forgetMeNot队列,如果迭代过程中元素被remove()移除且不是尾部,被移除的元素会被放进forgetMeNot,后续迭代器会把它也遍历出来。

这导致的直接后果是:迭代器遍历顺序既不是堆序,也不保证和元素插入顺序一致。比如我插入[5, 3, 9, 1],数组内容是[1, 3, 9, 5],迭代器遍历出来就是1, 3, 9, 5,而不是排序后的1, 3, 5, 9

想要有序遍历,正确姿势是循环poll(),因为每次 poll 都取堆顶最小元素,所以能按优先级顺序输出。但注意 poll 会清空队列,如果需要保留原队列,可以先用拷贝构造一个新队列再 poll。

7. 从源码回到实战:TopK 问题、合并有序列表与延迟队列场景

7.1 用 PriorityQueue 解决 TopK 的固定套路

求一个数据流里最大的 K 个元素,用小顶堆,堆顶就是当前第 K 大的元素;新元素如果比堆顶大,就替换堆顶并下沉。反过来,求最小的 K 个元素,用大顶堆(通过Comparator.reverseOrder())。

我贴一个实际用过的求 TopK 模板,处理 10 亿级数据量时主要靠它做内存内预筛选:

public List<Integer> topK(int[] nums, int k) { PriorityQueue<Integer> minHeap = new PriorityQueue<>(k); for (int num : nums) { if (minHeap.size() < k) { minHeap.offer(num); } else if (num > minHeap.peek()) { minHeap.poll(); minHeap.offer(num); } } return new ArrayList<>(minHeap); }

这样做的时间复杂度是 O(n log k),内存占用 O(k)。相比全局排序 O(n log n),当 n 远大于 k 时优势巨大。Java 8 之后也可以直接用自定义Collector,但底层思路还是这个。

7.2 合并 K 个有序链表

LeetCode 23 题的经典解法就是 PriorityQueue 做多路归并:把每个链表的头节点放进队列,每次 poll 出最小节点,然后把它的 next 节点补进队列,循环直到队列为空。每个链表头节点入堆 O(k),每次 poll 和 offer 各 O(log k),总复杂度 O(n log k)。

7.3 延迟队列的底层搭档

Java 的DelayQueue内部就持有一个 PriorityQueue,元素按getDelay(TimeUnit)返回值排序,最早过期的任务在堆顶。这算是一个"组合优于继承"的好例子——延迟队列不需要重新实现排序逻辑,只要定义好优先级规则就行。

7.4 一个要注意的坑:比较器不能与 equals 不一致

PriorityQueue 的remove(Object)是通过indexOf线性查找的,而indexOf用的是equals;但堆序调整用的是Comparator。如果两个元素在比较器眼里相等,但equals返回 false,就会出现能入队但从堆结构上难以定位删除的现象。实际业务中我建议:队列元素的比较器语义尽量和 equals 保持一致,或者在需求层面明确"相等即同一",否则排查 bug 时会非常痛苦。

再举个实际踩过的坑:我用 PriorityQueue 做任务调度,任务对象里有个timestamp字段用来排序,但两个任务的timestamp相同时,Comparator返回 0,这时如果它们的equals因其他字段不同返回 false,remove(task)会找不到目标任务(因为比较器只在堆内部调整时生效,indexOf不看比较器)。解决方案是给 Comparator 加一个次要排序键,比如任务 ID,确保比较结果和 equals 尽量一致。

8. 性能边界与替代方案:什么时候不该用 PriorityQueue

PriorityQueue 不是万能的,我整理了一份选型对照表,方便你快速判断:

需求推荐结构原因
频繁取最小/最大元素PriorityQueuepoll/offer 都是 O(log n)
频繁随机访问或按下标修改ArrayList + 手动排序数组按下标访问 O(1)
需要严格的全局有序遍历TreeSet / 排序后的 ArrayList迭代就是有序的
线程安全的优先级队列PriorityBlockingQueue内部加锁,基于 PriorityQueue
需要按插入顺序遍历LinkedList / ArrayDequeFIFO 语义清晰

PriorityQueue 的并发能力是零,它没有任何锁或 CAS 保护。多线程环境下要么自己加锁,要么直接用PriorityBlockingQueuePriorityBlockingQueue的源码就是在 PriorityQueue 外层套了ReentrantLock,核心算法完全复用。

另一个容易忽略的点是:PriorityQueue 的自动扩容会搬移整个数组,如果队列长期维持在大容量状态,反复扩容会导致明显的 GC 压力和内存抖动。我在一个高频交易场景里就遇到过:消息对象以每秒十万的速率进出队列,默认容量 11 的队列几乎每次 offer 都在扩容,后来直接预设容量 65536 才把性能稳下来。

如果你需要大量吞吐又不想扩容,可以考虑自己实现一个环形堆(大根堆/小根堆都行),或者用SizedPriorityQueue这类第三方实现。不过绝大多数业务场景,JDK 自带版本已经足够了。

最后再分享一个小技巧:调试 PriorityQueue 时,别只看toString()的输出,因为它就是数组快照的样子,不是树形结构。你可以写一个简单的递归打印方法,把数组下标和元素值对应成树形图输出,这样观察 siftUp/siftDown 的每一步变化会直观得多。我用这个方法给同事讲过一次堆调整过程,比对着调试器看变量快十倍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询