今天继续补 JavaScript 运行机制时,我发现 GC 很容易被说成一句空话:对象不用了,引擎会自动回收。
这句话没错,但太粗。真正有用的理解是:GC 回收的是从 roots 不可达的对象;新生代和老生代用的策略不同;同一套 JS 引擎放进浏览器和 Node,最容易出问题的地方也不一样。

图:以 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。这里的关键在于尽快处理大量短命对象,别每次都扫全堆。

图:新生代 GC 常用 Scavenge 思路。把 From-space 里还活着的对象复制到 To-space,死对象跟着整块旧空间一起丢掉,复制时顺手完成压缩。generated by gpt-image-2.
新生代适合 copying GC。假设 100 万个对象里只有 2 万个还活着,复制 2 万个活对象比扫描、清理、压缩 100 万个位置更划算。
对象如果连续几轮都活下来,就会被提升到 old generation。老生代里的对象死亡率没那么高,再用复制算法就不划算了:如果 90% 都活着,复制成本会非常重。

图:老生代更接近 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 版本的细节。版本会变,算法实现也会继续优化。
更稳定的是这几条:
- GC 看可达性,不看业务语义。
- 分代 GC 是用对象年龄换算法效率。
- 新生代复制活对象,老生代更关注标记、清扫、碎片和压缩。
- 对象会移动,移动后必须更新指针。
- 宿主会制造额外引用:浏览器是 DOM 和事件,Node 是进程级资源和 external memory。
- 泄漏排查先画引用链,再谈工具。
如果只能记一句,我会记这句:内存泄漏通常说明你还给 GC 留了一条路。