大多数对象朝生夕死 — 新生代"复制"快进快出, 老生代"标记"精细清点; 而泄漏永远不是 GC 的错, 是引用还活着
V8 把堆当成两个世界: 新生代是"产房+ ICU"—— 对象出生在 From 半空间, 回收时把还活着的几个复制到 To 半空间, 死的直接不管(倒掉整间房), 然后两间房交换名牌; 活过一轮的"壮年对象"被晋升进老生代。老生代住的都是长命住户, 用三色标记从 GC Roots 出发把能摸到的全染色, 摸不到的就是垃圾, 清掉后顺手整理碎片。
而内存泄漏从来不怪 GC — 它只认引用链: 只要你的定时器、事件监听器、闭包、全局 Map 里还拽着一个对象, 它就"有主"。所以泄漏排查永远是同一道题: 拍快照对比, 找到增量对象, 顺着 Retainers 找到"谁拽着它", 掐断那条引用。
// 临时字符串/中间数组: 新生代, 几次分配就触发一次 Scavenge // 全局配置/连接池: 老生代, 长期不动
const a = { n: 1 }; // a 在 From // GC: a 活着 → 复制到 To; 临时垃圾原地"蒸发"
let x = { big: new Array(1e6) }; // 大对象直接老生代 // 普通对象活过一轮 Scavenge 也晋升
// Roots: 全局对象 / 当前栈上的变量 / 活动的 DOM 引用 // global → obj → child 链上全黑; 谁摸不到谁白
obj.x = newItem; // 写屏障记下: newItem 待确认存活 // 没有它, 并发标记会漏掉这期间的新引用
// 症状: 每隔几秒动画集体掉帧 → 疑似 Major GC 频发 // 常见根因: 分配速率过高, 新生代频繁填满
WeakMap/WeakSet/WeakRef 持弱引用: 不阻止键对象被回收, 适合"挂在对象上的附加数据"。 const meta = new WeakMap(); meta.set(domNode, { clicks: 0 }); // domNode 移除后可回收 // 普通 Map 会拽住 domNode 永不回收 — 关键差异
let detached = []; list.querySelectorAll('li').forEach(li => detached.push(li)); list.innerHTML = ''; // DOM 走了, detached 数组拽着整棵树
// Performance 面板勾 Memory: // JS Heap 曲线: 上升后跌回 ≈ 锯齿(正常) / 只升不降(泄漏)
heapUsed 是 V8 堆; rss 还含 Buffer/线程栈/元数据; 容器 limit 看的是 RSS。 const m = process.memoryUsage(); log(m.rss / 1e6, m.heapUsed / 1e6); // 两都要看, 差值有故事
--max-old-space-size 显式声明。 node --max-old-space-size=2048 server.js
// K8s: limit 2Gi → 堆上限设 ~1.5Gi 留出 RSS 其他部分// DevTools Memory → Take snapshot (点垃圾桶先强制 GC) // Comparison 视图按 #Delta 排序 → 点对象看 Retainers
客服后台开一天卡死。快照对比锁定 UserRow 组件增量 5000 份, Retainers 指向一个全局数组:
// 泄漏点: 全局注册表只进不出 const mounted = []; export function mountUser(row) { mounted.push(row); } // 修复: 组件卸载时注销 export function unmountUser(row) { const i = mounted.indexOf(row); if (i >= 0) mounted.splice(i, 1); // 引用链断掉 }
回归方法: 路由切换 ×10 后快照对比, UserRow #Delta 应为 0。
商品详情缓存 Map 一天 40 万 key。加容量上限 + 淘汰最旧:
const cache = new Map(); // Map 有序: 便于 LRU const MAX = 2000; function put(k, v) { if (cache.has(k)) cache.delete(k); // 重插置新 cache.set(k, v); if (cache.size > MAX) cache.delete(cache.keys().next().value); } // 或直接用 lru-cache 库, 带 TTL 更稳
每页挂 4 个 chart, resize 监听闭包拽着整个 chart 实例与数据, 切换 20 次后 1.2GB:
useEffect(() => { const chart = initChart(el, data); const onResize = () => chart.resize(); window.addEventListener('resize', onResize); return () => { // 关键: 成对清理 window.removeEventListener('resize', onResize); chart.dispose(); // 组件内部监听一并释放 }; }, [data]);
组件里挂 3 个 window 监听器, 漏解绑一个就泄漏。用 AbortController 一键全摘:
useEffect(() => { const ac = new AbortController(); window.addEventListener('resize', h1, { signal: ac.signal }); window.addEventListener('scroll', h2, { signal: ac.signal }); document.addEventListener('click', h3, { signal: ac.signal }); return () => ac.abort(); // 关键: 一行摘掉全部 }, []);
无限滚动把每个离开视口的行缓存在 rowCache 便于回滚, 1 万行后 Detached 节点吃掉 800MB:
// 泄漏: 缓存的是 DOM 节点本身 rowCache.set(id, li); // 修复 1: 缓存"重建所需的数据"而不是节点 rowCache.set(id, { name, avatar, onClickId }); // 修复 2: 上限 + 淘汰, 只留视口附近的行 if (rowCache.size > 200) rowCache.delete(rowCache.keys().next().value);
图片代理服务 RSS 3GB 但 heapUsed 300MB。Buffer 分配在 V8 堆外, 走 8KB 池:
const m = process.memoryUsage(); // rss 3000MB, heapUsed 300MB, external 2600MB ← Buffer 在这 log(m.rss, m.heapUsed, m.external, m.arrayBuffers); // 治理: 大文件用流(pipe)不整读; 限制并发下载量 await pipeline(fs.createReadStream(p), res);
主线程把 500MB 数组 postMessage 给 Worker, 结构化克隆直接内存翻倍。转移所有权避免拷贝:
// 错: 克隆一份 → 内存 ×2 worker.postMessage(bigBuffer); // 对: 转移所有权, 零拷贝, 原线程立即失去访问权 worker.postMessage(bigBuffer, [bigBuffer.buffer]);
容器 limit 1Gi, V8 按宿主机 128G 内存把堆上限估到 32G, 满负荷时 RSS 冲破 limit 被杀:
// Dockerfile / deployment args: ENV NODE_OPTIONS="--max-old-space-size=768" # 关键: limit 1Gi → 堆 768MB, 留 25% 给 Buffer/栈/元数据 # 验证: 压测到满载, 观察 rss 稳定在 limit 的 85% 以下
给每个请求挂调试上下文, 用 Map 会拽住请求对象, 换 WeakMap 跟随生命周期:
const traceCtx = new WeakMap(); traceCtx.set(req, { start: performance.now(), id: genId() }); // 响应结束 req 被回收时, 元数据自动跟着回收 // Map 版本: 必须 finally 里 traceCtx.delete(req) 否则泄漏
动画帧循环里每帧 new 大数组, Scavenge 每秒触发几十次, 掉帧+CPU 高:
// 错: 每帧分配 function frame() { const pts = new Float32Array(1e4); compute(pts); } // 对: 复用同一块内存(对象池思想) const pts = new Float32Array(1e4); function frame() { pts.fill(0); compute(pts); requestAnimationFrame(frame); }
分配速率降为 0 后, 新生代 GC 几乎消失, P99 帧时间从 24ms → 8ms。
// 错: setInterval(poll, 1000); // 没人清 // 对: const id = setInterval(...); onUnmount(() => clearInterval(id));
AbortSignal。 // 错: addEventListener('resize', () => h()); // 无法移除 // 对: const h2 = () => h(); add + remove 同一引用
if (cache.size > 2000) cache.delete(cache.keys().next().value);// 错: () => render(rows[0]) // 整个 rows 被拽住 // 对: const first = rows[0]; () => render(first);
// 排查: 快照搜索栏输入 Detached // 修复: rowCache 只存可重建的数据
// 错: console.log(bigData) // 生产 build 里也留着 // 对: console.log(bigData.length) // 或 debug 级开关
// 错: wm.set('user:1', data); // → TypeError // 对: wm.set(userObj, data);
// 错: const cache = new WeakRef(expensive); // 对: LRU Map + 上限淘汰; WeakRef 最多做二级提示
// 错: function f() { acc = []; } // window.acc 泄漏 // 对: let acc = [];
AsyncLocalStorage)。 // 错: 模块级 ctx = {} 每请求往里塞不清理 // 对: als.run({ ... }, handler) 请求结束整体丢弃
// 错: s += chunk; // 每轮新串, 旧串等 GC // 对: parts.push(chunk); s = parts.join('');
// 500MB JSON.parse → 峰值 1GB+ // 对: 后端分页/分片, 或 ndjson 逐行解析
const a = []; a[9999999] = 1; // holey/dictionary 模式 // 对: new Map([[9999999, 1]])
Array.from({length:n}, fn)。 // 错: new Array(100).map(fn) // map 跳过空洞, 返回还是空的 // 对: Array.from({ length: 100 }, fn)
// 对: try { ... } finally { sessionCache.delete(session); }
// Retainers: bigData ← handler ← window.listener // 掐断最上面那条: removeEventListener
// 错: setInterval(() => sync(state), 1000) // 对: const id = state.id; setInterval(() => sync(id), 1000)
// DevTools Memory: 先 🗑 再 Take snapshot, 前后两次都要--max-old-space-size 显式设为 limit 的 ~75%。 ENV NODE_OPTIONS=--max-old-space-size=768 # limit 1GimemoryUsage() 四项都看。 process.memoryUsage(); // rss / heapUsed / external / arrayBuffers