☰
二叉树与堆:从数据结构原理到JVM内存排查实战
2026/10/3 3:52:05 网站建设 项目流程

二叉树和堆,这两个名字放在一起,真的是数据结构领域最容易让人“眼冒金星”的组合。不少朋友背着“二叉树有三种遍历”“堆是优先队列”的概念头头是道,一打开 IDE 写代码,却反复撞上运行时错误、堆空间不足、栈溢出的连环坑。更迷惑的是,有人在 IDEA 里把进程堆大小调到 8000MB 依然报java.lang.OutOfMemoryError,最后发现根本不是堆的锅。这篇文章我就把这两个数据结构拆开揉碎,从原理讲到底层运行机制,再到高频报错和实操排查,把学习时没人愿意细说的关键点一次讲透。

这内容适合三类人:刚学完数据结构、想真正写对二叉树代码的在校生,工作中被 JVM 堆内存/栈溢出折磨过的开发,以及在刷题时对“搜索二叉树、线索二叉树、树的深度”等进阶概念还比较模糊的人。文里不玩虚的,全部按我实际写代码和排查问题的经验来。

1. 先搞懂二叉树和堆到底是个啥

1.1 从生活场景认识两种结构

我先说一个认知上的大坑:数据结构的“堆”和程序内存里的“堆”是两码事,很多人学到后面就混了。数据结构里的堆,本质上是一种特殊的树,而内存里的堆,是程序运行时动态分配内存的一段区域。这两者只是共享了“堆”这个汉字,关系并不大。把概念先分开,后面才不会越学越糊。

二叉树的直觉,最好用“淘汰赛对阵表”来理解。16 支队伍打淘汰赛,每场比赛淘汰一半,胜者继续向上,最后形成一棵倒过来的树:最上面是总冠军,下面分左右两个半区,每个半区又往下分。这个结构里,每个节点最多有两个“孩子”,所以叫二叉树。真实场景里,文件系统的目录树、表达式求值的语法树、编译器里的 AST,都是二叉树或类二叉树的结构。

堆的直觉,则是“急诊室分诊台”。病人不是按先来后到处理,而是谁病情重谁优先,医生每次从候诊队列里抽出优先级最高的那个。堆就是这个优先级队列背后的结构——它保证你能在O(1)时间看到最大值(或最小值),插入和删除的时间复杂度是O(log n)。所谓“在一堆数据里凑出一个最值”类的需求,堆就是核心工具。

1.2 二叉树的“骨相”:节点、指针与结构定义

二叉树在代码层面的样子非常直白,每个节点就是一块“数据 + 两个指针”的复合体。下面是最常见的定义写法:

