JavaScript · V8 内存与垃圾回收

大多数对象朝生夕死 — 新生代"复制"快进快出, 老生代"标记"精细清点; 而泄漏永远不是 GC 的错, 是引用还活着

晋升: 活过一轮的 新生代 New Space (小·快·频繁) From ■ 存活 ■ 死亡 对象出生在这 To Scavenge: 存活者 复制到 To, 死亡不搬 随后 From/To 交换角色 假设: 绝大多数对象活不过一次回收 复制少量存活者, 速度极快, 代价是空间减半 老生代 Old Space (大·慢·三色并发标记) 长命对象: 全局配置 · 缓存 · 组件树 标记(白→灰→黑) → 清除死块 → 整理碎片 并发标记几乎不停世界, 只在收尾/整理有短暂 STW 写屏障: 并发期间 Mutator 改引用也不漏标 泄漏四大源头 ① 定时器 / 事件监听器 忘 clear / remove, 闭包拽住一切 ② 全局缓存无淘汰 Map 只进不出, 模块级状态累积 ③ 闭包持有大环境 函数活着, 抓住的环境就活着 ④ Detached DOM JS 变量拽着已从页面移除的节点 排查三板斧: Heap Snapshot 对比 (DevTools → Memory) ① 基线快照 页面刚加载/空闲时拍 snapshot A 先点垃圾桶强制 GC, 排除浮动垃圾 记录构造器维度的对象计数 (强制 GC 让"还没回收的"不干扰对比) ② 复现可疑操作 N 次 打开/关闭弹窗 ×5, 切换路由 ×5 再做一次完整交互后回到空闲 "按倍数增长"的操作才是嫌疑 (单次增长可能是缓存, 多次线性增长是泄漏) ③ Comparison 找增量 snapshot B − A: 看 #Delta 最大构造器 点开对象看 Retainers 引用链 找到"谁拽着它" → 掐断那条引用 detached 节点搜Detached / distance 最近 GC 根 Node 侧: node --expose-gc + heap snapshot API · rss 看进程占用 · process.memoryUsage() 里 heapUsed 才是 V8 堆 容器里 RSS 与 heap 差值 = 线程栈/Buffer/代码元数据, K8s OOM 别只盯 heapUsed

分代假说驱动设计

  • • 大多数对象朝生夕死 → 新生代复制回收
  • • 存活率低时"复制"远快于"标记"
  • • 活过一轮的晋升老生代接受精查
  • • 空间换时间: 半空间只有一半可用

GC 不背泄漏的锅

  • • 能被引用链摸到的对象 GC 绝不回收
  • • 泄漏 = 无用对象还挂在引用链上
  • • 四源头: 监听/定时器/闭包/Detached
  • • WeakMap/WeakRef 是"不阻止回收"的引用

排查靠对比不靠猜

  • • 快照 A → 操作 N 次 → 快照 B
  • • Comparison 看 #Delta 与 Retainers
  • • 内存曲线: 锯齿健康, 阶梯爬升有病
  • • Node 看内存曲线: rss vs heapUsed 分开看

💡 一句话理解

V8 把堆当成两个世界: 新生代是"产房+ ICU"—— 对象出生在 From 半空间, 回收时把还活着的几个复制到 To 半空间, 死的直接不管(倒掉整间房), 然后两间房交换名牌; 活过一轮的"壮年对象"被晋升进老生代。老生代住的都是长命住户, 用三色标记从 GC Roots 出发把能摸到的全染色, 摸不到的就是垃圾, 清掉后顺手整理碎片。

而内存泄漏从来不怪 GC — 它只认引用链: 只要你的定时器、事件监听器、闭包、全局 Map 里还拽着一个对象, 它就"有主"。所以泄漏排查永远是同一道题: 拍快照对比, 找到增量对象, 顺着 Retainers 找到"谁拽着它", 掐断那条引用。

🧠 必知必会 必考 & 必会

