深入理解堆:从二叉堆到PriorityQueue的底层原理与工程实践
1. 先把“堆”这回事彻底掰开揉碎聊PriorityQueue之前必须先搞清楚一个特别容易被搞混的点日常说的“堆”和Java里那个java.util.PriorityQueue和C报错里“堆已损坏”的“堆”以及JVM里“堆外内存”的“堆”到底是不是同一个东西。我见过太多人拿着PriorityQueue用得很溜但问他“堆在内存里到底是什么结构”就直接卡壳。这里面有历史原因也有翻译原因但最核心的是堆在不同的上下文里至少有三副面孔。第一副面孔是堆作为数据结构。也就是二叉堆、小根堆、大根堆这些东西。它本质上是一棵完全二叉树但用数组来存。为什么叫“堆”因为它像一堆东西堆在一起父节点和子节点之间有明确的大小关系但兄弟之间没有。这个“堆”是PriorityQueue的底层灵魂。第二副面孔是堆作为运行时内存区域。C/C里动态分配的内存就叫堆内存和栈内存相对Java里也有堆Heap和栈Stack对象基本都在堆上分配。这个“堆”和数据结构里的堆其实没啥关系只是碰巧都叫“堆”。很多人纠结“PriorityQueue里的堆是不是就是内存里那个堆”答案是不是。PriorityQueue里的堆是逻辑结构内存堆是内存管理概念。第三副面孔是堆作为资源池的概念。比如线程池、对象池、连接池本质上都是一堆资源放在一个容器里等你取用。还有现在很热门的“堆外内存”指的是绕开JVM堆管理的那部分直接内存。这些和“PriorityQueue”关系不大但如果你在项目里调优时遇到“Direct buffer memory”或者“OutOfMemoryError: GC overhead limit exceeded”就绕不开它。那我这篇“续集”到底讲什么我假设你已经看过我之前写的基本用法知道add、poll、peek怎么调。这篇我们往深走二叉堆的底层逻辑、PriorityQueue的扩容机制、TopK问题怎么用堆最优解、以及堆在真实工程里的各种坑——包括C“堆已损坏”、编译器提示堆空间不足、甚至HBM堆叠封装和充电堆全矩阵半矩阵方案。你会发现堆这个思想一旦吃透跨语言、跨行业都通用。1.1 堆、栈、PriorityQueue先分清逻辑结构和物理结构我教新手的时候喜欢先问一个问题“你在main函数里new一个对象这个对象在内存里长什么样”大多数人会回答“在堆里”其实只说对了一半。对象实体确实在堆里但对象的引用在栈里对象头的mark word、类指针、字段值这些还要看具体JVM实现。更重要的是堆里对象的分配不是按你new的顺序紧密排列的而是有内存对齐、有分代、有GC搬移。这个“堆”是物理结构。而PriorityQueue里的“堆”是纯逻辑结构。它底层就一个数组transient Object[] queue然后用一系列下标公式来模拟完全二叉树left 2*i1right 2*i2parent (i-1)/2。你在逻辑上觉得它是一棵树实际上内存里就是一段连续的数组。这种“用数组模拟树”的方法好处是缓存友好遍历快坏处是一旦插入或删除导致结构调整元素搬运成本高。所以如果你面试时问“PriorityQueue是数组还是链表”标准答案是底层是数组逻辑上是二叉堆。这个点很多人摔过我当年也被绕晕过因为“优先队列”这个名字太具误导性听上去像队列其实它根本不是一个“FIFO”的结构。至于栈和堆的区别我也顺手说清楚。栈是由操作系统自动分配和释放存局部变量、函数调用帧空间小但极快堆是动态分配需要手动申请或GC回收空间大但慢。在多线程环境里每个线程有自己的栈但共享堆。这也是为什么线程安全操作堆对象必须加锁而局部变量天然线程私有。1.2 二叉堆为什么叫“堆”以及大根堆小根堆的区别二叉堆的定义并不复杂首先它必须是一棵完全二叉树除了最后一层其他层都是满的最后一层从左到右填充不能有空档。其次它满足堆序性如果是小根堆父节点永远不大于它的子节点如果是大根堆父节点永远不小于它的子节点。注意没有要求左子节点和右子节点之间的大小关系这和二叉搜索树有本质区别。为什么叫“堆”我个人的理解是它就像你把一堆石头从下往上码小石头在顶大石头托底这就是小根堆反过来就是大根堆。你从顶上拿走一块最小的石头然后从下面抽块石头补上来再调整一下依然保持“上面小下面大”。整个过程非常像“倒腾一堆东西”。为什么在工程里这么爱用二叉堆因为它的核心操作复杂度都压在O(log n)。插入一个元素、取走最值、删除任意元素都能在对数时间内完成。不像数组取最值要O(n)也不像有序链表插入要O(n)。而且它不需要额外指针存储数组天然紧凑对GC和CPU缓存都友好。PriorityQueue默认是小根堆也就是peek()和poll()返回最小的元素。这个“最小”由对象的自然顺序或传入的Comparator决定。2. PriorityQueue核心机制拆解不只是add和poll那么简单2.1 源码里的上滤和下滤siftUp和siftDown如果你翻开JDK源码PriorityQueue最核心的私有方法就两个siftUp和siftDown。考得多的也是这两个。siftUp用于上滤典型场景是offer(e)。往堆底部数组末尾插入新元素然后不断和父节点比较如果小于父节点就交换直到满足堆序性。这个过程最坏要爬log n层所以时间复杂度O(log n)。siftDown用于下滤典型场景是poll()。取出堆顶元素后把数组末尾的元素拿到堆顶然后不断和左右子节点中较小的那个比较如果大于它就下沉直到找到合适位置。这个操作也是O(log n)。还有一个被很多人忽略的方法heapify。如果你用已有的集合构造PriorityQueue比如new PriorityQueue(list)它会调用heapify来建堆。这里有个关键点heapify的复杂度是O(n)不是O(n log n)。做这个操作时只需要从最后一个非叶子节点开始倒着做siftDown就能在O(n)时间完成建堆。很多人面试写堆排序时建堆直接一个个offer复杂度就退化成了O(n log n)虽然最终堆排序整体还是O(n log n)但建堆阶段就输给了标准做法。我用一个小例子演示一下siftDown的思路。假设数组是[3, 7, 2, 9, 5, 8, 1]要建小根堆。最后一个非叶子节点是下标(len-2)/2也就是(7-2)/22对应元素2。对下标2做下滤比较左右子节点8和11更小2和1交换得到[3, 7, 1, 9, 5, 8, 2]。接着对下标1做下滤7的左右子节点是9和55更小7和5交换得到[3, 5, 1, 9, 7, 8, 2]。最后对下标0做下滤3的左右子节点是5和11更小3和1交换得到[1, 5, 3, 9, 7, 8, 2]。还没完3的下标变成了2再继续比较左右子节点8和22更小3和2交换最终变成[1, 5, 2, 9, 7, 8, 3]。这就是一个小根堆。整个过程你可以自己手写一遍比看十遍源码都有用。2.2 扩容、比较器与自定义对象入队细节都在这些地方PriorityQueue的扩容机制和ArrayList很像默认初始容量是11不够了就增长。JDK8里的逻辑是如果旧容量小于64就翻倍再加2否则扩容50%。它用的是Arrays.copyOf整体搬迁数组。有一个很容易踩的坑如果你预估数据量很大最好在构造时指定初始容量否则会有多次扩容拷贝浪费性能。再一个坑是迭代顺序不等于堆序。PriorityQueue的iterator()不会按优先级顺序输出元素。你要是写上for (Integer x : pq)拿到的顺序基本是乱的。想要有序取出得用poll()循环。这个坑在调试时特别迷惑人你明明看到数组里有小有大的排列但迭代却不是从小到大。自定义对象入队时两种方式实现排序规则一是类实现Comparable接口二是在PriorityQueue构造器里传Comparator。我建议能用Comparator就用Comparator因为它不侵入业务类方便切换多种排序维度。还有一点PriorityQueue不是线程安全的多线程写入必须加锁或者用PriorityBlockingQueue。但PriorityBlockingQueue的迭代器依然是弱一致的不要指望它帮你解决并发遍历下的数据一致性。提示PriorityQueue里元素不允许为null否则抛NullPointerException。这个坑比你想的常见尤其在从外部接口拿数据后直接入队时。3. 堆的经典应用场景与实战案例从TopK到任务调度3.1 用PriorityQueue解TopK问题实战代码直接抄TopK是堆最经典的应用场景。比如海量日志里找出出现次数最多的K个IP电商里找出销量最高的K个商品。如果用全排序时间复杂度O(n log n)内存还要装下所有数据用堆只需要维护一个大小为K的小根堆内存O(K)时间O(n log K)。这就是“小根堆求最大TopK大根堆求最小TopK”的套路。为什么求最大TopK要用小根堆因为你只需要记住“目前最大的K个里最小的那个”。每来一个新元素如果比堆顶大就把堆顶替换掉然后下沉调整。这样堆顶永远是这K个里的“门槛”。举个例子统计词频后找前K个高频词MapString, Integer freq new HashMap(); // ... 统计每个词的频率 ... PriorityQueueString heap new PriorityQueue( (a, b) - Integer.compare(freq.get(a), freq.get(b)) // 小根堆按频率升序 ); for (String word : freq.keySet()) { if (heap.size() K) { heap.offer(word); } else if (freq.get(word) freq.get(heap.peek())) { heap.poll(); heap.offer(word); } } // 结果在堆中倒序输出 String[] topK new String[K]; for (int i K - 1; i 0; i--) { topK[i] heap.poll(); }注意我写的比较器是小根堆poll出来的是门槛最小频率最后倒序输出变成从大到小。如果你要原样输出“前K个高频词”直接把堆里的元素取出来再反转一下就行。如果数据量再大大到单机内存都装不下所有词频那么就得上分治或者外部排序。堆的思路仍然有效先把数据分片每片算出局部TopK再用一个大小为K的堆合并这些局部TopK。这个“先用小堆算局部再用大堆算整体”的模式在MapReduce里也经常出现本质就是堆的归并。3.2 定时任务与事件驱动里的堆调度堆在系统设计里另一个常见位置是定时任务调度器。比如Java的DelayQueue、ScheduledThreadPoolExecutor底层都用到了类似堆的结构来管理即将到期的任务。为什么因为调度的核心需求是“每次都快速拿到最近需要执行的任务”这不就是peek()吗用数组按时间排序也能做但插入新任务要O(n)用红黑树也可以但实现复杂且缓存不友好。二叉堆在这个场景下插入、取最值都是log n是性价比最优的选择。我之前在项目里实现过一个简约版的延时消息队列生产者把Task丢进来消费者循环peek和sleep时间到了就poll。核心思路就是while (true) { Task task queue.peek(); if (task null) { Thread.sleep(100); continue; } long wait task.executeAt - System.currentTimeMillis(); if (wait 0) { queue.poll(); executor.execute(task); } else { Thread.sleep(Math.min(wait, 100)); } }这里queue用的是DelayQueue它内部持有一个PriorityQueue。每次取堆顶任务判断执行时间是否到达没到就sleep一小段再继续。整体代码很简单但没有堆结构支撑任务一多性能就会崩。你可能会问用时间轮不是更好吗对时间轮在某些场景确实更优但实现要复杂得多而且不擅长处理“延迟时间跨度大但任务稀疏”的场景。二叉堆是通用性和简单性都很好的中间选择。3.3 堆排序基于堆的“最朴素”排序算法堆排序就是反复做“建堆 取堆顶”的过程。你先把数组建成大根堆然后把堆顶和末尾元素交换相当于把最大值排到末尾接着缩小堆范围再做下滤。重复n-1次数组就排好序了。时间复杂度稳定O(n log n)空间复杂度O(1)是一种原地排序。但它不稳定相同元素的相对顺序会变所以工程上一般优先用快速排序或归并排序。可面试官就爱让你手写堆排序因为能一口气考察你对堆、数组、递归的理解。写堆排序最容易错的地方是边界条件。我建议你记住建堆时从parent (len - 2) / 2开始倒着下滤排序交换后下滤时heapSize减一别越界。这些细节光看懂了没用必须自己抄着写一遍错两次就记住了。4. 堆使用中的“坑”从堆已损坏到堆外内存4.1 C里“堆已损坏”到底意味着什么聊完数据结构里的堆再来看看运行时层面的堆。很多人写C时Debug模式下弹出一个“HEAP CORRUPTION DETECTED”或者“堆已损坏”瞬间头皮发麻。这个堆指的是进程堆内存不是你PriorityQueue那个二叉堆。但因为它叫同一个名字我经常被拉去排查这类问题。“堆已损坏”本质上是一种内存破坏意思是某个地址区域的内存元数据被写乱了。常见诱因包括数组越界写、use-after-free、重复delete、释放栈内存而不是堆内存、缓冲区溢出等。比如int* arr new int[10]; for (int i 0; i 10; i) { arr[i] i; // i10时越界写 } delete[] arr;这段代码在Release下可能“碰巧”不崩但在Debug下极大概率触发堆完整性检查因为你在已分配的10个int之外多写了4个字节而这4个字节恰好是堆管理器记录块大小的头部信息。于是等到delete[]时堆管理器一校验“哟头部被改了”直接报堆已损坏。排查这类问题我建议按这个顺序来先用AddressSanitizer或者valgrind跑一遍能直接定位到越界写的那一行如果没有这类工具就把可疑的memset、memcpy、循环边界、数组下标全部过一遍脑子。最隐蔽的一类问题是跨线程同时改写同一块堆内存这种必须靠数据竞争检测工具比如ThreadSanitizer。4.2 堆外内存与编译器的堆空间不足另一个“堆”是JVM里的堆。Java程序员通常不直接管堆但架不住GC调优时碰到“堆空间不足”。比如报错java.lang.OutOfMemoryError: Java heap space一般就是堆太小或者有对象被长期引用无法释放。这个很好理解扩容堆就行-Xmx2g改成-Xmx4g。但如果改完之后还经常Full GC那就要排查是不是有对象泄漏。比“堆空间不足”更绕的是堆外内存。这个概念在Netty、Kafka客户端、Elasticsearch里经常出现。堆外内存是JVM直接通过DirectByteBuffer或Unsafe.allocateMemory分配的本地内存不经过GC管理受操作系统总内存限制。为什么用堆外最直接的原因是减少拷贝网络IO时如果数据在堆内存需要从JVM堆复制一份到操作系统缓冲区如果直接在堆外分配就能省掉这次拷贝提升性能。代价是你得自己管内存释放。堆外内存一旦泄漏表现很诡异free -m看到进程内存占用很高但-Xmx并不是特别大GC也很正常却时不时报OutOfMemoryError: Direct buffer memory。这个很可能是你频繁分配DirectByteBuffer却没有正确释放引用或者-XX:MaxDirectMemorySize设置太小。排查堆外内存的思路是启用-XX:NativeMemoryTrackingsummary用jcmd查看Native Memory分布如果是Netty重点看PooledByteBufAllocator的缓存池有没有被滥用。我还被问过“编译器的堆空间不足”。这个出现在编译阶段比如Maven/Gradle报Java heap space其实也是JVM堆太小只不过做编译的进程是编译器。解决办法很简单给构建工具加大堆内存。Maven是MAVEN_OPTS-Xmx2gGradle是在gradle.properties里配org.gradle.jvmargs-Xmx2g。但如果你写的代码有变态的泛型嵌套偶尔也会因为编译期符号表爆炸导致堆不足这种就得靠简化泛型或拆分模块。4.3 顺带聊聊“UAF”和堆安全别再谈虎色变热词里有个“redis cluster bus 远程堆 uaf 漏洞”UAF全称是use-after-free也就是释放后使用。这类漏洞本质上也是“堆”相关一块内存被释放后指针仍然存在且仍被使用攻击者就可能篡改权限或执行代码。虽然我们写业务代码不太会遇到恶意利用但理解UAF对排查崩溃、安全问题很有帮助。在Java里因为GC负责回收内存你基本不用关心UAF。但在C/C或者JNI调用native代码时UAF就可能出现。典型场景是一个对象被delete之后另一个线程还持有着它的指针。排查手段也很俗套用共享指针代替裸指针、在释放后把指针置空、上asan工具跑一遍。写代码时心存敬畏尽量减少裸指针跨线程流动比任何工具都有效。5. 堆思维的跨界联想从堆叠封装到充电堆5.1 HBM里的DRAM堆叠与“堆”式结构接着来一个跨界联想。热词里有个“HBM中的DRAM堆叠层封装技术”这个话题和数据结构里的堆八竿子打不着但它名字里也有“堆叠”。HBMHigh Bandwidth Memory是AI芯片里常用的一种高带宽内存它通过TSV硅通孔把多颗DRAM芯片垂直堆叠起来再和GPU/CPU封装在一起。这个“堆叠”其实更像字面意思的“堆东西”一层一层往上摞下面有base die做I/O控制。我们为什么关心这个因为堆的思想无处不在。在系统设计里PriorityQueue是“按优先级存取元素”的抽象在半导体工程里HBM是“按层垂直整合存储”的抽象。两者都解决了同一个问题在有限面积/空间里如何提升容量和效率。你越能快速抓住这类抽象读任何技术文章都越容易。如果你对HBM感兴趣它的关键技术点包括TSV的数量和间距、每层DRAM的厚度、堆叠层数带来的散热和翘曲问题、以及测试良率。这些和程序员平时写的堆代码关系不大但拓宽视野后你再看到“堆外内存”或“堆已损坏”时会更容易接受一件事**“堆”从来不是一个单一概念它是一个跨越软件、硬件、数据结构的核心思想。5.2 充电堆“全矩阵/半矩阵”与资源调度再看另一个热词充电堆全矩阵与半矩阵方案。这个来自充电桩行业。简单说充电堆就是一堆充电模块集中管理按需分配给多个充电车位。所谓“全矩阵”是每个充电模块都能通过开关矩阵连通到任意一把充电枪调度灵活但成本高、控制复杂。“半矩阵”则是模块分组组内可以任意调度组间不可或有限调度成本和灵活性折中。这个和PriorityQueue有什么关系你看充电堆本质上是多资源多调度单元的分配问题。如果你想保证“尽量给紧急车辆优先充电同时不浪费闲置模块”那就得像维护堆一样维护一个“优先级队列”紧急车辆插队普通车辆排队空闲模块实时分配。数据结构不一定直接用PriorityQueue但思想是相通的。这个例子再次说明堆这种“每次高效获取最值”的能力是几乎所有调度系统的底层刚需。不管是CPU线程调度、Kafka分区分配还是充电桩模块分配你心里都要有一个“优先级队列”的模型。我在项目里一旦遇到“要尽快取最小/最大”第一反应就是堆。6. 进阶手写一个小根堆彻底根治不懂装懂6.1 从数组到堆的建堆过程亲手实现一遍纸上得来终觉浅。我建议你不管用什么语言都亲手实现一个泛型小根堆。我们以Java为例核心字段就三个Object[] data、int size、Comparator comparator。建堆的方式有两种一种是从空堆开始逐个offer适合动态插入另一种是给你一个数组直接heapify适合静态初始化。我推荐你先把heapify写出来因为它是理解堆排序的钥匙。private void heapify() { for (int i (size - 2) / 2; i 0; i--) { siftDown(i); } }siftDown的逻辑要特别注意下标计算和边界判断private void siftDown(int index) { while (2 * index 1 size) { int left 2 * index 1; int right left 1; int small left; if (right size compare(data[right], data[left]) 0) { small right; } if (compare(data[small], data[index]) 0) { break; } swap(index, small); index small; } }这个写法里最关键的是循环退出条件如果子节点都不再小于当前节点就直接break否则一路下沉到叶子。很多人写错是忘了在整棵子树有序时提前退出导致多余比较。对应地offer插入时用siftUpprivate void siftUp(int index) { while (index 0) { int parent (index - 1) / 2; if (compare(data[index], data[parent]) 0) { break; } swap(index, parent); index parent; } }siftUp比siftDown简单因为它只需要比较父节点不需要找两个子节点的较优者。但一不小心也会把父节点下标算错。我建议你在纸上画一个完全二叉树给每个节点标下标亲手动一遍插入和删除比背十遍代码管用。6.2 堆排序与复杂度分析让“下滤”成为肌肉记忆写好堆之后堆排序就顺理成章了。先heapify然后循环size-1次交换堆顶和最后一个元素size--再对堆顶做siftDown。每次siftDown都是O(log n)n次就是O(n log n)。空间复杂度O(1)前提是你在原数组上操作。我自己写的时候有个心得把size和数组长度分开管理。堆排序开始前size等于数组长度排序过程中不断减小size但数组物理长度不变。所有siftDown操作只能在size范围内进行。这个细节不处理干净就会出现排序后数组尾部多出旧元素混淆排序结果的问题。复杂度分析也是面试常考点。建堆heapify的复杂度为什么是O(n)这个推导不难设树高h第k层有约2^k个节点每个节点下滤的最大深度是h-k。总工作量是sum_{k0}^{h} 2^k * (h-k)这个和等于n - log2(n1)所以是O(n)。你不需要背公式但至少要知道不要用n次offer来建堆那是O(n log n)而不是O(n)。至于TopK的时间复杂度扫描n个元素每次和堆顶比较并可能替换由于堆大小K每个元素最坏触发一次siftDown所以是O(n log K)。当K远小于n时这个性能优势非常明显。我曾经在一个千亿级日志系统中用PriorityQueue配合分片统计把TopK统计从MapReduce批处理改成单机内存计算内存占用不到200MB速度从小时级降到分钟级。堆的价值不在理论而在实战里实打实的资源节约。注意手写堆时一定要考虑容量扩容。数组满了要扩容通常grow()方法用位运算快速计算新容量。但我更建议在项目里直接用现成的PriorityQueue除非你在面试或学习泛型、数据结构原理。写到这里我其实还想再啰嗦一句PriorityQueue看起来简单但真到了线上环境它带来的问题是“隐形的”。你很少遇到它直接报错但会遇到因为比较器写错导致排序结果不对因为扩容导致GC压力大因为迭代顺序和预期不符导致你半夜排查数据对不上。这些都是我踩过的坑。如果你读完这篇能亲手把二叉堆实现出来再把源码里siftUp和siftDown对照着看一遍那下次再有人说“PriorityQueue不是一个队列吧”你就可以笑着把数组下标公式甩过去左孩子是2i1右孩子是2i2不信你自己画棵树。