typedef struct TreeNode { int val; // 数据域 struct TreeNode *left; // 左孩子指针 struct TreeNode *right; // 右孩子指针 } TreeNode;

用 Java 或者 Python 写也大同小异,无非是把指针换成了引用。这里我建议初学者在纸上亲手画一棵树,然后对着节点把 left、right 指针标出来。很多人写代码报空指针,本质是脑子里没有“指针指向哪里”的画面,盲写代码自然是各种NullPointerException。

二叉树有个重要的结构分类:满二叉树和完全二叉树。满二叉树是所有层都填满,比如总节点数为 7、深度为 3 的那棵树;完全二叉树呢,是除了最后一层,上面每层都满,最后一层的节点从左往右连续排列,中间不能有空位。这个“完全”条件特别关键,因为堆就是一棵完全二叉树。

手写二叉树时还有个容易忽略的点:如果只说“二叉树”,它可以是任意形状,包括退化成一长条的“链表”,这也是搜索二叉树最坏情况下时间复杂度退化到O(n)的根源。后面我会专门说为什么“平衡”这么重要。

1.3 堆:一种“伪装”成数组的完全二叉树

堆听起来很玄,其实它的物理存储就是一个数组,逻辑上却是完全二叉树。怎么做到的?靠的是“下标公式”。假设用数组a存储一棵完全二叉树,根节点放在a[0],那么:

  • 节点a[i]的左孩子是a[2*i+1]
  • 节点a[i]的右孩子是a[2*i+2]
  • 节点a[i]的父节点是a[(i-1)/2]

这三个公式,就是堆的“骨相”。你不需要真的把节点用指针串起来,数组下标天然模拟了父子关系。

在“物理数组、逻辑树”的基础上,堆再规定一条大小关系:如果是大顶堆,每个父节点的值都必须大于等于它的左右孩子;如果是小顶堆,则小于等于。所以你永远能在数组的首元素拿到最大值或最小值。

注意:堆的“完全二叉树”要求决定了它不能用链式存储最顺手的做法来随便插节点,正是因为有了下标公式和“上浮、下沉”调整算法,插入和删除才能在O(log n)时间内完成。

堆排序、top K 问题、优先队列、Dijkstra 最短路中的最小距离取点,底层全是这个结构。现在主流语言里你直接用PriorityQueue就是堆的现成实现,但面试和工作中,理解“上浮”(插入时从下往上调)和“下沉”(删除堆顶时从上往下调)这两个操作仍然非常重要,因为很多题会要求你手写堆。

2. 二叉树的四种遍历:背代码容易,写对很难

2.1 递归前序/中序/后序遍历

先给出一套几乎可以“默写”的递归遍历代码:

// 前序遍历:根 -> 左 -> 右 void preOrder(TreeNode root) { if (root == null) return; System.out.print(root.val + " "); preOrder(root.left); preOrder(root.right); } // 中序遍历:左 -> 根 -> 右 void inOrder(TreeNode root) { if (root == null) return; inOrder(root.left); System.out.print(root.val + " "); inOrder(root.right); } // 后序遍历:左 -> 右 -> 根 void postOrder(TreeNode root) { if (root == null) return; postOrder(root.left); postOrder(root.right); System.out.print(root.val + " "); }

三套代码只有输出语句的位置不同,但含义完全不同。前序遍历常用于“先处理父节点再处理子节点”的场景,比如复制一棵树、序列化;中序遍历在搜索二叉树(BST)里价值极大,因为对 BST 做中序遍历得到的序列一定是有序的;后序遍历适合先回收子树再处理当前节点,比如释放内存或计算树的子树和。

这里我想让初学者别把递归只理解成“函数自己调用自己”。递归真正的运行逻辑是“递下去,归上来”:每次调用都拿着新的参数往下一层走,走到根节点(空节点)之后逐层返回。画出调用栈来,你就明白为什么递归太深会栈溢出。

2.2 层序遍历:队列才是主角

层序遍历又叫广度优先遍历(BFS),它不走递归,而是用一个队列逐层扫描:

void levelOrder(TreeNode root) { if (root == null) return; Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); while (!queue.isEmpty()) { int size = queue.size(); for (int i = 0; i < size; i++) { TreeNode node = queue.poll(); System.out.print(node.val + " "); if (node.left != null) queue.offer(node.left); if (node.right != null) queue.offer(node.right); } System.out.println(); // 每层换行 } }

这段代码里有个很容易忽略的细节:为什么进入循环后要先记录queue.size(),再用for循环分批弹出?因为如果直接while (!queue.isEmpty())去 poll,就无法区分哪些节点属于当前层、哪些属于下一层。借助size快照,我们才能精准做到“按层输出”。

层序遍历的用途非常多:求二叉树的最大深度、最小深度、输出每层最右边的节点、判断一棵树是否是完全二叉树。工程上,很多“广度优先”的算法思路就是从树的层序遍历延伸出去的。

2.3 “写二叉树程序为什么总报运行时错误”的三大元凶

我见过太多初学者在牛客网或 LeetCode 上写二叉树,一运行就报错,而且报错信息千奇百怪。结合经验,十有八九是下面三类问题:

第一类:空指针访问。比如你在递归里写了root.left.left却不先判断root.left是否为空。二叉树递归的默认终止条件必须包含“当前节点为 null 就返回”,任何对子节点的访问,都要先确认子节点不为空。

第二类:递归终止条件写错。有人把终止条件写成if (root.left == null && root.right == null)返回,这看起来没问题,但会漏掉“只有一个孩子”的情况。基础操作的终止条件尽量统一写成if (root == null) return,逻辑最稳。

第三类:递归深度过大导致栈溢出。如果树退化成一条链,比如节点数有 10 万,递归到第 10 万层时,JVM 线程栈(默认约 512KB~1MB)根本扛不住,直接抛StackOverflowError。这种问题的正解不是调大栈空间,而是把递归改成显式的迭代遍历,用栈或队列模拟过程。

提示:如果你在 IDEA 里拉高运行参数后还是报错,先别看堆大小,先看错误信息尾部。它如果写着StackOverflowError,这锅就不该堆内存背。

3. 程序里的堆和栈:两种内存的脾气完全不同

3.1 内存里的堆与数据结构里的堆不是一回事

很多人在学习 JVM 时又把“堆”这个字给搞混了。这里必须说清楚:程序运行时,内存大致分为栈区和堆区(还有其他区域),这里的“栈”是调用栈,记录每个函数调用的局部变量、参数、返回地址;这里的“堆”是动态内存区域,用于存放new出来的对象。它们和数据结构里的“二叉堆”没有包含关系。

我把这两个“堆/栈”用一张表对比一下,方便你对照记忆:

对比维度数据结构里的堆(堆树)内存里的堆(JVM Heap)内存里的栈(JVM Stack)
本质完全二叉树 + 节点值关系运行时动态内存区,存对象每个线程私有的调用栈
操作复杂度插入/删除 O(log n)分配/回收由 GC 负责压栈/弹栈 O(1)
是否程序可控通过算法控制通过参数(如 -Xmx)控制大小大小固定,默认较小
常见故障无OutOfMemoryError: Java heap spaceStackOverflowError

“堆和栈”也经常在操作系统、编译原理、JVM 面试题里被放到一起问。最简单粗暴的理解:栈内存小但快,函数调用结束自动释放;堆内存大但需要手动释放(或依赖垃圾回收),对象生命周期长。

3.2 堆空间不足:OutOfMemoryError 的排查思路

先说你最常在 Java 里遇到的那条报错:java.lang.OutOfMemoryError: Java heap space。它非常直白地告诉你:JVM 堆内存不够了,新对象分配不出来。

按照我平时排查的步骤来:

  1. 先确认是不是真的堆满了。打开jstat -gcutil <pid> 1000观察老年代占用率。如果 Old 区一直 100%,GC 频繁且回收不掉,说明堆里堆积了本该释放的大对象或内存泄漏。
  2. 导堆转储文件分析。在启动参数加-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump.hprof,等下次崩的时候自动拿到 hprof 文件,用 MAT 或 VisualVM 分析哪个对象占了多少内存。
  3. 看代码里的集合缓存。最常见的堆泄露是往静态集合里塞数据从不清理,比如全局HashMap只增不减。

但我也要泼一盆冷水:不少人遇到 OOM 的第一反应就是改-Xmx,把堆调大。堆调大确实能临时续命,但它往往掩盖了真正的问题。如果每分钟产生 500MB 垃圾,你把堆从 2G 调到 8G,只是把崩溃时间点从“今天下午”推迟到“明天上午”。

3.3 进程堆大小调到 8000 还报错?先分清是哪种 OOM

你可能会在 IDEA 里这样配置运行参数:-Xmx8000M,结果应用照样报OutOfMemoryError,而且错误信息不一定是Java heap space。这里尤其要警惕:OutOfMemoryError 是一个家族,不同后缀代表完全不同的内存区域爆了。

我整理一份高频 OOM 后缀速查表:

报错信息后缀实际原因常见参数/工具
Java heap space堆内存不足,对象分配失败-Xmx、MAT、jstat
GC overhead limit exceededGC 一直在运行但回收效果极差,形同死循环通常伴随 heap 接近满,需要查泄漏
Metaspace元空间不足,常因动态生成大量类-XX:MaxMetaspaceSize
unable to create new native thread线程数量过多,或操作系统线程数受限ulimit -u、减少线程创建
Direct buffer memory堆外“直接内存”不足-XX:MaxDirectMemorySize、Netty 等框架

回到热词里的场景:在 IDEA 编译时,有人把进程堆大小调到 8000 依然OutOfMemoryError。编译 OOM 和普通运行 OOM 还不完全一样,IDEA 的编译进程(通常是 Kotlin/Gradle daemon)有独立的 JVM 参数,你在“运行配置”里改的堆大小不一定作用到编译进程上。真正要改的是 IDEA 安装目录下idea64.vmoptions里的-Xmx,或者 Gradle/Maven 的daemonJVM 参数。

另一个关键点是堆外内存。很多框架(Netty、DirectByteBuffer、RocketMQ)采用堆外内存来减少 GC 和拷贝。堆外内存虽然不占 Java 堆,但占的是本机内存。你设置了-Xmx8000M,可能恰好把可以分给堆内的部分拉高,系统物理内存一紧张,后续分配直接失败。排查时看全进程的内存占用,而不是只看一个参数。

注意:-Xmx8000M不是万能的,它只是给堆区设了一个上限。堆外内存、方法区、线程栈各自有独立上限,报错后缀已经告诉你是哪个区域出了问题,先读懂后缀再谈调整参数。

4. 搜索二叉树、线索二叉树与二叉树的深度

4.1 搜索二叉树(BST)的查找、插入与删除

搜索二叉树(Binary Search Tree)的定义并不复杂:对任意节点,左子树所有值都小于它,右子树所有值都大于它。这个性质让查找变成了一个“折半”的过程。

TreeNode searchBST(TreeNode root, int target) { if (root == null || root.val == target) return root; if (target < root.val) return searchBST(root.left, target); return searchBST(root.right, target); }

平均情况下,搜索一次的时间复杂度是O(log n)。但注意“平均”两个字,如果插入顺序是 1、2、3、4、5,BST 会退化成一个只有右孩子的斜树,此时查找复杂度是O(n),和链表没区别。

这也是为什么后面出现了 AVL 树、红黑树这类“平衡”结构。它们的思路都差不多:在插入或删除后,通过旋转让左右子树的高度差保持在一个范围内,尽量避免退化。工作中直接使用现成的TreeMap/TreeSet(红黑树实现)时不用手动旋转,但理解这个“平衡”思想对排查性能问题有实际帮助——如果你自己实现了一个 BST 并在线上出现严重的性能抖动,十有八九就是树不平衡了。

4.2 线索二叉树:把空指针利用起来

线索二叉树可能是在学校课程里“学了就忘”的知识点,但在某些资源极端受限的场景下,它的设计思路很妙。

普通二叉树里,一个具有 n 个节点、2n 个指针域的结构,真正被用到的指针只有 n-1 个(因为每个节点都只有一个父节点,根没有),所以空指针域有2n - (n-1) = n+1个。线索二叉树的想法是:把这些空指针利用起来,让左空指针指向节点的“前驱”,右空指针指向节点的“后继”,从而在遍历时省掉递归和栈。

以前序遍历为例,把每个空指针改造后,遍历过程就是顺着线索一路走,不需要再回退调用栈。代码上需要给节点加两个标志位:

typedef struct ThreadNode { int val; struct ThreadNode *left, *right; int leftTag; // 0表示左孩子,1表示前驱线索 int rightTag; // 0表示右孩子,1表示后继线索 } ThreadNode;

线索二叉树看起来复杂,但核心价值就一句话:用空间换遍历效率。对需要反复遍历的静态树结构(比如游戏地图的场景树)很合适。不过在内存充沛的现代应用里,它更多是作为理解“指针复用”的思想题出现,真正手写的机会不多。

4.3 二叉树的深度:递归一行代码,迭代也不难

二叉树的深度是个很经典的入门算法题,也是最容易检验你有没有真懂递归的题目。

递归写法极其简洁:

int maxDepth(TreeNode root) { if (root == null) return 0; return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1; }

为什么是Math.max(...) + 1?因为一棵树的深度 = 左右子树中较深的那棵的深度 + 当前这一层。这个公式看着短,但它包含了一个完整的“递下去、归上来”过程:一直递归到空节点返回 0,上一层拿到孩子的深度后加 1,层层返回,最终得到根节点的深度。

迭代写法用层序遍历也能做,每一层加 1,代码我在 2.2 节已经讲过,原理相同。面试里还会出现“最小深度”的变体:minDepth(root)不能简单把max换成min,因为如果某个节点只有左子树没有右子树,那棵“不存在的右子树”深度是 0,直接取 min 会得到 0,这是错的。正确做法是分别判断左右孩子是否为空。

这类细节就是区分“背过答案”和“理解原理”的分水岭。

5. 高频实战问题:凑数、堆外内存和栈溢出

5.1 “在一堆数据里凑出一个数”的解法思路

热词里有句“在一堆数据里凑出一个数”,这在算法题里几乎是一个大类,代表作是两数之和(Two Sum):给定数组和一个目标值,找两个数让它们的和等于目标值。

最直观的解法是双重循环,O(n^2),数据一大就超时。正确的入门优化是用哈希表:

int[] twoSum(int[] nums, int target) { Map<Integer, Integer> map = new HashMap<>(); for (int i = 0; i < nums.length; i++) { int need = target - nums[i]; if (map.containsKey(need)) { return new int[]{map.get(need), i}; } map.put(nums[i], i); } return new int[]{-1, -1}; }

这里的核心思想是“用空间换时间”:把已经访问过的值和下标存起来,每次只要 O(1) 查一下需要的另一半是否已经存在。

如果是“三数之和”,就不能只用哈希表硬凑了,更常见的做法是先排序,再用双指针夹逼。如果是“在一堆数里找第 K 大的数”,则用堆:维护一个大小为 K 的小顶堆,遍历完数组后堆顶就是第 K 大。这个场景,正好把前面讲的堆和数据结构的“凑数”结合起来了。

还看到热词里有“数数小木块”的题目,大概是在墙角堆放正方体小木块,要在限定内存和时间下计数或还原结构。这类三维堆积问题,往往要转化为“从底部逐层向上拆解”的思路,本质上和二叉树里“先处理子树再合并到父节点”是同一个套路:大问题拆成结构相同的子问题,找到递归边界。

5.2 堆外内存:被忽略的“隐形成本”

堆外内存(off-heap memory)这个词,很多没接触过网络编程的开发者会一脸茫然。简单说,它是指不完全受 JVM 垃圾回收管理的本机内存,最常见的是DirectByteBuffer支持的直接内存(Direct Memory),以及通过Unsafe分配的本地内存。

为什么需要堆外内存?主要原因是性能。在用 Netty 做网络通信时,数据会从 Socket 读到堆外缓冲区,再直接放入 I/O 栈处理,整个过程不用在 Java 堆和本地内存之间反复拷贝。如果全部走堆内,数据要“内核 -> 堆外 -> 堆内 -> 堆外”来回倒腾,性能损耗肉眼可见。

但这个设计有个隐患:堆外内存不计入-Xmx。所以经常出现这种情况——JVM 堆只用了 2GB,但进程整体已经占了 8GB 物理内存,最后系统直接 OOM 或者被内核杀掉。排查时需要先判断框架有没有使用堆外内存,再看-XX:MaxDirectMemorySize是否设置合理。默认情况下这个值和堆上限有关,但在容器化部署中经常踩坑。

实操心得:如果你在用 Netty、Cassandra、RocketMQ 这类堆外内存大户,监控不要只看 JVM Heap,必须把容器/进程的内存使用量也纳入监控。物理内存被打爆时,GC 日志往往还显示 Heap 很健康。

5.3 Windows 11 下栈溢出的排查与解决

StackOverflowError在 Windows 11 下并不特殊,毕竟 JVM 是跨平台的,但 Windows 的线程栈默认值确实会让某些在 Linux 上跑得好好的程序先崩。典型场景是递归深度太大,这个深度不一定需要多大,一条 10 万层递归链表树就能把栈打穿。

解决思路有个优先级顺序:

  1. 检查无限递归。最常见的原因是递归参数没有向边界靠近,比如深度优先搜索时反复访问同一个节点。先加日志观察递归入参,确认是在收敛还是在死循环。
  2. 把递归改写成迭代。用显式的Stack<TreeNode>模拟递归过程。二叉树前序、中序、后序遍历都能改成迭代,网上模板很多,我建议至少能手写出来一个版本。
  3. 谨慎调整栈大小。JVM 可以用-Xss设置线程栈大小,比如-Xss2m。但线程栈是每个线程各占一份,几百上千个线程同时存在时,这个参数会被放大成巨大的内存开销。不要上来就调大,先用前两个方法消掉“不该递归那么深”的根因。
  4. 考虑尾递归优化或换实现方案。JVM 目前没有完全可靠的尾递归优化,所以与其依赖编译器,不如重构算法,用循环或动态规划替代深层递归。

Windows 11 上偶尔还会因为安全软件、系统新版线程栈随机化等因素,让同一段代码在不同机器上表现截然不同。如果程序本身已经压到栈的临界值,多加点栈大小可能只是勉强过关,建议代码层面留出足够余量。

6. 我的实操心得与建议

最后聊点我个人的体感。二叉树和堆这些数据结构,越学越发现关键不在“背结构”,而在“画图”和“空指针思维”。我每次写二叉树递归前,都会先在草稿纸上画一棵小树,然后模拟一次完整的递归过程,把每一层调用栈上发生了什么写出来。这个过程看起来慢,但它能省下后面调试报错的大把时间。

调试时我常用的一个小技巧:在递归函数里加一个depth参数,打印时缩进显示。看到缩进就知道当前递归到第几层,左右子树是否正确,哪个分支访问了空指针也能立刻定位。

void debugInOrder(TreeNode root, int depth) { if (root == null) { System.out.println(" ".repeat(depth) + "null"); return; } System.out.println(" ".repeat(depth) + root.val); debugInOrder(root.left, depth + 1); debugInOrder(root.right, depth + 1); }

至于堆,我建议你亲手用数组实现一次小顶堆,完成“上浮、下沉、堆排序”三件套。做完之后,再去用PriorityQueue就会有一种“我在操控底层的既视感”而不是纯粹的 API 调用。工作中真正需要你手写堆的场景不多,但“优先队列解决 top K”“堆解决中位数流”这两类问题,面试和实战都经常出现,值得多练几遍。

最后说一个扩展建议:学完二叉树,可以立刻去刷“二叉树的最大深度、最小深度、翻转二叉树、判断对称二叉树”这四道题,它们能在一周内帮你建立递归的感觉。然后把这个感觉迁移到更多树上,比如二叉搜索树、平衡树、堆。数据结构这条路没有捷径,但每多画一棵树、多调一次空指针,后面的坑就会少一个。

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

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

立即咨询