长列表后台页面挂着两小时不刷新,用户在几个标签页之间来回切换,任务管理器里那个页面占到 1.8G 内存,F5 一下掉回 220M。这种曲线我见得太多了,第一次遇到时的直觉是"数据量太大了吧",后来才慢慢摸清楚,多数情况下是某处的引用没断干净,垃圾回收根本没有机会把那些对象收走。JavaScript 的内存管理一直给人"不用操心"的错觉——声明变量、用完不管,语言层面看起来全自动。可真正把页面放在用户浏览器里开一整天,或者让 Node 服务在线上连着跑一周,这份错觉就要拿线上问题来还。
这篇内容聊的是 JavaScript 内存、垃圾回收和性能优化的实操打法:V8 在什么时机回收、回收谁、回收要付什么代价;哪些写法会让对象悄悄留在堆里;手上只有浏览器开发者工具的时候,怎么从几万个堆节点里把泄漏对象揪出来;以及在不推翻架构的前提下,怎么把主线程的卡顿压下去。前端同学、写 Node 的同学、平时要盯线上内存指标的同学都能用上,文中代码可以直接贴到控制台或者临时脚本里跑。
1. 先把账算清楚:一次变量赋值到底占了多少堆
很多人对内存的认知停在"声明了就有内存,不用了就没了"。这句话本身没错,但漏掉了中间最关键的环节:什么时候算"不用了"。V8 判断的标准不是你想不想用,而是从根对象出发还能不能顺着引用链走到它。走不到,就是垃圾;走得到,哪怕你三年没碰过那个变量,它照样在堆里待着。
1.1 栈、堆和引用链的关系
JavaScript 里真正放在栈上的是原始类型值和执行上下文,对象、数组、函数、闭包这些引用类型全部堆在堆里,栈上(或寄存器里)存的只是一个指向堆地址的引用。所以const a = { n: 1 }这条语句实际产生了两块东西:栈上一格写着a,堆上一块装着{ n: 1 },中间用指针连着。
理解这一点之后,"谁在引用这个对象"就变成了排查内存问题的唯一核心问题。堆上的对象只要还有一条路径能从根(window、globalThis、process、当前执行栈上的局部变量、闭包环境)走到它,它就是可达的,GC 就不会动它。
// 堆上会留下一个对象,因为变量 list 还在引用它 let list = new Array(1e6).fill(0); // 断掉引用,下一次 GC 就有机会回收这 8MB 左右的空间 list = null;有个容易被忽略的细节:V8 对小整数(Smi)和短的内部化字符串有特殊处理,它们可能不单独占堆空间,或者被存放在字符串表里共享。所以拿'abc' === 'abc'、小整数比较去做内存实验,很容易测出"怎么不涨"的假象。要观察内存行为,用对象和数组,别用字面量小字符串。
1.2 新生代 Scavenge:便宜,但要拿空间换
V8 把堆分成两块:新生代(young generation)和老生代(old generation)。绝大多数对象一出生就落在新生代。新生代又被切成两个等大的半区(semi-space,通常叫 from 和 to),默认单个半区在 64 位桌面环境大约是 8MB 到 16MB 量级。
回收新生代用的是Scavenge 算法,本质是 Cheney 复制:标记活着的对象,把它们整块复制到另一个半区,复制完之后直接把原来的半区整体清空。这个做法的成本只跟存活对象的数量成正比,跟死了多少没关系——所以新生代回收通常非常快,几毫秒到十几毫秒。
代价也很明显:只能用到一半空间。对象在 from 和 to 之间搬来搬去,搬过两次还活着的,就被晋升到老生代。这就意味着一个对象的生命周期越长,它越可能进入"贵"的那一区。
1.3 老生代标记清除与增量并发:停顿变短,但没有消失
老生代对象多、体积大,复制算法在这里不划算,V8 用的是标记-清除(Mark-Sweep)加标记-整理(Mark-Compact)。标记阶段要从根开始遍历整张引用图,找出所有可达对象;清除阶段把没标记的回收掉;碎片太多时再触发整理,把存活对象往一边挪。
如果标记全程暂停主线程,那页面就彻底卡住了。所以现代 V8 把标记拆开做了几件事:
- 增量标记:把标记任务切成小片,穿插在 JS 执行之间做,每次只做一点点。
- 并发标记:把标记放到辅助线程上去跑,主线程继续执行 JS。
- 并行清扫:多个辅助线程同时清理死对象。
- 惰性清扫:需要分配新内存时才顺手清一块。
听起来很美好,但有两个现实约束要知道。第一,写屏障机制要求 JS 在修改对象引用时额外做一次记录,把跨代引用写进"记忆集",这个开销是实打实的;第二,增量标记必须保证"三色不变式",所以当 JS 代码修改引用时,某些对象会被强制拉回重新标记,极端情况下标记时间会被拉长。
| 维度 | 新生代 | 老生代 |
|---|---|---|
| 空间大小 | 小(十几 MB 级别) | 大(默认上限 1.4G~2G 量级) |
| 回收算法 | Scavenge 复制 | 标记-清除 + 标记-整理 |
| 暂停时间 | 短,通常几毫秒 | 长,大堆可能几十到几百毫秒 |
| 触发频率 | 高 | 低 |
| 主要风险 | 晋升过快,把压力推给老生代 | 单次停顿长,拖慢交互 |
有人喜欢拿其他运行时的分代模型来类比,思路确实相通:都是"分代 + 复制 + 标记整理"这一套。但 V8 面对的场景和 JVM 那类长时间驻留的服务端进程不太一样——浏览器页面要优先保证交互流畅,所以 V8 更激进地做增量与并发;服务端要保证吞吐,停顿容忍度更高。理解这个差异,才能明白为什么同一套调优思路不能照搬。
2. 泄漏不是"忘了释放":项目里五种真实的内存堆积现场
严格讲 JavaScript 没有"内存泄漏"这个词该有的样子——没有手动 free 这一步,何谈忘。真实情况是引用比你需要的活得更久。下面这五类是线上代码里出现频率最高的。
2.1 全局变量和模块级缓存
挂在window上的东西生命周期等于页面生命周期。SPA 里最典型的一幕是:某个页面把请求结果塞进模块级数组,路由切走之后数组没清,切回来再塞一遍,来回几次就是几百万条数据。
// 路由切换不会清空它,每次进入页面都 push 一遍 const pageCache = []; export function onPageEnter() { pageCache.push(fetchBigList()); // 三进三出,内存三倍 }这里的判断标准很简单:模块级变量就是长生命周期,外部调用多少次,它就可能累积多少。模块级缓存必须配淘汰策略,要么限长(保留最近 N 条),要么用WeakMap,要么在离开页面时显式清空。
2.2 闭包比你想象的能留东西
闭包的保留能力经常被低估。V8 会做上下文优化,只保留函数实际用到的外部变量,但问题是——只要有一个变量被保留,和它共享同一个上下文的变量也可能被一起留下。
function build() { const bigData = new Array(1e6).fill({ payload: 'x' }); const len = bigData.length; // 这个闭包只用到 len,但它的词法环境里还有 bigData return function report() { return `共 ${len} 条`; }; }V8 的优化在这里是看具体实现而定的,不能指望它一定把bigData剔除。稳妥的做法是在闭包外把需要的数据提前提取成原始值,只把原始值交给闭包。
function build() { const bigData = new Array(1e6).fill({ payload: 'x' }); const len = bigData.length; bigData.length = 0; // 用完立刻断掉 return () => `共 ${len} 条`; }2.3 事件监听和订阅没解绑
addEventListener是最隐蔽的一类。回调函数本身在堆上,它捕获的变量也在堆上,如果它又引用了某个 DOM 节点和一份数据,那整条链都活着。组件卸载时忘了removeEventListener,节点被移除、变量置空都没用,因为监听器还挂在那儿。
现在有更省事的写法——用AbortController把同一个组件里所有的监听、请求、定时器收口成一个信号,卸载时abort()一次全断:
const controller = new AbortController(); const { signal } = controller; window.addEventListener('resize', onResize, { signal }); document.addEventListener('visibilitychange', onVisibility, { signal }); fetch('/api/data', { signal }); // 组件卸载 function destroy() { controller.abort(); // 一次收口,监听和请求全部解绑 }提示:
AbortController适用于addEventListener、fetch、以及不少现代 API,只要它们接受signal选项。这是目前最不容易漏的解绑方式。
2.4 游离 DOM:节点删了,内存为什么不降
把节点从 DOM 树上remove()之后,如果 JS 变量、数组、Map、Set里还留着这个节点的引用,它就会变成游离节点(detached node)。游离节点不光自己占内存,它下面的整棵子树都跟着活着。
最常见的一行代码是:
// items 数组一直留着节点引用,节点虽然不在 DOM 里,但内存不释放 items.push(createRow(data)); container.appendChild(items.at(-1));Chrome 开发者工具里有一个专门的面板就叫Detached Elements,做得非常直白:点一下收集,它会把所有游离的 DOM 节点列出来,并给出保留它们的引用路径。排查这一步,基本不需要猜。
2.5 无上限缓存、Map 强引用、未清理的定时器和 Promise 链
剩下的三类问题经常混在一起出现:
Map的 key 是对象时,Map会强引用这个对象,即使外部已经没有引用,只要Map还在,对象就活着。这种场景换WeakMap更合适。setInterval没有clearInterval,回调里的闭包就一直被定时器任务持有。- 长期挂起的 Promise 链也是重灾区。链上的
then回调闭包捕获了大对象,而 Promise 一直处于 pending 状态,回调永远不会被调用,但也不会被释放。
// 每次调用都新建一个永不 settle 的 Promise,闭包里的大对象一直挂着 function waitForever() { const payload = new Array(1e5).fill('x'); return new Promise((resolve) => { // 没有 resolve 的出口,闭包里的 payload 就留在这儿 setTimeout(() => use(payload), 3600_000); }); }排查这类问题不用什么高级工具,把setInterval、setTimeout、addEventListener、new Map()这几个关键词在项目里全局搜一遍,逐个确认有没有配对的清理逻辑,命中率往往比随机翻代码高得多。
3. 三张堆快照定位泄漏:从"感觉有泄漏"到"就是它"
开发者工具的 Memory 面板功能不少,但真正能定性的手段就一个:对比堆快照。做法固定,关键是采样姿势要对,不然数据白看。
3.1 快照的取样顺序
先开一个无痕窗口,把所有扩展禁用掉——扩展脚本会污染堆数据,这个坑踩过一次就记住了。然后按这个顺序操作:
- 打开页面,等到稳定状态(数据加载完成、动画停下)。
- 点一下垃圾桶图标强制 GC(或者点 Memory 面板左上角的垃圾回收按钮)。
- 拍第一张快照,作为基线。
- 执行一次你怀疑泄漏的操作(比如切换路由来回 5 次)。
- 再强制 GC,拍第二张快照。
- 重复第 4、5 步,拍第三张快照。
为什么至少要三张:单张快照看不出趋势,两次对比又分不清"一次性初始化占用"和"每次操作都涨"。
| 现象 | 结论 |
|---|---|
| 三次快照对象数基本持平 | 没有泄漏,数据量正常 |
| 第二张比第一张多,第三张和第一张差不多 | 大概率是缓存预热,不是泄漏 |
| 每张都比上一张多一截,且增量接近 | 泄漏,增量部分就是嫌疑对象 |
注意:拍快照前必须强制 GC。否则堆里塞满了"已经死但还没回收"的临时对象,对比出来的数据全是噪声。
3.2 Comparison 视图里真正要看的两列
把视图切到Comparison,选第二张和第一张比。列表里最重要的是Delta和Size Delta:
Delta:对象数量变化。同样的构造函数从 100 涨到 5000,基本实锤。Size Delta:占用字节变化。数量不多但体积暴涨的,通常是某个大数组或者超长字符串。Retained Size:这个对象连同它独占引用的所有对象加起来占多少。这个数才是真正"干掉它就能省下多少"的答案,比Shallow Size有参考价值得多。
选中一行可疑的构造函数,下方会展开它创建的所有实例。然后切到Retainers面板,这里显示的是"谁在引用它"的完整路径。顺着往上走,最后一定会停在一个根对象上——那个根,就是你要动的地方。
我看到过的真实案例里,保留路径长这样:
window └─ __chartInstances (Array) └─ [3] Chart └─ _dom (HTMLDivElement) // 已经被 remove,但实例还持有引用 └─ ...整棵子树根因是图表库实例没有dispose(),组件卸载时只删了 DOM。这种问题换任何工具都一样,最终都要落到"谁在引用它"。
3.3 Allocation instrumentation on timeline:看"什么时候产生"
堆快照看的是结果,Allocation instrumentation on timeline看的是过程。开始录制之后正常操作页面,时间轴上会出现一排柱子,蓝色柱子表示这一时刻分配的对象现在仍然存活,灰色柱子表示已经回收。
操作一遍进入页面、退出页面,如果每次进入都留下一条蓝色柱子,退出时柱子不消失,那泄漏点就在进入流程里。这个视图的好处是能把内存增长和具体交互动作对应起来,误差比凭感觉猜小得多。
代价要说清楚:这个模式需要在每次分配时记录栈信息,会明显拖慢页面,不适合长时间录制,录十几秒就够。如果只想看趋势不想被拖慢,用Allocation sampling,它按采样记录,开销小,但看不到完整的分配栈。
3.4 线上没有开发者工具怎么办
线上不可能开面板,所以得自己埋点。浏览器端可以用performance.measureUserAgentSpecificMemory(),它返回当前页面的内存估算值,比早已非标准化的performance.memory更靠谱。不过这需要一个前提:页面必须处于跨域隔离环境(响应头带Cross-Origin-Opener-Policy: same-origin和Cross-Origin-Embedder-Policy: require-corp)。这个条件能满足的话,隔一段时间采一次上报,就能在监控上看到真实的内存曲线。
async function reportMemory() { if (!crossOriginIsolated) return; const { bytes } = await performance.measureUserAgentSpecificMemory(); navigator.sendBeacon('/monitor/mem', JSON.stringify({ bytes })); } setInterval(reportMemory, 60_000);拿不到跨域隔离条件时,退一步用几个替代指标:performance.now()配合长任务观察器PerformanceObserver统计长任务时长与频率;用requestAnimationFrame测量帧间隔,帧率从 60 掉到 40 且内存同步上涨,基本能定位到问题区间。
Node 端就直观多了,process.memoryUsage()一次给你五个数:
const m = process.memoryUsage(); // rss 进程实际占用的物理内存,包含堆外和共享库 // heapTotal V8 申请的堆总量 // heapUsed V8 实际使用的堆量,关注这个 // external C++ 对象绑定的内存,Buffer 大多算在这里 // arrayBuffers ArrayBuffer 占用的部分 console.log(m.heapUsed / 1024 / 1024, 'MB');要留证据的话,v8.writeHeapSnapshot()能直接落盘一个.heapsnapshot文件,用开发者工具打开就能像分析浏览器堆一样分析服务端堆,heapTotal一路涨到--max-old-space-size上限然后进程挂掉,这类问题基本都能这样定位。
4. 让代码跑得更快:从对象形状到时间切片
内存和性能是一件事的两面。可达对象越多,标记阶段越长;主线程越堵,GC 的增量任务越难插进去。下面这些写法的收益都很直接。
4.1 对象形状、隐藏类与内联缓存
V8 给每个对象挂了一个隐藏类(内部叫Map,跟 ES6 的Map不是一回事),记录这个对象有哪些属性、各自在内存里的偏移量。属性结构相同的对象共用一个隐藏类,读取属性时 V8 可以直接按偏移量取值,这叫**内联缓存(Inline Cache)**命中。
一旦属性结构变来变去,隐藏类就会分叉,读取属性退化成查字典,性能差好几倍。
// 不推荐:属性顺序不一致,形状分裂 const a = { x: 1, y: 2 }; const b = { y: 2, x: 1 }; // 不推荐:先创建空对象再逐个加属性,每次加属性都换一次隐藏类 const c = {}; c.x = 1; c.y = 2; // 推荐:构造函数里一次性初始化全部字段,顺序保持一致 function Point(x, y) { this.x = x; this.y = y; }还有两个细节:
- 不要用
delete删属性。delete obj.x会把对象降级成字典模式,后续所有属性访问都变慢。真要"删",赋undefined或null。 - 属性尽量别越加越多。一个对象在生命周期里不断加新字段,隐藏类链会越来越长,读属性时不得不沿着原型链找。
4.2 数组与类型化数组的取舍
V8 对数组分了六种元素类型:PACKED_SMI、PACKED_DOUBLE、PACKED_ELEMENTS,以及对应的HOLEY_版本。装整数的数组最快,装了对象就慢一档,变成稀疏数组(有洞)再慢一档,而且这个降级是单向的——一旦变成HOLEY_ELEMENTS,哪怕后来把洞补上,也不会自动升回去。
const arr = [1, 2, 3]; // PACKED_SMI,最快 arr.push(4.5); // 升级成 PACKED_DOUBLE arr.push('text'); // 升级成 PACKED_ELEMENTS const arr2 = [1, 2, 3]; delete arr2[1]; // 变成 HOLEY,永久降级 arr2[100] = 1; // 大跨度赋值同样制造洞真正要处理几十万上百万个数字时,用类型化数组,内存和速度都不是一个量级:
const n = 1e6; const typed = new Float64Array(n); // 8MB,定长,无装箱 const plain = new Array(n).fill(0); // 元素是数字,但每个都要装箱/拆箱浏览器端还有一个更省内存的思路:如果数据是同一个结构、字段固定,可以考虑列式存储——把每个字段拆成一个类型化数组,而不是一万个对象。对象的固定开销(隐藏类指针、属性存储在对象内部时还会占额外空间)在数据量大时相当可观。
4.3 主线程拥堵治理:分片、时间切片与 Worker
主线程被一个 200ms 的同步循环占住,浏览器就没法响应输入、没法更新画面,GC 的增量任务也塞不进来。业界的判断基准是超过 50ms 的任务就算长任务。
治理长任务有三个层次,从轻到重:
第一层,分片执行。把大循环拆成一批一批,每批做完把控制权交回浏览器:
function processInChunks(items, chunkSize = 500) { let i = 0; function run() { const end = Math.min(i + chunkSize, items.length); for (; i < end; i++) heavyWork(items[i]); if (i < items.length) { requestIdleCallback(run, { timeout: 200 }); // 或者用 setTimeout(run, 0) / scheduler.postTask(run, { priority: 'background' }) } } run(); }requestIdleCallback会在浏览器空闲时执行,timeout保证最迟也会执行,不至于拖太久。如果环境支持scheduler.postTask,它给的优先级控制更细。
第二层,把纯计算搬到 Web Worker。数据解析、加密、图片处理、大数组排序这类活儿,放到 Worker 里做,主线程完全不受影响。传数据时用结构化克隆或者更快的Transferable:
// 主线程 const worker = new Worker('./calc.worker.js'); const buffer = new Float64Array(1e6); worker.postMessage(buffer, [buffer.buffer]); // 转移所有权,零拷贝 // calc.worker.js self.onmessage = (e) => { const data = e.data; // 处理完成后回传 self.postMessage(data.length, [data.buffer]); };用 Transferable 之后,主线程那边的buffer会变成不可用(byteLength变成 0),这是正常的,别以为代码写错了。
第三层,渲染层面做虚拟列表。一次渲染一万个 DOM 节点,不管怎么优化都是灾难。虚拟列表只渲染可视区加少量缓冲区,节点总数从一万降到几十,内存和渲染时间同步下降。这是长列表场景收益最大的一招。
4.4 字符串、DOM 与批量更新
字符串拼接在现代引擎里已经优化得很好了,+=不一定比数组join慢,具体要实测。但有两类情况还是要注意:一是在超大文本上循环拼接(比如生成几十兆的 CSV),此时用数组收集再join('')或者边生成边分片写入更稳;二是反复拼接同一个长字符串用于日志,每次都产生新的中间串,临时对象数量会很夸张。
DOM 操作的核心原则只有一条:批量改,别一次一次改。每改一次样式都可能触发重排,长列表里逐个追加节点同样会反复触发布局计算。
// 次数多的时候,用 DocumentFragment 一次性插入 const frag = document.createDocumentFragment(); for (const item of list) { const li = document.createElement('li'); li.textContent = item.name; frag.appendChild(li); } ul.appendChild(frag); // 只触发一次插入读取布局属性(offsetTop、offsetHeight、getBoundingClientRect)同样会强制刷新渲染队列,读写混在一起就是典型的布局抖动。正确做法是先集中读、再集中写:
// 先读 const heights = items.map((el) => el.offsetHeight); // 再写 items.forEach((el, i) => { el.style.height = `${heights[i]}px`; });4.5 弱引用与显式断引用
WeakMap的 key 是对象,key 被回收时对应的条目也会消失,天然适合做"给对象挂私货"的场景:
const meta = new WeakMap(); function attach(el, info) { meta.set(el, info) // el 被移除且无其他引用时,这条记录自动消失 }WeakRef能让你持有一个"不阻止回收"的引用,但不要依赖它做业务逻辑——对象什么时候被回收完全由 GC 决定,可能在下一轮 Scavenge,也可能很久之后。FinalizationRegistry同理,它的回调执行时机没有任何保证,只能用来做兜底清理或者埋点,不能用来做状态管理。
提示:
WeakRef和FinalizationRegistry的正确用法是"优化",不是"正确性"。需要确定性释放的场景,老老实实手写一个dispose()方法,在卸载时调用。
5. 一次长列表页的完整排查还原
说个具体的过程,比抽象讲原理有用。
5.1 现象和第一轮假设
一个数据看板页面,左侧是长长的任务列表(大概 2000 行),右侧是实时图表。用户反馈"开了半小时就卡,最后要重启浏览器"。当时的假设有三条:列表数据太多、图表的定时刷新没关、WebSocket 消息堆积。三条全错。
5.2 用三快照把范围缩小
在无痕窗口里,进入页面 → 强制 GC → 快照 1 → 来回切换两次路由(每次回来看板都会重新挂载)→ 强制 GC → 快照 2。对比结果非常清楚:
| 构造函数 | Delta | Size Delta |
|---|---|---|
Chart | +6 | +1.2 MB |
TaskRow | +4000 | +9.8 MB |
HTMLDivElement | +4200 | +6.1 MB |
Array | +120 | +2.4 MB |
TaskRow每次切换路由增长 2000 个,正好是列表长度。说明上一次的列表实例没被回收。
5.3 顺着 Retainers 找到根
选中一个TaskRow实例,看保留路径,最后停在:
window └─ __DASHBOARD_STATE__ (Object) └─ rows (Array, 长度 4000+) └─ [0] TaskRow └─ el (HTMLDivElement, 已 detached)问题串起来了:页面初始化时把rows挂到了全局的__DASHBOARD_STATE__上(早期为了调试方便),路由切走时既没清空数组,也没断开每个TaskRow里的 DOM 引用。列表组件本身是"清楚"的,它每次挂载都创建了新实例,但全局对象把旧的一批全留住了。
同时发现图表实例每次挂载新增 3 个,是因为图表库的定时刷新用的是setInterval,组件卸载没有调dispose()。
5.4 改法和验证
改动其实很小,一共三处:
// 1. 卸载时清掉全局调试引用(或者干脆别挂全局) function onUnmount() { window.__DASHBOARD_STATE__?.rows?.length = 0; delete window.__DASHBOARD_STATE__; } // 2. 图表实例统一管理并释放 const charts = []; function mountChart(el, options) { const c = new Chart(el, options); charts.push(c); return c; } function unmountAll() { while (charts.length) charts.pop().dispose(); } // 3. 列表行卸载时断开 DOM 引用 function releaseRow(row) { row.el = null; row.data = null; }改完再跑同一套快照流程:三张快照的TaskRow数量分别是 0、2000、0,HTMLDivElement不再累积,半小时压测下来heapUsed稳定在 180MB 上下,不再单调上涨。
5.5 沉淀下来的自查项
这次之后我把检查项写成了一张表,每次改动内存敏感模块时对着过一遍:
| 检查项 | 判断标准 |
|---|---|
| 模块级变量是否无限增长 | 有没有长度上限或淘汰策略 |
| 事件监听是否配对解绑 | 组件卸载路径上能否找到解绑代码 |
| 定时器是否清理 | 每个setInterval有没有对应的clear |
| 第三方实例是否释放 | 图表、编辑器、播放器有没有调dispose/destroy |
| 全局对象是否留引用 | 调试用的全局变量上线前是否移除 |
| 缓存是否限长 | Map/数组缓存有没有 LRU 或最大条数 |
| 闭包捕获是否过大 | 闭包内是否引用了超出所需的大对象 |
6. Node 服务端的内存看护
浏览器和服务端的排查思路一样,但观察方式完全不同。服务端没有开发者工具面板,全靠日志和快照文件。
6.1 heapUsed 涨不等于泄漏
先把几个指标的关系理清楚,不然很容易自己吓自己:
rss是进程实际占的物理内存,包含 V8 堆、C++ 原生对象、Buffer、以及共享库。rss高但heapUsed平稳,多半是 Buffer 或者堆外内存。heapUsed才是 JS 对象占的部分。它呈锯齿状上下波动是正常的——每次 GC 会掉下来一截,然后再涨上去。- 真正要警惕的是锯齿的谷底在逐步抬高。谷底抬升意味着"GC 都收不掉的存活对象"越来越多,这才是泄漏。
// 每分钟采一次,重点看谷值趋势而不是瞬时值 setInterval(() => { const { heapUsed, rss, external } = process.memoryUsage(); metrics.gauge('node.heapUsed', heapUsed / 1024 / 1024); metrics.gauge('node.rss', rss / 1024 / 1024); metrics.gauge('node.external', external / 1024 / 1024); }, 60_000);6.2 一次性读入大文件是最常见的坑
把几百兆的日志文件readFileSync全读进来,再split('\n'),内存瞬间翻好几倍——原字符串一份,切出来的数组一份,每个小字符串还要各自的头部开销。这种问题在本地测小文件时完全看不出来。
// 不推荐:整个文件进内存 const content = await fs.promises.readFile(path, 'utf8'); const lines = content.split('\n'); // 推荐:流式处理,内存占用和文件大小无关 const rl = readline.createInterface({ input: fs.createReadStream(path, { encoding: 'utf8' }), crlfDelay: Infinity, }); for await (const line of rl) { handleLine(line); }同样的道理适用于请求体、数据库查询结果、日志上报。能用流就别用一次性读入,能分页就别一次查全表。
6.3 抓一份堆快照来定位
怀疑泄漏但看不出是哪块时,抓快照:
# 方式一:代码里直接落盘 node -e "require('v8').writeHeapSnapshot('/tmp/heap.heapsnapshot')" # 方式二:启动时开 inspector,用开发者工具远程连 node --inspect=0.0.0.0:9229 server.js拿到的.heapsnapshot文件有时会非常大,抓之前可以先global.gc()一次(需要--expose-gc启动参数),能省下不少体积。
6.4--max-old-space-size怎么调
调大堆上限只是把问题往后推,不是解决。它的真正用途是给有明确大内存需求的场景留余量,比如构建工具、大数据量的离线脚本:
node --max-old-space-size=4096 build.js设置这个值的经验:先看压测时heapUsed的峰值,在此基础上留 30%~50% 余量。设得过大,GC 触发点被推后,单次停顿会变长,反而拖慢响应;设得过小,进程频繁触发全堆回收,CPU 占用会明显上升。
我在实际项目里的做法是,把"内存回归"当成发布前的固定动作:拿一套标准脚本跑十遍核心流程,录下三张堆快照做对比,任何一张的Delta出现单调增长就拦住发版。这套流程花不了十分钟,但拦下来的问题,往往都是用户侧跑满一整天才会暴露、而后端日志里什么都看不见的那种。