JavaScript GC 机制:分代、宿主和常见泄漏

Read in English

今天继续补 JavaScript 运行机制时,我发现 GC 很容易被说成一句空话:对象不用了,引擎会自动回收。

这句话没错,但太粗。真正有用的理解是:GC 回收的是从 roots 不可达的对象;新生代和老生代用的策略不同;同一套 JS 引擎放进浏览器和 Node,最容易出问题的地方也不一样。

JavaScript GC hosts and heap

图:以 V8 为例,GC 从 stack、global、closure 等 roots 出发追踪可达对象。浏览器宿主和 Node 宿主会通过 DOM、listener、Buffer、EventEmitter 等把对象继续留在图里。generated by gpt-image-2.

GC 不是回收“你觉得没用”的对象

GC 只看引用图。

如果一个对象还能从 stack、global、closure、模块缓存、DOM listener、EventEmitter、timer 或 pending async task 走到,它就是活的。业务上你觉得它“已经没用了”,对 GC 没意义。

这也是很多内存泄漏难查的原因。难点通常在引用链上:有人还在很远的地方引用它。

为什么要分新生代和老生代

分代的目的不是给对象贴标签。它是为了用不同算法处理不同年龄的对象。

GC 设计里有一个很实用的观察:大部分对象死得很快。一次渲染里的临时数组、map/filter 生成的中间对象、函数里临时创建的小对象,很多活不过下一轮 GC。

所以新对象先进 young generation。这里的关键在于尽快处理大量短命对象,别每次都扫全堆。

V8 young generation scavenge

图:新生代 GC 常用 Scavenge 思路。把 From-space 里还活着的对象复制到 To-space,死对象跟着整块旧空间一起丢掉,复制时顺手完成压缩。generated by gpt-image-2.

新生代适合 copying GC。假设 100 万个对象里只有 2 万个还活着,复制 2 万个活对象比扫描、清理、压缩 100 万个位置更划算。

对象如果连续几轮都活下来,就会被提升到 old generation。老生代里的对象死亡率没那么高,再用复制算法就不划算了:如果 90% 都活着,复制成本会非常重。

Old generation mark sweep compact

图:老生代更接近 Mark-Sweep-Compact。先标记可达对象,再把不可达对象放回 free list;如果碎片太多,再移动活对象并更新指针。generated by gpt-image-2.

这里最容易漏掉的是 compact。Sweep 之后内存可能变成一块一块的洞,能复用,但局部性差,也可能放不下大对象。Compact 会把活对象往一侧移动,把空闲空间整理成连续区域。代价是对象地址变了,所以引用这些对象的指针也要更新。

现代 V8 不会傻等一次超长 stop-the-world。Orinoco 里有 parallel、incremental、concurrent 这些手段,把一部分工作并行化、切片化或者放到后台做。核心目标很朴素:少卡主线程。

宿主不同,坑也不同

JavaScript 语言层说的是可达性,宿主决定了很多引用从哪里冒出来。

浏览器里,我会先看 DOM 和交互生命周期:

场景 典型引用链
detached DOM 全局数组或 Map 还保存着被移除的 DOM node
event listener listener 捕获大对象,节点移除后 listener 没清
timer / requestAnimationFrame 回调一直排着,closure 里的状态一直活
状态缓存 tab、路由、列表缓存没有上限

Node 里更常见的是长生命周期对象:

场景 典型引用链
Map 缓存 请求越多,cache key 越多,永远不淘汰
EventEmitter 每次请求都 on,最后 listener 堆到报警
Buffer / ArrayBuffer JS wrapper 不大,但 external memory 可能很大
pending Promise promise 不结束,closure 里的大对象一直被挂住

所以排查方向也不一样。浏览器先看页面生命周期和 detached DOM;Node 先看进程级缓存、连接、listener、Buffer 和 heap snapshot。

Map 和 WeakMap 的边界

WeakMap 很适合给对象挂 metadata,尤其是 DOM node 这类生命周期不完全由你控制的对象。

但它不是“自动防泄漏按钮”。只有 key 是弱引用,value 里如果又被别的地方强引用,照样活着。WeakMap 也不能枚举 key,因为一旦能枚举,你就能观察 GC 什么时候发生,语义会变得不确定。

我现在更愿意这样记:

结构 适合做什么
Map 明确拥有数据,需要遍历、统计、主动淘汰
WeakMap 对象附属 metadata,不想因为 metadata 延长对象生命周期
LRU / TTL 有业务生命周期的缓存,别把“缓存”写成永久仓库

我会记住的几件事

学 GC 不需要背每个 V8 版本的细节。版本会变,算法实现也会继续优化。

更稳定的是这几条:

  1. GC 看可达性,不看业务语义。
  2. 分代 GC 是用对象年龄换算法效率。
  3. 新生代复制活对象,老生代更关注标记、清扫、碎片和压缩。
  4. 对象会移动,移动后必须更新指针。
  5. 宿主会制造额外引用:浏览器是 DOM 和事件,Node 是进程级资源和 external memory。
  6. 泄漏排查先画引用链,再谈工具。

如果只能记一句,我会记这句:内存泄漏通常说明你还给 GC 留了一条路。

参考资料