分代假说
对象要么早夭要么长寿; 按年龄分两代, 各用最省的回收策略, 而不是对全堆一锅端。
// 临时字符串/中间数组: 新生代, 几次分配就触发一次 Scavenge
// 全局配置/连接池: 老生代, 长期不动
Scavenge 复制
新生代 From/To 两半空间: 存活者复制到 To, 死者不搬, 完毕后互换。速度快, 空间利用率 50%。
const a = { n: 1 };   // a 在 From
// GC: a 活着 → 复制到 To; 临时垃圾原地"蒸发"
晋升条件
熬过一轮 Scavenge, 或对象过大(直接进老生代); 老生代回收代价高, 所以要"够格才进"。
let x = { big: new Array(1e6) };  // 大对象直接老生代
// 普通对象活过一轮 Scavenge 也晋升
三色标记
白=未访问(垃圾候选), 灰=自己访问到但引用没扫完, 黑=扫完确认活; 从 Roots 出发染黑, 结束后白色全清。
// Roots: 全局对象 / 当前栈上的变量 / 活动的 DOM 引用
// global → obj → child 链上全黑; 谁摸不到谁白
并发 + 写屏障
标记大部分与应用并发跑; 应用同时改引用可能"漏标", 写屏障把新写入的引用记下来补扫。
obj.x = newItem;   // 写屏障记下: newItem 待确认存活
// 没有它, 并发标记会漏掉这期间的新引用
STW 在哪
标记的启动与收尾、老生代整理阶段有短暂停顿; 频繁全量 GC(STW 叠加)表现为周期性掉帧。
// 症状: 每隔几秒动画集体掉帧 → 疑似 Major GC 频发
// 常见根因: 分配速率过高, 新生代频繁填满
Weak 三兄弟
WeakMap/WeakSet/WeakRef 持弱引用: 不阻止键对象被回收, 适合"挂在对象上的附加数据"。
const meta = new WeakMap();
meta.set(domNode, { clicks: 0 });  // domNode 移除后可回收
// 普通 Map 会拽住 domNode 永不回收 — 关键差异
Detached DOM
节点已从文档移除, 但 JS 变量/数组还引用它 → 整棵子树留在内存。排查搜 Detached。
let detached = [];
list.querySelectorAll('li').forEach(li => detached.push(li));
list.innerHTML = '';   // DOM 走了, detached 数组拽着整棵树
泄漏判定
单次操作后内存涨 = 可能是缓存; 同一操作重复 N 次后阶梯式不回落 = 泄漏。曲线"锯齿"是健康态。
// Performance 面板勾 Memory:
// JS Heap 曲线: 上升后跌回 ≈ 锯齿(正常) / 只升不降(泄漏)
Node 内存账本
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 其他部分
快照三板斧
基线快照 → 复现操作 N 次 → 对比快照; 关注 #Delta 与 Retainers, "距离"显示到 GC 根的最短引用链。
// DevTools Memory → Take snapshot (点垃圾桶先强制 GC)
// Comparison 视图按 #Delta 排序 → 点对象看 Retainers

🏭 生产实战 real world

场景 1 · SPA 用 8 小时内存翻 3 倍: 快照对比破案

客服后台开一天卡死。快照对比锁定 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。

场景 2 · 全局缓存无上限 → LRU 化

商品详情缓存 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 更稳

场景 3 · 第三方图表组件的监听器泄漏

每页挂 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]);

场景 4 · 一次性事件用 AbortSignal 自动清理

组件里挂 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();   // 关键: 一行摘掉全部
}, []);

场景 5 · Detached DOM: 虚拟列表的快照残留

无限滚动把每个离开视口的行缓存在 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);

场景 6 · Node: rss 涨 heapUsed 不涨 = Buffer 的锅

图片代理服务 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);

场景 7 · Worker 计算大数组: transferable 零拷贝

主线程把 500MB 数组 postMessage 给 Worker, 结构化克隆直接内存翻倍。转移所有权避免拷贝:

// 错: 克隆一份 → 内存 ×2
worker.postMessage(bigBuffer);
// 对: 转移所有权, 零拷贝, 原线程立即失去访问权
worker.postMessage(bigBuffer, [bigBuffer.buffer]);

场景 8 · K8s 里 Node 被 OOMKill: 堆上限错配

容器 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% 以下

场景 9 · WeakMap 做对象元数据: 天然不泄漏

给每个请求挂调试上下文, 用 Map 会拽住请求对象, 换 WeakMap 跟随生命周期:

const traceCtx = new WeakMap();
traceCtx.set(req, { start: performance.now(), id: genId() });
// 响应结束 req 被回收时, 元数据自动跟着回收
// Map 版本: 必须 finally 里 traceCtx.delete(req) 否则泄漏

场景 10 · 分配速率过高: 新生代频繁 GC 的治理

