有个 Node 服务,平时跑得好好的,但每隔三四天就会在凌晨被监控告警叫醒——进程内存(RSS)一路爬到几个 G,触发 OOM 被容器杀掉重启,然后又开始新一轮缓慢攀升。重启能"治好",所以一开始大家就靠定时重启糊弄过去了。直到有一次重启没赶上,服务在高峰期挂了十几分钟,我才被推上去彻底查清这件事。
那次排查让我对 JavaScript 的内存管理有了全新的认识。很多人以为 JS 有垃圾回收(GC)就不会内存泄漏——这是个天大的误解。GC 回收的是"不再被引用"的对象,可一旦你不小心让一个本该被释放的对象一直被某处引用着,GC 就永远不会动它,内存就这么一点点漏掉。JS 的内存泄漏不像 C++ 那样忘了 free,而是更隐蔽的"忘了断开引用"。这篇就把我这些年抓过的几类典型 JS 内存泄漏摊开讲:它们长什么样、为什么 GC 救不了、怎么用堆快照把它们揪出来、又该怎么从根上避免。
先认清:JS 内存泄漏的几张常见面孔
把这些年踩过和帮人查过的泄漏归类,会发现它们高度集中在几个模式上。它们的共性是——某个生命周期长的东西(全局对象、长存的闭包、未清理的注册表),意外地攥住了一个本该短命的对象不放。先把这几张面孔列出来,后面逐个拆解怎么查、怎么治:
| 泄漏模式 | 典型成因 | 触发场景 | 后果 |
|---|---|---|---|
| 未清理的定时器/监听器 | setInterval、addEventListener 注册了不注销 |
组件反复创建销毁 | 回调及其闭包永不释放 |
| 闭包意外捕获大对象 | 闭包引用了外层一个大变量 | 事件回调、缓存函数 | 大对象被闭包钉死 |
| 无上限的全局缓存 | 用普通 Map/对象做缓存,只增不删 |
按 key 缓存请求结果 | 缓存无限膨胀直至 OOM |
| 游离 DOM(detached DOM) | DOM 已移除,JS 仍持有其引用 | SPA 频繁切换页面 | 整棵子树无法回收 |
| 全局变量囤积 | 意外挂到 window/global 上 |
漏写 let、模块级数组只 push |
对象进了"永生区" |
这五类里,前三类在 Node 后端尤其高发,第四类是浏览器 SPA 的专属重灾区。但不管哪一类,根因都是同一句话:一条从 GC Root(全局对象、调用栈、活跃闭包)出发、本不该存在的引用链,把对象牢牢"挂"在了可达图上。要查泄漏,本质就是找到这条不该存在的引用链。下面先讲查它的核心方法——堆快照对比。
抓泄漏的核心武器:三次堆快照对比法
排查内存泄漏,凭肉眼读代码往往大海捞针,真正可靠的是用工具拍"堆快照(heap snapshot)"。我用得最顺手的是 Chrome DevTools 的 Memory 面板(Node 服务可以用 --inspect 启动后远程连上,或用 heapdump 落盘再载入分析)。而比"拍一张"更有效的,是三次快照对比法:它能精准过滤掉噪音,只暴露出"持续在涨、且回收不掉"的那部分对象。
这套方法的精髓在于"重复相同操作 + 主动 GC + 多次对比":同一个业务操作跑很多遍,正常的临时对象会在 GC 时被回收掉,只有泄漏的对象会一张快照比一张多、怎么 GC 都降不下去。DevTools 里把两张快照切到 "Comparison" 视图,按 "# Delta"(对象数量增量)排序,那些每轮都在正增长的构造函数,就是泄漏的源头。锁定可疑对象后,再点开它的 Retainers(引用链),一路往上看是谁在引用它——那条引用链的顶端,就是真正的泄漏元凶。下面从最高发的那类——定时器和监听器——开始拆。
泄漏模式一:注册了却忘了注销的定时器与监听器
开头那个 Node 服务的泄漏,根因就是这一类。代码里有个逻辑:每来一个长连接,就 setInterval 起一个心跳定时器,定时器的回调里引用了这个连接的上下文对象。连接断开时,大家清理了连接本身,却忘了 clearInterval。于是定时器还活着,它的回调闭包还攥着那个连接上下文,GC 就永远回收不掉——连接来了几万个,就漏了几万份上下文:
// ❌ 泄漏写法:注册了定时器/监听器,却没在销毁时清理
class Connection {
constructor(socket, ctx) {
this.ctx = ctx; // 一个不小的上下文对象
// 心跳定时器:回调闭包捕获了 this,从而钉住整个 Connection
this.timer = setInterval(() => this.heartbeat(), 5000);
emitter.on('broadcast', this.onBroadcast.bind(this)); // 全局事件监听
}
close() {
this.socket.destroy();
// 致命遗漏:timer 没清、监听没摘 → 这个 Connection 和它的 ctx 永远活着
}
}
// ✅ 正解:谁注册,谁在销毁时负责注销,把引用链彻底断开
class Connection {
constructor(socket, ctx) {
this.ctx = ctx;
this.timer = setInterval(() => this.heartbeat(), 5000);
this.onBroadcast = this.onBroadcast.bind(this); // 存下引用,才能精确移除
emitter.on('broadcast', this.onBroadcast);
}
close() {
this.socket.destroy();
clearInterval(this.timer); // ✅ 清掉定时器
emitter.off('broadcast', this.onBroadcast); // ✅ 摘掉监听器
this.ctx = null; // ✅ 主动断引用,帮 GC 一把
}
}
这类泄漏有个魔鬼细节:emitter.on('broadcast', this.onBroadcast.bind(this)) 这种写法——bind 每次都返回一个新函数,你后面拿 this.onBroadcast 去 off 根本摘不掉那个匿名的绑定函数,等于永远注销不了。正确做法是先把绑定后的函数存成一个属性,注册和注销用同一个引用。一条铁律记牢:任何"注册型"的 API——setInterval、setTimeout、addEventListener/on、订阅、观察者——都自带一个"注销"动作,注册的那一刻就要想好它在哪里被清理,成对出现,绝不能只注册不注销。
泄漏模式二:闭包悄悄捕获了一个大对象
第二类泄漏更隐蔽,它藏在 JS 最强大也最容易出事的特性——闭包里。闭包会捕获它定义时所在作用域的变量,只要这个闭包还活着(比如被注册成了某个长存的回调),它捕获的所有变量就都跟着活着,哪怕你实际只用到了其中一个小字段:
// ❌ 闭包意外钉住大对象:只用到 id,却让整个 hugeData 无法回收
function createHandler(record) {
const hugeData = record.rawBuffer; // 假设这是几 MB 的原始数据
const id = record.id;
// 这个回调被注册到长存的 emitter 上,会活很久
return function onEvent() {
console.log('handling', id); // 只用到了 id
// 但闭包作用域里有 hugeData,只要 onEvent 活着,hugeData 就被钉死
};
}
longLivedEmitter.on('tick', createHandler(bigRecord));
// ✅ 正解:闭包只捕获真正需要的最小数据,大对象用完即可被回收
function createHandler(record) {
const id = record.id; // 只提取需要的字段
// record / rawBuffer 不再被闭包引用,函数返回后即可被 GC
return function onEvent() {
console.log('handling', id);
};
}
这个坑的隐蔽之处在于:你的代码"看起来"只用了 id,但 JS 引擎为这个闭包保留的作用域里,只要词法上能访问到 hugeData,它就可能被一并保留。判断闭包会不会泄漏,要看两件事:这个闭包会活多久,以及它的作用域里攥着多大的东西。长存的闭包(注册到全局 emitter、挂在缓存里、作为模块级变量)配上一个大捕获,就是泄漏。治理的原则是"最小捕获":在创建闭包前,先把真正需要的小字段单独解构出来,别让闭包顺手把整个大对象的引用也带走。
泄漏模式三:只增不删的全局缓存,迟早把内存撑爆
第三类几乎是后端服务的标配泄漏。为了加速,大家爱用一个模块级的 Map 或对象做缓存:按某个 key 把计算结果或请求响应存下来,下次直接取。问题是——很多人只写了"存"和"取",从没写过"删"。于是这个缓存只进不出,key 的种类越多它就越大,在一个长命的服务里,它就是个缓慢但必然的内存炸弹:
// ❌ 无上限缓存:只 set 不 delete,key 越攒越多,永不收缩
const cache = new Map();
function getUser(id) {
if (cache.has(id)) return cache.get(id);
const user = expensiveLoad(id);
cache.set(id, user); // 只增不减 → 内存随 id 种类无限增长
return user;
}
// ✅ 方案一:用 LRU,给缓存定一个容量上限,挤出最久未用的
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({ max: 5000, ttl: 1000 * 60 * 10 }); // 限量+过期
function getUser(id) {
let user = cache.get(id);
if (user) return user;
user = expensiveLoad(id);
cache.set(id, user); // 超过 5000 条时自动淘汰最久未访问的
return user;
}
// ✅ 方案二:键是对象、且"对象活着才需要缓存"时,用 WeakMap
const meta = new WeakMap(); // key 被回收时,对应 entry 自动消失
function attachMeta(obj, data) {
meta.set(obj, data); // obj 一旦没人引用,这条 meta 也随之被 GC,零泄漏
}
这里的关键认知是:任何缓存都必须有"退出机制"——要么限容量(LRU),要么限时间(TTL),要么让它跟着 key 的生命周期自动消亡(WeakMap)。WeakMap 尤其值得单独说:它对 key 是弱引用,不会阻止 key 被 GC,一旦作为 key 的那个对象在别处没人引用了,这条记录会被自动清除——所以"给某个对象附加一些元数据、对象死了元数据也该死"这种场景,用 WeakMap 是天然防泄漏的。而用普通 Map 做缓存,你就必须自己背负"何时删除"的责任,一旦忘了,它就成了泄漏。
泄漏模式四:游离 DOM,移除了却没真正放手
第四类是浏览器 SPA 的专属坑。你从页面上移除了一个 DOM 节点(比如关闭一个弹窗、切走一个页面),以为它就被回收了——但只要你的 JS 代码里还有一个变量指着它,这个节点连同它的整棵子树都无法被回收,这就是"游离 DOM(detached DOM)":它已经不在文档树上,却还赖在内存里:
// ❌ 游离 DOM:节点从文档移除了,JS 还攥着引用,整棵子树回收不掉
const cache = {};
function openModal() {
const modal = document.createElement('div');
modal.innerHTML = buildHugeForm(); // 一大棵子树
document.body.appendChild(modal);
cache.lastModal = modal; // 把引用存进了长命的 cache
}
function closeModal() {
document.body.removeChild(cache.lastModal); // 从文档移除
// 致命遗漏:cache.lastModal 还指着它 → 游离 DOM,永远回收不掉
}
// ✅ 正解:移除节点的同时,清掉所有 JS 侧对它的引用
function closeModal() {
document.body.removeChild(cache.lastModal);
cache.lastModal = null; // ✅ 断开引用,整棵子树才能被回收
}
游离 DOM 在 DevTools 的 Memory 面板里很好认:拍快照后在过滤框里输入 "Detached",就能列出所有游离的 DOM 节点,再顺着它的 Retainers 看是哪个 JS 变量在死死拽着它。SPA 里这类泄漏的高发区是:全局缓存里存了 DOM、事件监听器的闭包里引用了 DOM、或者第三方库内部缓存了你传进去的节点。原则还是那条——从文档里移除一个节点时,要顺手把 JS 这边所有指向它的引用一起清掉,移除和断引用必须配对。
泄漏模式五:不知不觉囤进"永生区"的全局变量
最后一类泄漏最朴素,却也最容易被忽视:对象被意外地挂到了全局上,而全局对象(浏览器的 window、Node 的 global)是 GC 永远不会回收的根。一旦东西进了这个"永生区",就再也出不来了。最常见的两种进法,一是漏写声明导致意外全局,二是一个模块级的容器只往里塞、从不清理:
// ❌ 意外全局:漏写 const/let,赋值直接挂到了 window/global 上
function process(data) {
result = heavyTransform(data); // 忘了写 const!result 成了全局变量
return result; // 严格模式下会报错,正是它该报错的原因
}
// ❌ 模块级容器只增不减:日志/事件越堆越多,等于慢性泄漏
const allEvents = []; // 模块级,活得和进程一样久
export function track(e) {
allEvents.push(e); // 只 push 从不清,无限增长
}
// ✅ 用严格模式堵掉意外全局 + 给容器加上限和清理
'use strict'; // 意外全局直接抛错,当场暴露
const recentEvents = [];
export function track(e) {
recentEvents.push(e);
if (recentEvents.length > 1000) recentEvents.shift(); // 只保留最近 1000 条
}
这类泄漏的根治手段简单到容易被低估:全程开启严格模式('use strict' 或直接用 ES Module,它默认严格),意外全局会立刻变成一个 ReferenceError 而不是悄悄泄漏;任何模块级的数组、Map、对象,只要它会随运行不断增长,就必须配一个上限和淘汰策略。"模块级 + 只增不减"这个组合,几乎可以直接画等号到"慢性内存泄漏",看到就要警惕。
把内存盯起来:Node 端的持续监控与落盘分析
光会查还不够,得能在泄漏酿成事故前就发现苗头。Node 进程的内存状况可以用 process.memoryUsage() 直接读出来,把它定时打到监控里,RSS 或 heapUsed 的长期单调上升就是泄漏最可靠的信号——注意要看"长期趋势"而非一时波动,GC 本身就会让内存上下起伏:
// ✅ 定时采样内存,喂给监控系统,盯的是"长期趋势"而非瞬时值
setInterval(() => {
const m = process.memoryUsage();
metrics.gauge('mem.rss', m.rss); // 进程总占用(含堆外)
metrics.gauge('mem.heapUsed', m.heapUsed); // V8 堆内已用
metrics.gauge('mem.external', m.external); // Buffer 等堆外内存
}, 30_000);
// ✅ 收到内存告警时,自动落一份堆快照下来事后分析(生产可控触发)
import { writeHeapSnapshot } from 'v8';
process.on('SIGUSR2', () => {
const file = writeHeapSnapshot(); // 落盘 .heapsnapshot
console.log('heap snapshot written to', file); // 下载回本地用 DevTools 打开
});
这里的要点是把"被动救火"变成"主动观测":RSS/heapUsed 的长期趋势图,是判断有没有泄漏最直观的依据。一条锯齿状但整体平稳的曲线是健康的(GC 在正常工作),一条锯齿但基线持续抬高的曲线,几乎就是泄漏的铁证。配合 writeHeapSnapshot() 或 heapdump 在告警时自动落盘,你就能拿到"出事那一刻"的现场快照,而不必在生产上手忙脚乱地连调试器。线上还可以用 clinic.js、--inspect 远程连 DevTools 等工具做更深的剖析,但核心方法论不变,都是回到那套"对比快照、追 Retainers"。
回放那次排查:我是怎么揪出那个 setInterval 的
把方法套回开头那个 OOM 的服务,我的排查过程其实很线性。第一步,看监控的内存曲线——heapUsed 基线随时间稳定抬升,锯齿但不回落,确认是泄漏而非偶发高峰。第二步,在压测环境用固定脚本反复建立、断开长连接,这是"重复相同操作"。第三步,用三次快照对比法拍了三张,切到 Comparison 视图按 Delta 排序——Timeout 这个构造函数的对象数,每一轮都在精准地正增长,和我建立的连接数完全对得上。
// 排查时盯到的现象:Timeout 对象只增不减,数量 ≈ 累计建立的连接数
// Comparison 视图(快照2 vs 快照1)大致是这样:
// Constructor # New # Deleted # Delta
// Timeout 10000 0 +10000 ← 元凶:定时器没被清
// Connection 10000 0 +10000 ← 被 Timeout 的回调钉住
// Object (ctx) 10000 0 +10000 ← 顺着被钉死的上下文
// 点开某个 Timeout 的 Retainers,引用链清清楚楚:
// Timeout → callback(闭包) → Connection → ctx
// 顶端是一个永远不会触发 clearInterval 的 setInterval —— 真相大白
第四步,点开那批 Timeout 对象的 Retainers,引用链一眼看穿:定时器的回调闭包引用着 Connection,Connection 又攥着 ctx,而这个定时器从来没被 clearInterval 过。改动只有一行——在 close() 里补上 clearInterval(this.timer) 并摘掉监听、置空引用。上线后内存曲线立刻从持续抬升变成了平稳的锯齿,定时重启的"创可贴"也撤掉了。整个过程没靠任何灵感,全靠"趋势确认泄漏 → 重复操作 → 快照对比锁定增长对象 → Retainers 追引用链"这条固定的流水线。内存泄漏排查最反直觉的一点恰恰是:它其实是门有章可循的体力活,而不是玄学。
怀疑内存泄漏,照这棵树走一遍
把前面的排查方法和五类泄漏模式串起来,其实就是一套"从怀疑到定位再到根治"的固定流程。下次再遇到内存可疑地往上涨,别急着猜,照这棵树一步步走,绝大多数泄漏都逃不掉:
这棵树的入口判断最关键:不是"内存涨了就是泄漏",而是"基线是否持续抬高"。JS 的内存天然是锯齿状的——分配上去、GC 回收下来,只要基线长期平稳就健康。把这个前提搞清楚,能省掉大量的虚惊一场。确认是泄漏之后,后面"重复操作 → 快照对比 → 追 Retainers"这条路几乎是机械的,引用链顶端是谁,就对应着五类模式里的某一种解法。
沉淀成清单的几条防泄漏铁律
这套排查经历最后收口成了我们团队的几条内存规范,代码评审时按它逐条过一遍:
- 注册与注销必须成对:
setInterval/setTimeout、addEventListener/on、订阅、观察者,注册时就想好在哪注销;注销要用同一个函数引用,别用bind现造的新函数。 - 销毁时主动断引用:对象生命周期结束时,把它持有的大对象、DOM、回调显式置
null,帮 GC 一把,别指望它自己想明白。 - 闭包只做最小捕获:长存的闭包别顺手把整个大对象带进作用域,先解构出真正需要的小字段。
- 任何缓存都要有退出机制:限容量(LRU)、限时间(TTL),或用
WeakMap让它跟着 key 自动消亡;普通Map做缓存就必须自己负责删除。 - 移除 DOM 同步清引用:从文档移除节点时,把 JS 侧所有指向它的引用一起清掉,避免游离 DOM。
- 全程严格模式:用 ES Module 或
'use strict'堵掉意外全局;模块级的可增长容器一律加上限。 - 把内存趋势纳入监控:定时采样 RSS/heapUsed,盯基线是否长期抬升,告警时自动落堆快照,别等 OOM 才知道。
几个反复见到的认知误区
推广这套规范时,我发现几个误区几乎人人都有,值得专门点破。
第一个、也是最根本的误区:"JS 有 GC,所以不会内存泄漏"。这句话错在它把"自动回收"理解成了"绝不泄漏"。GC 的判定标准从来不是"你还要不要用这个对象",而是"这个对象还能不能从 GC Root 被引用到"。只要存在一条可达的引用链——哪怕是你早就忘了的一个定时器回调、一个全局数组里的旧元素——GC 就认为它"还有用",绝不回收。JS 的内存泄漏不是"忘了释放",而是"忘了断开引用",这是它和手动内存管理语言最大的不同,也是最容易让人放松警惕的地方。
第二个误区是"内存涨上去了就是泄漏"。很多人一看监控里内存曲线往上窜就慌。但 JS 的内存本就是锯齿波动的:对象不断分配,GC 周期性回收,曲线自然忽高忽低。真正的泄漏信号是基线(每次 GC 后的低点)长期单调抬高,而不是某一时刻的峰值。只看峰值、不看基线趋势,会把大量正常的内存波动误判成泄漏,白白浪费排查精力。
第三个误区是"手动 delete 或赋 null 能立刻释放内存"。置 null 的作用是断开一条引用,让对象变得"不可达",从而有资格被回收——但具体什么时候真正回收,是 GC 自己决定的,你控制不了时机。所以断引用是必要的(不断,GC 永远不会动它),但它不是"立即生效的 free"。理解这一点,就不会纠结于"我都置 null 了为什么内存没马上降",也不会去滥用那些试图"强制 GC"的歪招。
第四个误区是"WeakMap/WeakRef 是银弹,缓存全用它就不漏了"。弱引用确实优雅,但它有适用边界:WeakMap 的 key 必须是对象,且语义是"key 活着这条记录才有意义"。如果你的缓存 key 是字符串(比如用户 id),或者你就是想让缓存比 key 活得更久,那 WeakMap 根本不适用——这种场景该用的是带 LRU/TTL 的普通缓存。弱引用解决的是"附属数据跟随宿主对象消亡"的问题,不是"所有缓存"的通用解法,用错地方反而会让缓存提前失效、命中率暴跌。
写在最后
从那个三天一 OOM、只能靠定时重启续命的服务,到最后一行 clearInterval 让内存曲线彻底躺平,我最大的体会是:JS 的内存泄漏,本质上是一场"引用关系"的疏忽,而不是什么高深的底层问题。每一个泄漏的对象背后,都有一条你本以为已经断开、实际还连着的引用链——一个忘了清的定时器,一个攥着大对象的闭包,一个只进不出的缓存,一个移除了 DOM 却没松手的变量。它们的解法千篇一律地朴素:找到那条引用链,把它断掉。
所以别再被"有 GC 就不会漏"这句话麻痹了。真正该养成的习惯是:每当你注册一个回调、缓存一个对象、把什么东西挂到一个长命的地方时,顺手问自己一句——这东西什么时候、由谁来断开它?把这个问题在写代码的当下就回答掉,泄漏根本没有机会发生。而万一还是漏了,你也已经有了一条不靠运气、纯靠流程的排查路径:看趋势、重复操作、对比快照、追引用链。内存泄漏不可怕,可怕的是你既不防它、又不会查它,只能眼睁睁看着服务在每个深夜被 OOM 叫醒。
—— 别看了 · 2026