
1. 先把话说在前面JS的内存管理到底在管什么在 JavaScript 里写业务时最省心的一件事就是不用手动释放内存。你 new 一个对象、定义一个数组、用一个闭包运行时都在背后帮你做内存的分配和回收本地没有free也没有delete这种“杀对象”的语法。有人说这是“自动管理”有人说这是“垃圾回收”但很多人没意识到自动回收不等于自动正确它只负责收那些已经没有任何引用的对象至于业务上那个对象还该不该被引用、该不该被留在某个闭包里运行时一概不知。内存问题是我在日常排障中遇到频率最高的一类尤其是页面越用越卡、切路由后内存不降、服务端进程内存只涨不跌。这些问题表面看是回收器不够聪明根源却是代码中留下了不该存在的引用链。只要有一个全局变量把你本来已经关掉的页面组件引用住整个组件树就永远“可达”垃圾回收器只能放行它让它一直在堆里躺着。所以今天这篇文章写的是内存管理“如何工作”但我会刻意把重点放在三个层面栈和堆怎么划分、一个对象从出生到回收要经过什么、垃圾回收器用哪些策略来决定“谁该死”。最后配合排查内存泄漏的完整思路以及我处理过的几个典型事故复盘。适合前端开发者、跨端开发、以及服务端 JavaScript 开发者阅读。不打算把引擎源码捞出来逐行分析但看完之后你至少能在写代码的时候判断这个变量会造成泄漏吗这个闭包会不会留住一堆不该留的数据为什么内存只涨不降2. 底层机制拆解内存分配与回收的本质2.1 值类型与引用类型一个存在栈上一个住在堆里很多人第一次接触“栈”和“堆”是在数据结构课但在 JS 运行时里这两个词更像内存区域的分工。基础类型的值比如 number、string、boolean、null、undefined通常分配在栈区。栈区的特点是跟随函数调用自动入栈和出栈速度极快作用域一结束这些值就直接从栈顶弹出不需要垃圾回收器介入。而对象、数组、函数这些引用类型则生活在堆区。堆区是一大片动态分配的内存大小不固定生命周期也不能简单地跟着某个函数走。栈上保存的只是指向堆区地址的“引用”也就是指针。我们平时说“把一个对象赋给另一个变量”本质上只是复制了引用并没有复制对象本身。let user { name: 小明 }; let copy user; copy.name 小红; console.log(user.name); // 小红这个例子很容易解释清楚。user和copy在栈上是两个独立变量但它们指向同一个堆对象修改copy.name其实就改了同一个对象的数据。堆区内存什么时候回收不能靠变量出栈来判断因为堆对象可能还被其他变量、其他函数、其他闭包引用着。由此可以带出一个非常关键的判断JS 里判断“要不要回收一个对象”看的不是“这个变量还在不在作用域里”而是“还有没有一条可达的引用链能从根节点走到这个对象”。顺着这条线索继续往下看。2.2 从产生到回收一个对象的完整生命周期一个对象从被创建出来到被回收通常会经历这样几个阶段第一创建阶段。执行let obj { data: new Array(10000).fill(0) }时运行时先在堆区分配一块空间给这个对象然后把地址写入栈上的obj。第二使用阶段。对象被传入函数、被作为返回值、被挂到全局对象上、被闭包捕获这些操作的本质都是往不同的“引用”里写入这个对象的地址。只要还有一个引用存在对象就不会被回收。第三失去引用阶段。如果obj null那原来那条从当前作用域到对象的路径就断了。但对象不一定马上被回收它只是“不可达”了垃圾回收器会在某个合适的时机把它标记为垃圾然后清理掉。我有一次给团队做分享时用过一个不太严谨但很好懂的类比可以把对象想象成一个正在酒店里开会的客人。房间号写在接待本上本子上的记录就是引用。只要有一个接待员某条引用链知道他在哪个房间酒店就得让房间一直保留当所有接待员都忘了这个人他住的房间就成了“无主房”保洁阿姨垃圾回收器会在空闲时来收拾。问题是内存回收的“空闲时”不好把握。运行时得在“少停用户交互”和“尽快释放内存”之间做平衡于是垃圾回收器常见做法是“分代回收”。这个后面细说。2.3 闭包与回调最容易延长对象生命周期的隐形引用闭包可以说是 JS 内存管理里最容易踩坑的设计。它的语法很美能让你在函数外访问函数内的变量但代价就是被捕获的变量会随着闭包一起存活。function createBigCache() { const bigData new Array(100000).fill(x); return function () { return bigData.length; }; } const getLength createBigCache();这段代码里bigData按普通函数理解应该在createBigCache执行完就被释放但因为返回的函数还在被外部变量getLength持有而内部函数引用着bigData所以bigData会一直活着。只要getLength不被释放这十万个字符串就永远占着堆空间。事件监听器也是同一个道理。dom.addEventListener(click, handler)会把handler和它内部引用的变量都绑到dom上。如果dom本身已从文档里移除但没有removeEventListener浏览器仍然可能判断dom对象和handler都是可达的因为事件系统内部还有一条引用链。这样“分离”的 DOM 节点会堆积形成前端里非常经典的一种泄漏。当然了闭包本身不是洪水猛兽该用还得用。关键是要意识到它所带来的生命周期延长以及反推“这个闭包会被谁引用、能活多久”。这一段先建立这个概念后面的排查案例全部围绕这个展开。3. 垃圾回收策略标记-清除与分代回收3.1 为什么引用计数在 JS 里行不通很多讲 GC 的文章会先介绍引用计数每个对象身上记一个“被引用次数”有人引用它时加一引用解除时减一次数归零就立即回收。听起来很合理但它有一个致命问题循环引用。let a {}; let b {}; a.next b; b.prev a; a null; b null;此时a和b已经不可达了但它们两个对象互相引用引用计数都不会归零。如果垃圾回收器只靠引用计数这两个对象就会永远站在内存里形成活死人式的泄漏。所以现代 JavaScript 运行时基本不会把引用计数当作主算法最多作为某些局部结构的辅助手段。引用计数还有一个缺点每次给对象赋值、传参都要同步修改计数器固定消耗性能而且频繁的大对象引用变化会让计数器抖动。在追求吞吐和低延迟的 Web 场景里这种同步开销并不划算。3.2 标记-清除从根出发顺藤摸瓜真正的通用做法是标记-清除Mark-Sweep。它的核心逻辑是先把所有能到达的根节点找出来然后从根节点出发沿着对象的引用关系一层一层遍历所有碰到过的对象全都打上“存活”标记一次遍历结束后没有被标记的对象就是垃圾直接回收。根节点包括哪些简单说全局对象、当前正在执行的函数调用栈里的局部变量和参数、还挂在定时器或事件队列里的回调、以及其他由运行时所维护的内部结构。你可以把它理解成“系统一开始就攥在手里的那些根”。标记-清除最漂亮的地方在于它不关心对象之间怎么互相引用。a和b循环引用也没关系只要从根节点出发已经摸不到它们它们照样会被当成垃圾。这正解决了引用计数的最大痛点。但标记-清除也不是没有成本。标记阶段需要遍历整个对象图清除阶段可能要移动或整理内存这个过程发生时程序的其他执行会被暂停术语上叫“Stop The World”。如果堆里的对象数量巨大一次 GC 会造成明显的卡顿。为了缓解这个问题引擎在实现上做了大量优化比如增量标记、并发标记、并行清除等让停顿时间摊开到多个小片段里。这也是为什么我们在浏览器里感知上的 GC 停顿越来越不明显但写代码时仍然要尽量避免制造超大对象图。3.3 分代回收新生代、老生代和弱引用一直有观点说“ 90% 的对象都活不过几次 GC”。很多前端对象是临时计算出来的比如事件处理函数里的配置对象、渲染流程里的临时数组用完就应该被丢弃。所以现代运行时把堆分成新生代和老生代两个区域。新生代里放刚创建的对象空间小回收频率高。新生代的回收思路通常采用复制式算法把存活对象从一个半区复制到另一个半区剩下所有对象一次性清空。因为新生代对象大部分活不久所以复制存活对象的成本很低比较高效。当某个对象在新生代里撑过几轮回收或者它的内存占用达到一定阈值它就会被晋升到老生代。老生代的空间更大里面的对象生命周期长回收频率低。老生代如果没有足够大的“新鲜血液”内存里的对象会越堆越多。这里的回收更多依赖标记-清除以及复杂度更高的整理算法。理解分代之后一个很重要的实用结论就是如果业务代码制造了大量“长生命周期的对象”比如无限增长的缓存、常驻的闭包、被全局变量引用的 DOM 树那么这些对象都会被送到老生代让老生代越来越膨胀最终导致 GC 时间变长页面或服务端进程出现周期性停顿时长增加。为了配合这套机制JS 提供了WeakMap和WeakSet。它们的“弱引用”特性很有意思键或值不会阻止垃圾回收器回收对象。换句话说如果你把一个对象放进WeakMap当这个对象没有其他强引用时即使WeakMap里还存在这张表GC 照样可以把对象收走。这个特性特别适合做缓存、事件监听元数据、以及一切“不应该影响对象存亡”的附加信息。const cache new WeakMap(); let target { id: 1 }; cache.set(target, 缓存数据); target null; // 此时 cache 里对应的键值对会随 target 的回收自动消失有了这个基础后面在讲缓存类泄漏时你会更清楚为什么该用WeakMap而不是普通Map。4. 内存泄漏定位从一次卡顿页面开始的排查4.1 先看症状内存问题不只从数字看出来做内存排查不能光盯着“内存占用 2GB”这种数值。数值高不代表泄漏很多应用本来就要用大量内存反而更值得关注的是“趋势”和“稳定点”。如果页面打开后内存一路向上切到后台再回来看内存没有回到正常水位或者同一个操作重复执行一百次后内存比第一遍时高出一大截这基本就是有对象在持续堆积。我有一个很具体的经验内存问题出现时用户往往不是直接说“卡”而是说“用着用着就白屏了”“切页面越来越慢”。这在排查时非常有引导性。因为 GC 停顿会拖住主线程用户操作越多停顿造成的卡顿感越明显。只要抓住“重复一个操作 - 内存持续上涨 - 操作变慢”这个链路通常就能圈定泄漏发生的模块。浏览器开发者工具里的 Performance 面板可以帮助观察 GC 活动。录制一段操作过程你会在时间线上看到很多锯齿状的内存变化上升段是分配内存下降段是 GC 回收。如果锯齿的“底”一直在抬高说明每次操作都会残留一部分内存没有释放这就是典型的泄漏。4.2 用快照对比找出漏点两个 Heap Snapshot 的对照实验最直接的定位方法是拍两组堆快照。核心步骤是先进入一个稳定状态做一次堆快照记作 Snapshot A。连续执行同一种操作比如打开一个弹窗再关闭、切到一个详情页再返回。等待一段时间让 GC 跑完再次拍堆快照记作 Snapshot B。在快照列表里选择“比较”过滤出 Snapshot B 比 Snapshot A 新增的对象。新增对象里通常有一个明显的“大头”可能是某个组件实例、某个内部类实例、或某个字符串数组。每一条都可以展开看它的“保留路径”也就是通过哪些引用链被根节点持有。顺着保留路径几乎一定能找到某个全局变量、某个事件监听器、或者某个长期存活的闭包。我印象最深的一次记忆是我们某后台系统里一堆弹窗关闭后不及时销毁。用快照比较后发现内存里多了很多 DOM 节点和组件实例保留路径完整指向全局缓存列表里的“历史弹窗配置”。修复方式就是在弹窗关闭时清理全局配置并移除弹窗 DOM。两次快照之间只改了几行代码内存曲线马上恢复稳定。这里有一个技巧要提醒拍摄快照的最佳时机是“GC 之后”不是操作刚结束就立刻拍。因为操作结束的瞬间仍然有很多对象正处于即将被回收但还没到回收点的状态这时快照里的噪音很大。你可以在操作完成后稍等片刻手动触发一次 GC然后再拍摄第二张。如果工具里不好手动触发就多等几秒让运行时自己跑完一轮 GC 再拍。4.3 服务端 JavaScript 的排查思路没有页面就看曲线服务端进程没有“页面卡顿”这种直观感受最早出现的通常是内存只涨不跌甚至被监控平台告警。如果没有开启自动重启进程会一直吃到系统内存上限最后被宿主环境杀掉。这种情况如果只盯一次内存快照很难区分“正常业务缓存”和“真泄漏”必须结合时间来做。服务端排查我会分三步走第一步看内存趋势。用运行时自带的内存统计接口比如process.memoryUsage()定期采样并记录。重点关注heapUsed这个值它代表 JS 堆当前实际使用的内存。如果heapUsed随着请求量增加等请求高峰过后仍然回不到之前的位置就有泄漏嫌疑。第二步生成堆快照。服务端运行时通常可以在进程运行中通过命令行参数或调试协议把当前堆快照导出再传到本地用浏览器开发工具打开分析。分析和浏览器端快照对比一样找“新增对象”和“保留路径”。第三步做二分定位。在怀疑泄漏的业务模块里手动把某个功能开关打开/关闭观察内存曲线的斜率变化。比如每分钟跑一次定时任务如果关掉后内存曲线不再上升那定时任务所在的模块大概率是元凶。服务端还有一个很容易忽略点外部依赖和原生绑定的内存。有些问题根本不在于 JS 堆而是库创建了自己的缓冲区、线程、文件句柄。遇到这种问题堆快照看不到就不展开讨论了但你也得知道。5. 三个真实且常见的内存事故复盘5.1 事件监听重复注册组件关了回调还挂在全局上一次在做一个数据可视化大屏时页面里需要轮询刷新图表数据。最初实现的思路是每次进入可视化模块创建一个新的轮询函数并注册给全局事件总线退出模块时只清除了定时器没有把监听函数从全局事件总线移除。结果用户反复切换大屏和主菜单每切换一次老的事件回调就留在内存中一次新的定时器又不断触发回调堆里堆积了几十份图表实例和轮询闭包。这类问题最迷惑人的地方是从表面看用户每次进入看到的数据和图表都是正常的完全不觉得有哪些代码在重复执行。直到打开堆快照发现同一类型的轮询对象实例数量比实际应该存在的多了很多。修复方法也很简单关键是“对称清除”在注册的同时保存函数引用在关闭销毁时用同一个引用去移除监听。很多人注册时习惯直接写匿名函数这会导致之后想移除也没有“把手”这是最常见的坑。正确做法是用命名函数或变量保存const handler () { refresh(); }; eventBus.on(refresh, handler); // 退出时 eventBus.off(refresh, handler);5.2 闭包把大对象“钉”在内存里一个小缓存拖垮整个页面另一个项目是做管理后台的列表页列表里有几千条记录业务方希望切换分页时不要重新请求前端做了一个 Map 当缓存。这个需求很常见但因为 Map 本身对键持强引用只要不手动 delete缓存里的每条旧数据都会一直存在。随着用户翻页翻了几百页Map 里积累了大量完整数据对象最终列表页的性能一落千丈。这个问题的本质是完全违背了“对象生老病死”的规律本来一页数据只应该活到用户不再看它为止结果被一个无限增长的 Map 给强行续命了。修复方案是把缓存容器换成WeakMap并调整使用方式如果键对象本身也是一个有短生命周期的对象那么当没有其他引用时缓存会自动消失不会阻止回收。不过这里要特别说明WeakMap的键不能是基本类型而且不支持遍历和统计数量。做缓存时必须想清楚这个缓存到底该以谁为键。另外如果真要做“保留最近 50 条”这种有上限的 LRU 缓存WeakMap不一定合适得自己在封装层维护淘汰策略或者直接用 Map 并显式删除。5.3 大数据列表造成的 GC 停顿不是泄漏但比泄漏更难受还有一种情况特别容易误导人明明没有泄漏内存占用也稳定但页面照样卡。我遇到过某表格页一次性渲染了上万行数据的同时还在每行里做字符串拼接、数组排序、格式换行。表面看操作完成后内存会回收但因为数据量太大GC 每次工作都要扫描一个非常庞大的对象图遇到极限场景时明显感受到主线程停顿。这种情况的源头是“一次性分配太多短期对象”。即使这些对象最后都被回收在高频 GC 的阶段依然会吃掉大量时间。优化思路不是从“怎么让回收更快”入手而是想办法减少分配把全局一次性大列表改成分批渲染或虚拟滚动只保留可视区数据。避免在渲染函数里频繁创建中间数组和对象能复用就复用。用流式处理代替一次性 full array 的操作。如果某些大数组确实在一个生命周期内必需尽量保持引用稳定不要反复替换整个数组引用触发新的分配。那次改动后尤其是把一次性渲染改为按页渲染页面内存和 GC 停顿都明显下降。这件事让我对“内存管理如何工作”有了更实际的理解回收器再努力也弥补不了代码凭空制造大量垃圾的问题。6. 落地方案与团队约定6.1 十条约法式编码建议如果你不想每次都靠堆快照才能发现内存问题最好的办法是在编码阶段就挡一层。下面十条是我在做代码评审时经常会提的建议每一条背后都有真实的踩坑经历不要往全局对象上随意挂业务缓存和临时组件实例。全局变量会让对象生命周期无限延长而且很难追踪。注册事件监听时保存函数引用不使用匿名函数确保销毁阶段可以移除。创建setInterval和setTimeout后要在明确不用时清除页面关闭和模块销毁时都要检查。弹窗、抽屉、列表项这类可能被反复创建销毁的组件要确认销毁逻辑也执行视觉上关闭不算销毁。大型对象在不再需要时可以考虑置空或者重新赋值虽然不是万能钥匙但能帮助引用链尽快断开。用WeakMap和WeakSet做附加数据和缓存不要让缓存反过来决定业务对象生不生死不死。闭包捕获的数据尽量缩小范围不要在闭包里长期引用一个巨大对象图的一小部分数据。DOM 节点移除后检查是否还有代码持有该节点的引用特别是事件回调、全局数组、数据映射表。大量数据展示优先考虑分页、分批、虚拟滚动不要为了省事一次性把全量数据渲染出来。建立内存检查习惯尤其在频繁切换页面、反复开关弹窗、反复请求接口的模块里跑一次内存曲线观察。6.2 关于排查和预防的一点个人看法我见过很多团队平时完全不关注内存等到线上报警才开始查。这倒也没什么可怪罪的毕竟功能迭代压力大内存问题又多发生在特定操作路径上。但如果能在一开始就建立“生命周期意识”很多事故根本不会发生。所谓生命周期意识其实就是写代码前问一句这个对象活着的时候对谁有用它该死的时候有没有代码挡在回收路上把这个问题变成肌肉记忆之后你会发现自己写出来的代码天然更整洁因为你会主动减少全局状态、主动清理副作用、主动拆分长生命周期的内部引用。另外排查内存问题一定要沉住气。很多问题不是看第一眼快照就能看出来的可能要反复对比、反复试。别指望有一个命令能把所有泄漏一键定位堆快照对比、保留路径分析、代码走读三者配合才是完整的路子。如果最后确认某个模块实在没法做到完全无泄漏那就给进程或者页面加一道定期重启或上线的兜底策略这也是一种务实选择。这轮讲下来回到题目那句话JS 的内存管理是如何工作的其实它就是一个全自动的“房间管家”不停地在分配空间、清扫无主对象、调整存储布局之间循环。作为开发者我们不需要伸手去捡垃圾但至少要能看懂哪些行为会造成“无人认领又没法清扫”的死角以及怎样在写业务代码时尽量不让垃圾堆积到影响主流程。记住这一点内存就不再是启动时的玄学而是你可控的一部分。