---
title: "JavaScript GC 机制：分代、宿主和常见泄漏"
url: https://clean99.github.io/zh/2026/06/08/JavaScript-Garbage-Collection-Generations-Hosts-Leaks/
date: 2026.06.08
lang: zh
---

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

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

![JavaScript GC hosts and heap](/img/js-gc-runtime/gc-hosts-and-heap.png)

图：以 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](/img/js-gc-runtime/young-generation-scavenge.png)

图：新生代 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](/img/js-gc-runtime/old-generation-mark-sweep-compact.png)

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

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

## 参考资料

- [Trash talk: the Orinoco garbage collector](https://v8.dev/blog/trash-talk)
- [Orinoco: young generation garbage collection](https://v8.dev/blog/orinoco-parallel-scavenger)
- [Memory management - JavaScript | MDN](https://developer.mozilla.org/docs/Web/JavaScript/Guide/Memory_management)
- [WeakMap - JavaScript | MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap)
