
先聊一个特别典型的场景你用 JS 处理了一个比较大的列表页面切换了十几次浏览器标签页的内存占用一路飙升最后整个页面开始掉帧、崩溃。或者一个 Node.js 服务跑了一个星期之后内存从 200MB 涨到 2GB不得不定时重启。这时候很多人的第一反应是“代码写得有问题”但往深了挖问题往往出在 JS 垃圾回收机制上。JS 虽然是门自带垃圾回收Garbage Collection简称 GC的高级语言内存的分配和释放大部分时候不需要开发者手动管但不代表你可以完全不管。理解了 GC 的运作方式你才能解释“为什么内存会涨”“为什么会有卡顿”“哪些写法会造成内存泄漏”也才能在线上出问题的时候快速定位并解决。这篇文章我想把 JS 的垃圾回收机制掰开揉碎讲清楚从最基础的内存模型到 V8 引擎真实使用的分代式回收再落到 Chrome DevTools 里怎么排查内存泄漏最后给出一份可以直接照着改的编码建议。内容按“由浅入深、从理论到实战”的顺序来适合所有写 JavaScript 的开发者尤其是经常被页面性能、内存问题折磨的前端同学。1. 为什么每个 JS 开发者都要懂垃圾回收1.1 内存管理从“听语言的话”到“听引擎的话”C 和 C 的开发者对内存是有绝对控制权的malloc 一块内存用完 free 掉某个对象引用计数不对程序直接给你段错误。这种“手动挡”的好处是极致可控坏处是心智负担高一个疏漏就崩溃。JS 选择了一条完全不同的路——它是一门“自动挡”语言。你声明一个变量、创建一个对象底层内存由引擎自动分配你用完了引擎会在某个时间点自动把这块内存收回去。这个“自动回收不再使用的内存”的过程就是垃圾回收。听着很完美对吧但自动回收有一个隐藏代价引擎不知道你的对象“什么时候算真正用完”只能通过“还能不能访问到”来判断。它随时可能因为回收策略的问题把你还想用的对象提前干掉或者把早就该回收的对象一直留在内存里。前者是 Bug后者就是内存泄漏。所以懂 GC 的核心价值其实是让你从“听语言的话”变成“听引擎的话”。你知道引擎是怎么判断一个对象是否存活的就知道该怎么写代码才能让它精准地回收掉那些没用的内存而不是误判。1.2 谁是“垃圾”一切从可达性说起要聊 GC必须先搞清楚一个概念什么样的对象是“垃圾”在 JS 引擎看来判定标准不是你手动调用了什么方法而是这个对象是否“可达”reachable。所谓可达就是能从一组固定的根节点roots出发通过引用链访问到。根节点包括但不限于全局对象浏览器里的 windowNode.js 里的 global当前正在执行的函数调用栈中的局部变量和参数所有被标记为 active 的 Promise、定时器、DOM 元素等浏览器环境下只要一个对象能从根节点到达引擎就会认为它“还有用”即使你可能已经很久没碰它了。反过来如果某个对象从根节点出发怎么都访问不到了那它就是垃圾会在下一次 GC 时被回收。这个模型特别重要。很多内存泄漏的本质就是“本来应该断开的引用链因为某个疏忽一直没有断掉”导致对象明明没人在用却依然能从根节点访问到GC 就永远不回收它。比如挂在全局变量上的数据、被闭包长期持有的 DOM、没有清理的定时器回调都是这种类型。理解了这个底层逻辑再看后面那些算法和工具就会顺很多。2. 垃圾回收的核心算法从引用计数到标记清除2.1 引用计数最直观的方案为什么会被淘汰很早期的 JS 引擎比如第一代 Netscape 的 SpiderMonkey用的是一种叫“引用计数”Reference Counting的算法。它思路特别简单每个对象身上记一个数字表示“现在有几个地方引用了我”。这个数字归零说明没人再用它了立刻回收。let objA { name: A }; let objB { name: B }; // 此时 objA 和 objB 的引用计数都是 1 let objC objA; // objC 也指向 objA所以 objA 的引用计数变成 2这样确实很直观回收也很及时但有一个致命缺陷——循环引用。如果两个对象互相指着对方那它们的引用计数永远不可能归零。function createCircle() { const a {}; const b {}; a.ref b; b.ref a; return a; } // 即使外部不再使用返回的对象a 和 b 互相引用 // 引用计数都不会降到 0这块内存就永远回收不掉现实项目里循环引用出现得非常隐蔽尤其是 DOM 事件、闭包、复杂的数据结构组合起来之后你根本防不住。所以引用计数后来被所有主流引擎放弃了只在某些“辅助手段”里还能看到它的影子。现代 V8、SpiderMonkey、JavaScriptCore 的核心里站 C 位的是标记清除。2.2 标记清除Mark-Sweep现代引擎的地基标记清除Mark-Sweep的思路和引用计数完全相反它不关心“谁引用了谁”只关心“从根出发能不能到达”。整个算法分成两个阶段第一个阶段是标记Mark。从根节点出发遍历所有能访问到的对象给它们打上一个“存活”标记。这个遍历是深度优先的一路沿着引用链往下走把所有可达对象都标记上。第二个阶段是清除Sweep。遍历整个堆内存把没被打上标记的对象当成垃圾回收掉并把回收的内存记到空闲列表里供后续分配使用。对比一下就知道标记清除天生就能处理循环引用。因为标记阶段是从根开始“逆向”寻找的不管 a 和 b 怎么互相指只要根出发访问不到它们它们就是垃圾。但是标记清除也有问题它会产生内存碎片。回收掉的对象在堆内存里分布得乱七八糟后续分配大对象时可能找不到足够大的连续内存只能被迫触发一次额外的 GC或者浪费一些空间。这就是后面要说的“碎片化”问题。早年的 V8 老生代 GC 做完一次标记清除内存布局就变得跟碎饼干一样所以还需要一个补充手段。2.3 标记整理Mark-Compact解决碎片化的下一步既然清除会产生碎片那就再加一步整理Compact。标记整理的前半程和标记清除一样也是从根出发做标记但在清除的时候它不是简单地把垃圾对象的空间释放掉而是会用一种特殊的方式把存活对象往内存的一头搬移让它们紧凑排列然后再一次性清掉边界之外的空间。这个过程会把“碎片化的内存”整理成“连续的内存块”后续分配大对象时成功率更高。但代价也很明显搬移对象的成本很高尤其是对象多且引用关系复杂的时候整个堆的内存都要动一遍停顿时间明显变长。所以 V8 不会每次都走完整理而是有一套自己的策略大部分时候只做标记清除当内存碎片化到影响分配效率时才触发一次代价更高的标记整理。你可以理解成“平时按需打扫隔一段时间做一次全屋归位”既要控制单次 GC 的耗时也要保证长期运行后内存还是够用的。3. V8 引擎的垃圾回收结构解剖3.1 分代式收集为什么要区分年轻对象和老对象如果是小规模脚本用一套全局标记清除就够了但真实世界的 JS 应用动辄成千上万个对象GC 一跑就是几十毫秒页面直接卡一下。于是 V8 引入了一个非常重要的优化思路——分代Generational收集。这个思路的基础是一个统计学观察大多数对象都是“朝生暮死”的。在函数里创建的对象、临时字符串、map 方法中间产生的数组基本上用一次就变成垃圾了。而能活过几轮回收的对象往往是长期存活的比如模块级别的数据、全局配置对象、缓存等。V8 把自己的堆Heap分成两大块新生代New Space放刚创建出来的新对象空间不大默认在 64 位系统里大概 16~32MB 的样子老生代Old Space放经过了多轮回收仍然存活的对象空间大得多默认上限可以到 1.4GB~4GB 级别新生代里的对象更新换代的频率极高所以垃圾回收应该足够快、足够频繁老生代里的对象生命周期长回收不能太频繁而一旦触发就应该做一次彻底的清理。两个区域采用完全不同的算法这就是分代回收的核心。3.2 新生代的 Scavenge 算法与对象晋升新生代使用的算法叫 Scavenge具体实现是“复制式回收”。它把新生代又分成两个“半空间”Semi-space一个叫 From 空间一个叫 To 空间。新对象一律先放进 From 空间。GC 发生时V8 会扫描 From 空间里所有还存活的对象把它们“复制”到 To 空间。复制完以后From 空间整块清空然后 From 和 To 交换角色原来空的 To 变成新的 From原来的 From 变成新的 To继续给新对象分配空间。这个思路的精妙之处在于它避免了“逐块清除”产生的碎片问题而且新生代里存活对象通常很少复制成本很低速度非常快。特别适合“绝大多数对象活不长”的场景。缺点是空间利用率只有一半因为两个半空间同时只用一个这也是新生代空间设计得比较小的原因。对象在新生代里要是“命比较硬”经历了好几轮 Scavenge 还活着或者被复制时 To 空间已经快满了它就会被“晋升”Promotion到老生代。这个逻辑也很好理解既然你活过了好几轮 GC说明你是个长期对象放在朝生暮死的新生代里反而碍事赶紧搬到老生代去。对应到写代码上你可以想想如果你在一个高频执行的循环里创建了大量临时对象而且每个对象都被人为挂到了全局变量上那它们就会很容易晋升到老生代让老生代提前膨胀进而触发代价非常高的老年代 GC。这就是堆内存飞涨的一个典型前置因素。3.3 老生代的标记清除、标记整理与触发时机能进老生代的对象都是“见多识广”的。在新生代只要复制就能解决大部分问题但在老生代对象又多又大再用复制的方式不现实——你总不能把几百 MB 的老对象全部搬到另一个空间吧所以老生代的主算法是标记清除 标记整理的组合这在第 2 章已经讲过。老生代的 GC 不是轻易会触发的它有自己的触发条件当老生代的内存占用达到一个阈值时当新生代对象晋升到老生代导致老生代空间不足时当前一次 GC 之后内存使用率仍然很高需要再次回收时每次老生代 GC 都是一次对主线程的“入侵”最直接的后果就是 JavaScript 执行被暂停页面可能出现卡顿。所以 V8 才会绞尽脑汁优化 GC 的停顿时间这也直接引出了现代 GC 的另一个重头戏——增量标记和并发技术。另外补充一个和 Node.js 场景相关的信息Node.js 里 V8 的堆内存上限是可以调的常见的做法是加--max-old-space-size4096让老生代上限变成 4GB。但注意这只是“允许堆用这么大”不是“一上来就占这么大”。堆越大单次 GC 要扫的对象就越多停顿时间可能越长。所以别盲目调大内存上限真实需求是多少就调多少否则是在用 GC 延迟换内存上限不划算。3.4 现代GC优化增量标记、惰性清理、并发与并行传统标记清除有一个“原罪”标记阶段需要遍历整棵对象图一个都不能漏。如果应用里有几百万个对象这一步可能要花几十毫秒甚至上百毫秒。在这段时间里JavaScript 是停的页面就卡。为了让 GC 尽量不打断主线程V8 这些年做了一系列优化核心思路是“把大任务拆小”和“把任务转移给其他线程”。首先是增量标记Incremental Marking。不再一口气标记完所有对象而是把标记过程分成一小段一小段穿插在 JavaScript 的各个任务之间执行。这样每一小段只占非常短的时间用户几乎体感不到卡顿。为了让“分小段标记”不出错V8 采用了三色标记法用白色表示“还没扫描到”灰色表示“对象本身扫描了但它的引用还没扫描完”黑色表示“自己和引用都处理完了”。每个小段执行完标记状态都会被保留在对象颜色里下次增量接着做就是了。然后是惰性清理Lazy Sweeping。以前清除阶段会立刻把垃圾对象的内存全部释放并整理进空闲列表现在 V8 会“偷懒”把清除挪到用时再做。只有在分配内存的时候发现空间不够了才顺着空闲列表清掉一部分垃圾释放出足够的内存给新分配。这避免了一次性清扫带来的长停顿也把开销摊到了平时的分配路径上。再往深了一步V8 引入了并发标记Concurrent Marking和并行清理Parallel Sweeping。就是把标记、清理的任务交给辅助线程去跑主线程继续执行 JavaScript。当然不是所有步骤都能交给辅助线程和主线程数据交互紧密的步骤比如对象搬移、引用更新还是得回到主线程做但在 V8 的不断优化下GC 造成的停顿已经从一个“一次性的长暂停”变成了“若干次极短的微暂停”。理解了这一整套优化你再看“页面 GC 卡顿”这个问题就不会只想到“是不是对象太多了”而是会去分析是不是老生代频繁触发了代价高的 GC是不是对象图太大导致增量标记也撑不住是不是频繁分配大对象导致必须触发整理。4. 内存泄漏排查与 GC 友好代码实战4.1 最常见的六个内存泄漏场景懂原理的最终目的是写出不容易泄漏的代码。我把自己排查线上问题时反复撞到的六个场景整理成了一张表每一个都是真实项目里的“老熟人”。场景典型表现根因意外全局变量局部赋值时忘了写let/const或者隐式挂到 window 上对象永远可达根节点指向对象GC 无法回收定时器未清理setInterval回调引用了大对象页面关闭了却没clearInterval回调中引用的对象被长期持有事件监听器重复注册每渲染一次就 addEventListener组件卸载时没 remove监听器里的引用链断不掉闭包滥用一个长期存活的闭包引用了巨大的局部变量或 DOM闭包保住了外层的整条引用链DOM 引用残留把 DOM 存进了数组或对象DOM 从页面上移除了但引用还在已移除的 DOM 和关联数据一起滞留无边界缓存Map 或普通对象里不断塞数据从不清理过期项缓存对象本身持续可达且无限增长最阴险的是“意外全局变量”。有一次排查一个页面内存泄漏发现一个函数里写了data res.data明明是想声明局部变量却因为少了let直接把 data 挂到了全局对象上。后台返回的列表数据巨大只要页面不刷新那几 MB 就永远赖在内存里。解法很简单开启use strict严格模式会直接禁止隐式创建全局变量等于从语法层面拦掉了一类泄漏。4.2 用 Chrome DevTools 定位泄漏源头排查内存泄漏强烈建议先用 Performance 面板录一段操作流程。具体做法是打开 Chrome DevTools切到 Performance性能面板勾选 Memory 选项点录制在页面上执行你想要测试的操作比如刷新列表、打开弹窗、切换路由重复几次停止录制观察内存曲线如果内存曲线波浪式上涨后能回落说明 GC 在正常工作如果每次操作结束内存的基线都往上抬一个台阶就算你停止所有操作内存也降不回去那基本确定有泄漏。找到方向后再用 Memory内存面板做精确分析。切到 Heap Snapshot堆快照操作前拍一张操作完成后再拍一张然后对比两张快照的“Retained Size”保留大小。看哪个构造函数占用的内存增量最大再点进去看是哪些对象被谁引用了。从引用链里基本能顺藤摸瓜找到泄漏的源头——十有八九是一个闭包、一个定时器或者一个挂在全局的数组。如果是持续性的内存增长还可以用 Allocation Instrumentation on Timeline分配时间线录一段它能把每毫秒的分配情况记录下来形成按函数名分组的内存分配柱状图一眼就能看出是哪个函数在疯狂分配大对象。在真实场景里我的排查顺序基本都是Performance 曲线确认“泄漏是否存在” → Heap Snapshot 对比确认“是哪些对象” → 看引用链确认“是谁在引用它” → 改代码闭环。这个过程不要靠猜每一步都要有数据支撑。4.3 弱引用WeakMap、WeakSet 与 FinalizationRegistry很多内存泄漏的尽头是一个“不该存在却一直存在的强引用”。要想打破这种绑定JS 提供了弱引用工具WeakMap 和 WeakSet。它们的特点是只要 key 对象没有其他强引用即使你在这个 WeakMap 里存了值GC 也可以把 key 回收掉。最典型的应用场景是缓存。比如你写了一个处理大对象的缓存模块用 Map 存对象会导致缓存无限膨胀。换成 WeakMap 后key 对象如果不再被外部使用它和对应的缓存数据就会一起被回收不会拖垮内存。// 使用 WeakMap 做对象级缓存不会造成内存泄漏 const cache new WeakMap(); function processData(obj) { if (cache.has(obj)) { return cache.get(obj); } const result heavyCompute(obj); cache.set(obj, result); return result; } // obj 不再被外部引用后cache 里的对应条目会被 GC 自动回收注意WeakMap 不是万能的。它的 key 必须是对象不能是原始类型而且你没法遍历 WeakMap 的所有 key因为在 GC 前后 key 集合是变化的。需要用“可枚举的缓存数据”时还是老老实实用 Map但记得自己做清理策略。如果你需要更细粒度地监听对象的回收时机可以试试FinalizationRegistry。它能注册一个回调当注册的对象被 GC 回收时回调会执行。比如你想给一个被回收的对象打日志、或者移除关联的缓存条目都可以在回调里做。同理WeakRef可以拿到一个对象的弱引用不会阻止它被 GC 回收但使用时要非常小心因为deref()可能随时返回undefined。纯业务代码里我不建议大量使用这两个 API它们更适合框架层、工具库这类需要精细控制资源释放的场景。4.4 我总结的 GC 友好编码清单聊完排查工具最后再给一份可以直接落地执行的编码清单算是我这几年替团队写前端代码时反复强调的“每日守则”。第一能局部就不全局。变量声明用let/const限定在尽可能小的作用域里临时对象用完了就让它自然离开作用域GC 才好判断它已经死了。很多人图省事把本应该放在函数内部的数据挂到路由级状态或者组件实例上等于给垃圾对象送了“免死金牌”。第二严格管理生命周期。配对使用定时器和清理器组件里用了setIntervalunmount或beforeDestroy里一定要clearInterval注册了事件监听removeEventListener别忘用了第三方库的订阅方法退订方法同样要补上。这听起来像废话但线上绝大多数泄漏都是这种“配对的另一半没写”造成的。第三谨慎使用闭包。闭包不是不能用而是要控制它引用范围。我在实际项目里见过同事写了一个很长的闭包内部引用了外层一个包含大量历史数据的 state结果这个闭包被存进了全局数组等于那整份历史数据全被保住了。解决办法是闭包里只引用必要的数据该解构的解构该传参的传参别让一个函数顺手牵住整个外层的“家产”。第四合理使用弱引用。对象映射、对象缓存、按对象维度存储元信息优先考虑WeakMap/WeakSet。它们就是专门为“不想强持有对象”的场景设计的用对了能让 GC 省很多事。第五避免频繁创建超大对象。比如渲染一个表格前先用 filter/map 生成几个中间大数组这本身没问题但如果这些数组被人为挂到了组件属性上或者闭包里被长期持有就会不断推高老生代的内存水位线。该用流式处理的就别一次性全塞进来该复用的对象池就复用尤其是往 Canvas、WebGL、WebSocket 这类高频场景传数据时对象复用的收益非常明显。第六给长时间运行的应用加上“观察哨”。前端可以用 Performance Observer、performance.memory监听内存变化Node.js 里可以定期用process.memoryUsage()记录堆内存超过某个阈值就报警。有监控你才能知道改动有没有生效否则“内存泄漏”这种东西往往等项目上线跑了两周才在某个深夜把服务拖崩。5. 从面试题到实际项目GC 知识应该怎么用5.1 一道经典面试题背后的真实考点网上关于 GC 的面试题不计其数最常见的是“说说 JS 的垃圾回收机制有哪些”。很多人背了一堆概念什么引用计数、标记清除、分代回收都能说个大概。但到了实际项目里能拿这套知识解决真实问题的人少之又少。我判断一个人是不是真懂 GC会问他一个延伸问题“如果线上页面内存一直涨你第一步做什么”这时候如果他还停留在背概念往往会回答“用标记清除把没用的对象清掉”——这就说明他没真正理解 GC 的边界。正确的做法是先判断内存趋势用 Performance 看曲线再定位到具体对象用 Heap Snapshot最后顺着引用链找到代码里的强引用修改代码从而让 GC 有机会回收。GC 从来不会主动“修掉”你代码里的逻辑问题它只负责把你已经“断开引用”的对象清走。所以我的建议是面试题里的概念一定要理解而不是死记比如“标记清除为什么能解决循环引用”“Scavenge 为什么适合新生代”这些本质都是在考你“这个设计解决了什么问题、代价是什么”。把这些底层逻辑想通了面试时自然能结合场景展开而不是给一份教科书式的背诵。5.2 真实项目一个内存泄漏排查的完整复盘最后分享一个我印象极深的排查案例它几乎包含了所有 GC 难点。项目是一个数据大屏页面每隔 5 秒请求一次后端接口把数据渲染到页面上。运行大约 2 小时后页面开始卡顿Chrome 内存从开局的 300MB 涨到了 2GB 以上。一开始我们以为是接口返回的数据量太大让后端压缩数据但优化后内存依然涨。于是开了 Performance 录制发现每次数据刷新后内存曲线都会“上一个台阶”几乎不回落。再用 Heap Snapshot 对比发现占内存最多的对象是一个被闭包引用的大数组。顺着引用链一查发现了真正的问题代码在setInterval的回调里调用了一个updateChart()函数而这个函数内部创建了一个Chart实例并且在初始化时传入了这一次的完整数据。关键点在于这个Chart实例被赋值给了模块级变量导致每次调用都会把上一次的实例顶掉但实例内部的事件监听器仍然持有老的数据数组形成了一个“旧实例不被回收”的引用链。问题根因不是数据量大而是每次更新时没有销毁旧实例。修复方法也很简单在更新前调用chart.destroy()或手动移除内部监听器并把模块级变量重置为null。改完后内存曲线变得平稳再跑一整天也没有明显增长。这个案例对我最大的触动是GC 不是靠某个一次性操作解决的它是一个“让 GC 能正常工作”的工程问题——引用链不断干净哪怕算法再牛也没有用。5.3 这个知识后续还能怎么扩展学会了 JS 的 GC其实你已经在理解“自动内存管理”这件事上踏出了一大步。同样的分代思想也应用在 Java 虚拟机、Go runtime、.NET CLR 里只是各自叫法和细节不同。如果你以后要深入 Node.js 底层、写 WebAssembly 模块、或者做性能相关的工具链这套“内存分配 → 对象存活判断 → 回收策略 → 停顿优化”的思路会反复出现。还可以了解一下 V8 官方文档里的--trace-gc、--trace-gc-nvp日志输出Node.js 下跑一段压测脚本能看到每次 GC 的耗时、类型和内存回收量。把这些日志接入到监控系统里你就能实时掌握服务的 GC 频率和压力水平提前预判性能风险。说实话我刚入行时也觉得 GC 这话题太底层前端写出界面就行了。直到我自己在大屏项目里被那个“2 小时内存暴涨”的问题折磨了整整三天、差点通宵上线的时候才意识到不理解垃圾回收写出来的代码就像是一个不看油箱分表的司机开着开着就抛锚了。这些年我再带团队都会要求核心开发至少要能讲清楚“可达性”“分代回收”“引用链”这三个词不为别的就为哪天线上内存报警时有人能冷静地说一句“别急我们先看引用链。”最后分享一个实际工作中的小技巧在排查内存问题时别只看堆大小也留意一下页面卡顿的时机。如果卡顿呈周期性而且正好和 GC 触发节奏重合那基本就是 GC 停顿在作祟。你可以用 Performance 面板里的“Experience”指标看看有没有 Long Task再配合--trace-gc日志确认 GC 的暂停时间。这种问题通常不是内存泄漏而是“对象图太庞大 频繁触发高成本 GC”优化方向是减少长期存活对象的数量、压缩缓存、避免在热路径上创建大对象。两回事别用一套排查方案硬套。