ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

堆的三种真面目:数据结构、内存管理与数据库堆表

堆的三种真面目:数据结构、内存管理与数据库堆表 堆Heap大概是计算机世界里被误用得最狠的名词之一。你说自己是写业务代码的碰到“堆空间不足”第一个想到的是启动参数里加个-Xmx或者--max-old-space-size你说自己是学算法的打开《算法导论》翻到第六章看到的却是那个能把最大元素快速挤到数组前面的数据结构你要是再点开数据库文档Oracle 的“堆组织表”、MySQL 8.0 里被砍掉的“堆表”又完全是另一码事。这三个场景里都写着 Heap底层逻辑没一个相同的但很多入门教程偏偏喜欢混在一起讲把人绕得云里雾里。这篇文章我就以亲身踩坑的经验把“堆”的这几副面孔全部拆开讲清楚。从数据结构里的二叉堆到 V8 和 JVM 的内存堆再到 Node.js 那句让人头疼的fatal error: ineffective mark-compacts near heap limit allocation failed以及 Win11 下堆栈溢出的排查、数据库堆表的取舍最后顺带聊聊“小土堆 Pytorch 学习笔记”为什么会被并到“堆”的热搜里。适合被内存报错折磨过的后端开发、做算法题卡在优先队列上的同学以及刚入坑想看明白底层原理的初学者。1. 数据结构里的堆看似“一棵树”实则是优先队列的语法糖1.1 五个性质完全约束住的“最小模型”很多人背过定义堆是一棵完全二叉树并且父节点的值不小于或不大于子节点的值。但背下来并不等于理解我建议换个角度去看——堆的本质其实是一个“随时能吐出最大最小元素的容器”树的结构只是为了让它高效运作而采取的物理形态。先用大白话定义它。二叉堆满足五个硬性条件结构上必须是完全二叉树也就是除了最后一层上面每层都是满的最后一层的节点全部靠左排列父节点 子节点这叫大顶堆父节点 子节点这叫小顶堆堆顶元素永远是全局最大最小值插入元素后要重新调整让上述性质保持成立删除元素时只能“删堆顶”删完后同样要调整。很多人第一次看到“堆是一棵完全二叉树”这个描述时会想那为什么不直接用链表式二叉树非要拿数组存这就是关键点——数组才是堆真正的“亲爹”。因为完全二叉树的节点位置是连续且可索引的我们可以把整棵树压进一个一维数组里用数学下标代替左右指针。父节点下标为i时左孩子是2 * i 1右孩子是2 * i 2父节点是(i - 1) / 2。就这么三个公式省掉了全部指针开销。1.2 “上浮”“下沉”两个动作撑起全部操作堆的所有操作拆到最底层其实就是两个动作上浮swim和下沉sink。我当年看《算法》那本书时Robert Sedgewick 把这俩动作讲得极清楚这里我再用自己的话复述一遍。插入操作把新元素扔到数组末尾也就是完全二叉树的最后一个空位然后让它和父节点比大小。如果它比父节点大大顶堆场景就交换位置再继续向上比直到它找到了属于自己的位置。这个过程叫上浮时间复杂度是 O(log n)因为树的高度就是 log n 级别。private void swim(int k) { while (k 1 less(k / 2, k)) { swap(k, k / 2); k k / 2; } }删除堆顶操作把堆顶元素和数组最后一个元素互换然后删除末尾元素——此时最大元素被安全拿走而新堆顶是个“残次品”需要它和自己的两个子节点比大小挑出较大的那个换上来自己再继续往下沉这个过程叫下沉同样 O(log n)。private void sink(int k, int n) { while (2 * k n) { int j 2 * k; if (j n less(j, j 1)) j; if (!less(k, j)) break; swap(k, j); k j; } }这两个动作为什么重要因为堆排序的原理就是从最后一个非叶子节点开始对每个节点执行下沉把整个数组调成堆结构然后反复“交换堆顶 下沉”把最大值挪到数组末尾。你不需要真的写一个递归版二叉树结构只需要一个数组和这两个方法。1.3 堆排序之外的现实应用TopK、定时器与图算法很多初学者以为堆就是为堆排序准备的看到堆排序本身不稳定、平均效率也不如快排就觉得这东西没用。这是典型的误解。堆真正的价值在于“动态维护最值”而不是排序。最经典的应用是 TopK 问题。比如从一亿个用户 ID 中找出消费额最高的 100 个直接排序需要 O(n log n)但用一个大小为 100 的小顶堆可以做到 O(n log K)。具体思路是堆里始终维护当前最大的 K 个元素新来的元素如果比堆顶大就替换堆顶再下沉这样堆顶永远是这 K 个里的“守门员”。还有一个非常务实的场景是定时器。一个进程里可能有几千个定时任务需要触发最简单的做法是每来一个任务就丢进一个小顶堆堆顶就是下一个该触发的任务触发时间到了就弹出去。这也是很多语言标准库 PriorityQueue 的底层实现方式。图算法里 Dijkstra 的最短路径优化版本同样依赖优先队列每次取出距离最近的未处理节点。我在实际工程里最常用它来跑实时排行榜的“近似 TopN”——比如网关要统计过去一分钟调用量最大的十个接口每次请求进来往一个小顶堆里塞数据定期取堆顶。不用引入 Redis ZSet也不需要全量排序一台普通机器就能扛住很高的 QPS。2. 运行时内存里的堆JS/Java 程序员绕不开的“堆空间不足”2.1 堆与栈同住一个地址空间命运却截然不同聊完数据结构再说说运行时内存里的堆。这里要先纠正一个最常见的概念混淆内存里的“堆”和“栈”是进程虚拟地址空间中的两个区域它们的分配方式完全不同。栈是函数调用产生的活动记录栈帧存放地。每个函数被调用时局部变量、参数、返回地址都被压进栈帧里函数返回时整块弹出。栈的特点是自动分配、自动释放速度快但空间很小——Linux 下默认通常是 8MBWindows 下默认 1MB。堆则是程序运行时动态申请的一块大内存区域。它的特征是手动申请、生命周期不固定想什么时候分配就什么时候分配想活多久就活多久所以 JVM、V8 这类运行时环境才需要引入垃圾回收机制来管理它。两者的关系有点像公共厨房和办公室的储物柜。栈是桌上那块临时放菜的小案板用完就清空堆则是后厨的大冷库你放进去的东西理论上可以一直存着但需要定期有人来盘点清理。你往案板上堆的东西太多会翻栈溢出往冷库里放的东西太多会塞不下堆空间不足但两者完全不是一个问题。2.2 V8 眼中的堆新生代、老生代与 GC 周期如果你写 Node.js内存模型的默认值经常坑人。V8 引擎把 JavaScript 对象分配在堆上这个堆又被划分为两个主要区域新生代New Space和老生代Old Space。新生代是一个块很小、频率很高的区域用来存放刚创建的对象。这里使用 Scavenger 算法把一块区域划分为两半对象先放 from 区满了就把存活对象复制到 to 区。复制过程会把大对象和“存活很久”的对象晋升到老生代。老生代就是那个“大冷库”V8 在这里使用标记-清除Mark-Sweep和标记-压缩Mark-Compact两种策略。Mark-Sweep 找到不再被引用的对象并把它们的内存标记为可回收Mark-Compact 则进一步把存活对象压缩合并解决内存碎片问题。Node.js 64 位版本中老生代的默认上限大约是 1.4GB 到 2GB具体数值会根据物理内存自动计算。这个上限并不是“最大可用内存”而是 V8 给堆设定的一个“警戒线”——一旦堆的使用量接近上限GC 开始拼命工作如果 GC 之后空间仍然不够分配新对象就会触发那句著名报错。2.3 fatal error: ineffective mark-compacts 到底在说什么热搜词里那条fatal error: ineffective mark-compacts near heap limit allocation failed - j完整报错通常是--- Last few GCs --- [56156:0x103000000] 2196091 ms: Mark-sweep (reduce) 2048.0 - 2047.7 (2052.5) MB, 10.3 / 0.0 ms (average mu 0.998, current mu 0.998) allocation failure; scavenge might not succeed [56156:0x103000000] 2196103 ms: Mark-sweep (reduce) 2048.0 - 2047.7 (2052.5) MB, 11.2 / 0.0 ms (average mu 0.998, current mu 0.998) allocation failure; scavenge might not succeed --- JS stacktrace --- FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory我当年第一次在生产环境遇到这个问题时第一反应是“内存泄漏了”于是疯狂加内存、重启服务反复几次都没解决。后来才明白这行日志的信息量比想象中大得多“Ineffective mark-compacts”的意思是 V8 已经执行了标记-压缩但释放出的空间微乎其微你看日志里 2048.0 - 2047.7只回收了 0.3MB而进程仍在向堆请求分配新的对象所以直接判定为不可恢复干脆让进程崩掉。这种情况通常有两种原因。一是真的内存泄漏代码里某个集合无限增长或闭包持有了大量对象导致堆里活在引用图上的对象越来越多GC 想回收却收不掉。二是一次性加载了超过堆上限的数据比如从数据库查了几 GB 的数据一次性塞进内存做处理此时堆的上限不够用GC 永远赶不上分配的速度。2.4 怎么优雅地扩大堆参数、诊断与真正的根因如果你确定只是单次任务需要更大的空间可以通过启动参数扩大 V8 堆上限。Node.js 脚本如下node --max-old-space-size4096 large-data-process.js--max-old-space-size的单位是 MB上面这句话就是把老生代上限调到 4GB。如果你用 Jest、Webpack 这类工具遇到同样的崩溃可以在调用命令前加上环境变量NODE_OPTIONS--max-old-space-size4096 jest但请记住扩大堆上限只是治标。如果你不判断根因堆从 4GB 涨到 8GB内存泄漏就晚一点才崩而服务器物理内存不一定撑得住。正确姿势是先用--trace-gc跑一遍看看 GC 日志里 Mark-Sweep 回收了多少、耗时多久再用heapdump或者 Chrome DevTools 的 Memory 面板抓一份堆快照Heap Snapshot对比两次快照找出持续增长的大对象。node --trace-gc --max-old-space-size2048 app.js我见过一个真实案例某网关服务每天定时崩溃报错就是ineffective mark-compacts。抓快照后发现是一个 Map 结构在缓存请求链路数据时key 用了包含时间戳的字符串导致缓存永不过期每秒新增几百个条目一路涨到堆上限。解决方式只是给 Map 加一个基于长度的淘汰策略问题当场消失。所以看到allocation failed别急着调参数先查“谁在分配、为什么分配完不释放”。3. 堆外内存与堆内变量的“侦探工作”3.1 为什么要绕开堆零拷贝、大对象与 GC 暂停同样是热搜词里的“堆外内存”这个概念在 Java 里最常见。Java 程序员说的堆外内存Off-Heap Memory指的是 JVM 堆之外、由操作系统直接分配的内存区域典型代表是DirectByteBuffer、mmap映射文件和 JNI 分配的本地内存。我见过很多刚接触 NIO 的开发者想不通“JVM 就是用来管内存的为什么非要跑到堆外去分配”答案有三个。第一减少 GC 压力。一个 100MB 的字节数组如果放在堆内每一轮 Young GC 都要扫描它是否存活老年代 GC 也要遍历它代价极高。放在堆外GC 直接看不到它不会产生停顿。第二实现零拷贝。网络读写时堆内数据要先从 JVM 堆复制到操作系统缓冲区再进入 socket。而 DirectByteBuffer 直接在操作系统内存区域分配和 socket 缓冲区共享同一份数据省掉一次复制。第三生命周期更可控。堆外内存的释放由你主动控制不受 GC 调度影响。Java 中用-XX:MaxDirectMemorySize限制堆外内存大小。但要注意堆外内存不受 GC 管理泄漏了更难察觉因为jmap -heap看到堆内一切正常进程的内存却一点点涨上去最后被操作系统 OOM Killer 干掉。3.2 如何查看堆内变量从 jmap 到 Heap Snapshot“如何查看堆内变量”这个话题听起来很基础实操时才容易踩坑。这里区分 Java 和 Node.js 两种情况。Java 端早期我是jmap -heap pid看堆使用量和分区大小再用jmap -dump:live,formatb,fileheap.bin pid导出堆快照然后用 MATMemory Analyzer Tool打开分析。MAT 有一个很省事的功能叫 Leak Suspects能直接列出最可能的泄漏点和它们的引用链。定位到大对象后再回到代码里找创建它的地方。Node.js 端最快的路线是给进程加--inspect标志然后打开 Chrome DevTools 的 Memory 面板现场做一次 Heap Snapshot之后就能在 “Summary” 视图里按照 Retained Size 排序看到哪些对象占用的深层内存最大。node --inspect app.js然后浏览器打开chrome://inspect点击你 Node 进程的 inspect 链接切到 Memory 页签点 “Take heap snapshot”。我记得排查一个内存泄漏时就是用这个方法五分钟就定位到一个Set里存了大量已完成的 Promise 对象——本以为 Promise 结束后会被回收实际上因为闭包里引用了它GC 根本拿它没办法。3.3 堆外内存泄漏怎么抓堆外内存的排查比堆内难得多因为它不在 JVM 管辖范围内。如果你在 Java 中怀疑堆外泄漏第一步做的是加 JVM 参数开启 NMTNative Memory Tracking-XX:NativeMemoryTrackingsummary然后配合jcmd pid VM.native_memory summary.diff查看内存增长差异。我做过一次真实的堆外泄漏排查进程 RSS 持续上涨但堆内始终稳定在 1GB 附近。开启 NMT 后立刻发现Internal区域涨得飞快顺着代码找到是一个第三方库在解析大 JSON 时为每一个 token 都调用了Unsafe.allocateMemory且没有及时释放。修复方式是把这个库的解析模式切换成流式解析RSS 立刻平稳下来。如果你用 Node.js可以通过process.memoryUsage()看到external字段它表示堆外内存主要是 ArrayBuffer 和 Buffer 底层引用的大小。连续打印几次这个值如果只增不减那八成是 Buffer 或流对象没有释放。setInterval(() { const mem process.memoryUsage(); console.log(heapUsed${(mem.heapUsed / 1024 / 1024).toFixed(1)}MB external${(mem.external / 1024 / 1024).toFixed(1)}MB); }, 5000);4. 当“堆”出现在系统报错里Win11 堆栈区溢出剖析4.1 先纠个名堆栈溢出不是堆溢出热搜词里“win11堆栈区溢出解决方法”这句话本身就藏着一个陷阱。简体中文语境下“堆栈”这个词其实是从港台翻译“Stack”时留下的说法它的完整意思是“堆叠”跟内存里的“堆 Heap”一点关系都没有。所以当 Windows 弹出“堆栈溢出”错误时真正发生的事是调用栈 Stack 溢出了不是堆 Heap 空间不足。栈溢出的典型诱因有三个递归没有基准条件或者递归深度太大函数内部声明了超大的局部数组压爆栈帧深层次的函数调用链加内联展开导致每个线程的栈空间被迅速耗尽。Windows 下每个线程默认栈大小是 1MB在 Win11 上这个默认值并没有变。而 Linux 的 pthread 默认也是 8MB 左右。所以一个 Linux 上能跑得很欢的递归程序挪到 Windows 上可能几分钟就崩了这是平台默认值差异不是 Win11 的缺陷。4.2 从崩溃到修复的完整排查链路我分享一次真实的 Win11 排查经历。当时一个用 C 写的离线分析工具在客户机器上报0xC00000FD: Stack overflow错误在自己开发机Ubuntu上跑却没问题。第一步看错误码。0xC00000FD是 Windows 的 STATUS_STACK_OVERFLOW明确指向调用栈溢出。第二步打开 dump 文件。用 WinDbg 加载 dump 后执行!analyze -v它能直接帮你定位到崩溃栈帧。那次我们看到崩溃前的调用栈深度异常同一个函数在栈上出现了几千次——明显是递归失控。第三步检查代码。定位到一个递归解析目录树的函数解析到某个符号链接时目录结构形成了环函数在环里无限递归。因为调用栈每一层都保存了一个较大的目录信息结构体几百层就把 1MB 栈空间吃光了。第四步修复。修复方式是加一个“已访问目录表”来防止环同时把目录信息结构体从栈上搬到了堆上用std::vector替代定长数组。如果你是测试或运维角色不写代码但需要临时救火可以用 PE 工具修改可执行文件的栈大小配置。一个快速手段是把 exe 拖进 Visual Studio 自带的editbin工具执行editbin /STACK:16777216 app.exe这把栈空间调整到 16MB能解燃眉之急但不是长久之计。4.3 恢复后的结构性预防治完一次栈溢出我通常会做三件事防止复发。第一限制递归深度。任何递归函数都加一个深度参数超过阈值直接抛错。很多语言对尾递归有优化但 C 在 Debug 模式下往往会禁用千万别依赖编译器帮你优化。第二把大局部变量改成堆分配。超过 64KB 的局部数组老实换成std::vector或std::unique_ptr。栈是稀缺资源堆才是干重活的地方。第三给每个线程设置明确的栈大小。如果程序里自建线程别用默认栈大小。Windows 上_beginthreadex的第四个参数能显式指定Linux 上pthread_attr_setstacksize可以指定。定多大按你函数最大调用深度估算即可通常 4MB 到 8MB 足够大多数业务场景。5. 数据库里的“堆”堆组织表与索引组织表的取舍5.1 数据库堆表到底长什么样热搜词里还出现了“数据库栈、堆”很多人以为数据库的堆和内存堆一样其实差得很远。数据库里的“堆表”Heap Table也叫堆组织表指的是一种数据存储组织形式表中的行数据按照插入顺序存放在数据页中不强制按任何键值排序。Oracle 里默认建表就是堆组织表。你往表里 INSERT 一行数据库随便找个有空位的块放进去行和行之间没有物理顺序。要查数据时走索引或全表扫描扫描到的行是无序的最后再做排序操作。MySQL 8.0 之前有 MEMORY 引擎它的表也被称为堆表因为数据完全放在内存里并且默认使用哈希索引做查找。8.0 之后官方逐步把它标记为过时但这不影响理解内存表也是堆表的一种。SQL Server 里的堆表概念更直白——凡是没有聚集索引的表就是堆表。表里数据按分配顺序摆放在数据页里没有 B 树结构维护物理顺序。理解表格对比维度堆组织表索引组织表物理排序按插入顺序无序按主键排序插入速度快不用维护索引顺序稍慢可能触发页分裂查询定位靠索引或全表扫描主键查询极快空间复用支持但可能有碎片页重组后相对紧凑典型产品Oracle 默认、SQL Server 无聚集索引表MySQL InnoDB5.2 什么场景选堆表什么场景坚决不选从 DBA 的视角看堆表最大的优点是插入快。因为没有主键顺序约束新行直接追加到当前页尾省掉了 B 树插入时查找位置、处理页分裂的成本。适合那种只进不读的日志表、审计表。但堆表最大的坑在于对行做 UPDATE 可能导致行迁移。如果更新后的行变大了原数据页放不下数据库会把整行搬到另一个新页并在原位置留下一个“转发指针”。这会导致后续全表扫描时多一次额外的 IO查询性能显著下降。Oracle 里这种现象尤为明显。我在几年前的订单归档项目中就踩过这个坑。订单表用的是 Oracle 堆组织表初始设计没问题但后来在列上增加了冗长的 JSON 字段频繁更新导致行迁移率到 20% 以上归档查询慢了不止一倍。后来用ALTER TABLE ... MOVE重建了一次表行迁移率才降下来。如果你有明确的主键查询需求比如WHERE id ?绝对优先选索引组织表MySQL InnoDB或者 Oracle 的索引组织表 IOT。只有那种纯粹追加、极少更新的日志型数据才能考虑堆表。另外堆表上一定要建好合适的二级索引否则每次查询都是全表扫描等于给数据库上刑。“数据库栈、堆”里还经常提到“堆排序”——当查询语句里有 ORDER BY 且无法利用索引时数据库执行计划里会出现 sort 操作某些数据库实现就使用堆排序对结果集进行排序。这是把数据结构和数据库执行引擎连接得最紧密的一环。6. 热搜词里的“小土堆”一个名字引发的关联误会6.1 为什么“小土堆 PyTorch 学习笔记”会和堆撞在一页看到热搜词里“小土堆 pytorch学习笔记”时我第一反应是笑出来了。小土堆是 B 站上一名知识区 UP 主的昵称他做的《PyTorch 深度学习快速入门教程》很受欢迎因为“土堆”发音和“together”相近他的口号就是“Pytorch 土堆一起学习”。这本来就只是一个可爱的用户名跟计算机的内存堆、数据结构堆毫无关系但搜索算法把“堆”和“小土堆”直接关联了导致很多搜堆相关内容的同学误以为小土堆是一个讲堆排序和内存原理的教程。6.2 学习路径中的概念澄清借此机会我把不同语境下的“堆”做一个最终澄清。如果你在学数据结构和算法看到“堆”想的是优先队列、TopK、堆排序需要掌握的内容是二叉堆、上浮下沉、PriorityQueueAPI。如果你在看语言运行时原理看到“堆”想的是内存管理、GC、OOM 报错需要掌握的是新生代/老生代、-Xmx、堆外内存。如果你在看数据库原理看到“堆表”想的是数据页如何存储行需要掌握的是堆组织表和索引组织表的区别、页分裂与行迁移。如果你在搜小土堆那只是一个教 PyTorch 的 UP 主的名字他大概率不会讲太多堆的内容你只管放心去学深度学习。这四个场景的唯一共同点是它们都借用了“Heap”这个英文单词。Heap 在英语里的本义是“一堆、一摞”计算机借它来表达“随意堆放的对象集合”数据结构借它来表达“树形堆叠”数据库借它表达“无序堆叠的物理存储”每个领域借的角度都不一样。我自己带新人时最常强调的一句话是遇到技术名词先搞清楚它所在的领域和上下文再套用概念不然就是拿着一本数据结构的书去修 Node 进程的内存报错方向全错了。聊到这里分享一下我的个人体会学“堆”最好的方式是在真实问题里去碰它。你写一次 TopK 题写一次 Node 内存快照分析再经历一次数据库行迁移三个“堆”就再也混不起来了。尤其建议遇到fatal error: ineffective mark-compacts时不要急着调--max-old-space-size先抓一次 heap snapshot 看看里面到底堆了什么——你所有的“堆”难题本质上都是对象在堆里扎了根、堆表里行乱跑、或者栈被递归挤爆了。看清根因解决方案自然就出来了。
返回列表