动画帧循环里每帧 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。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 忘 clearInterval — 定时器闭包拽着组件状态永不释放. 正解: 生命周期对称清理。
// 错: setInterval(poll, 1000);          // 没人清
// 对: const id = setInterval(...); onUnmount(() => clearInterval(id));
坑 2 · 忘 removeEventListener — 匿名函数根本解不掉. 正解: 存引用或 AbortSignal。
// 错: addEventListener('resize', () => h());   // 无法移除
// 对: const h2 = () => h(); add + remove 同一引用
坑 3 · 全局 Map 只进不出 — 模块级缓存无上限, 长会话必涨. 正解: LRU 上限 + TTL。
if (cache.size > 2000) cache.delete(cache.keys().next().value);
坑 4 · 闭包拽大数组 — 回调只需要个数字却引用整个数据集. 正解: 先提取标量。
// 错: () => render(rows[0])     // 整个 rows 被拽住
// 对: const first = rows[0]; () => render(first);
坑 5 · Detached DOM — JS 数组/变量缓存已移除节点, 整棵子树滞留. 正解: 缓存数据不缓存节点。
// 排查: 快照搜索栏输入 Detached
// 修复: rowCache 只存可重建的数据
坑 6 · console.log 保留大对象 — DevTools 打开时, 已打印对象被控制台拽住不回收. 正解: 调试后移除或打印摘要。
// 错: console.log(bigData)   // 生产 build 里也留着
// 对: console.log(bigData.length)  // 或 debug 级开关
坑 7 · WeakMap 键用原始值 — WeakMap 键必须对象, 传字符串直接 TypeError. 正解: 键用对象, 原始值键用普通 Map。
// 错: wm.set('user:1', data);   // → TypeError
// 对: wm.set(userObj, data);
坑 8 · 把 WeakRef 当缓存主力 — 弱引用随时可能被回收, 数据"说没就没". 正解: WeakRef 只做加速旁路, 真缓存走 LRU/TTL。
// 错: const cache = new WeakRef(expensive);
// 对: LRU Map + 上限淘汰; WeakRef 最多做二级提示
坑 9 · 隐式全局变量 — 忘写声明挂到 window, 永不回收. 正解: 严格模式/ESM + lint no-undef。
// 错: function f() { acc = []; }   // window.acc 泄漏
// 对: let acc = [];
坑 10 · SSR 模块级状态跨请求共享 — 每个请求的状态存模块闭包, 既串号又泄漏. 正解: 请求作用域(AsyncLocalStorage)。
// 错: 模块级 ctx = {} 每请求往里塞不清理
// 对: als.run({ ... }, handler) 请求结束整体丢弃
坑 11 · 字符串拼接驻留 — 循环 += 拼出 O(n²) 中间串, GC 跟不上. 正解: 数组 join 或流式写。
// 错: s += chunk;        // 每轮新串, 旧串等 GC
// 对: parts.push(chunk); s = parts.join('');
坑 12 · JSON.parse 大响应峰值 — 解析瞬间字符串+对象双份驻留. 正解: 流式解析或分页加载。
// 500MB JSON.parse → 峰值 1GB+
// 对: 后端分页/分片, 或 ndjson 逐行解析
坑 13 · 稀疏大数组 — arr[9999999]=1 直接把数组退化成字典, 内存放大. 正解: 连续索引或 Map。
const a = []; a[9999999] = 1;   // holey/dictionary 模式
// 对: new Map([[9999999, 1]])
坑 14 · new Array(n) 后忘填充 — 拿到的是空洞数组, map 不执行还误导. 正解: Array.from({length:n}, fn)。
// 错: new Array(100).map(fn)   // map 跳过空洞, 返回还是空的
// 对: Array.from({ length: 100 }, fn)
坑 15 · Map 键为对象不清理 — 会话结束后键对象被 Map 拽住. 正解: 生命周期结束 delete 或换 WeakMap。
// 对: try { ... } finally { sessionCache.delete(session); }
坑 16 · 大对象跨闭包被间接拽住 — 引用了小函数, 函数的外层环境里有大对象. 正解: 快照 Retainers 顺藤摸瓜找到中间引用。
// Retainers: bigData ← handler ← window.listener
// 掐断最上面那条: removeEventListener
坑 17 · 定时器轮询引用整棵组件树 — 1s 一次的轮询闭包拽着页面全部状态. 正解: 只捕获需要的字段。
// 错: setInterval(() => sync(state), 1000)
// 对: const id = state.id; setInterval(() => sync(id), 1000)
坑 18 · 快照没先强制 GC — 浮动垃圾干扰对比, #Delta 虚高. 正解: 拍照前点垃圾桶(Collect garbage)。
// DevTools Memory: 先 🗑 再 Take snapshot, 前后两次都要
坑 19 · 容器堆上限错配 — V8 按宿主机内存估上限, 容器里 OOMKill. 正解: --max-old-space-size 显式设为 limit 的 ~75%。
ENV NODE_OPTIONS=--max-old-space-size=768   # limit 1Gi
坑 20 · 只看 heapUsed 忽略 RSS — Buffer/external 不在堆里, rss 涨了 heap 却"正常". 正解: memoryUsage() 四项都看。
process.memoryUsage();  // rss / heapUsed / external / arrayBuffers