「硬核 JS」你真的了解垃圾回收机制吗
本文保留原文从可达性、标记清除、引用计数、分代式回收到并行、增量、并发回收的学习路线,并把它们放回现代 JavaScript/V8 的实现边界中。不同引擎、版本和宿主的具体策略会变化,文章中的算法图和数值只能用于理解,不能当作 V8 的固定配置。
一、GC 是什么
GC(Garbage Collection,垃圾回收)是运行时自动发现并回收不再可达内存的机制。JavaScript 程序员通常不需要手动申请和释放普通对象内存,但仍然需要管理对象生命周期:只要全局变量、闭包、事件监听器、定时器、缓存或宿主资源继续持有引用,垃圾回收器就不能回收它。
垃圾回收不是“删除所有暂时没用的变量”,也不保证在变量离开某一行代码后立即执行。它通常根据**可达性(reachability)**判断对象是否仍可能被程序访问:
- 从运行时根开始可以到达的对象,暂时视为存活;
- 从根无法到达的对象,具备被回收的条件;
- 何时标记、清除、压缩和归还内存,由具体引擎决定。

图片来源:原文配图。已下载为本地图片。
二、垃圾是怎样产生的
let test = {
name: 'isboyjc'
}
test = [1, 2, 3, 4, 5]
第一次赋值后,变量 test 指向一个对象;重新赋值后,test 指向数组。如果没有其它引用指向原对象,它就可能在之后的 GC 周期中被回收:
let object = { name: 'old object' }
const keep = object
object = null
// keep 仍然指向原对象,因此它仍然可达
console.log(keep.name) // old object
“变量不再指向对象”不等于“对象立刻被释放”。是否还有其他引用、是否存在宿主资源、GC 是否已经运行,都需要纳入判断。

图片来源:原文配图。
三、标记清除(Mark-Sweep)
标记清除是追踪式垃圾回收的基础思路,通常分为:
- 从一组根开始遍历对象图,并标记遇到的对象;
- 扫描堆中的对象,回收没有被标记的对象;
- 根据需要清空标记,准备下一轮回收。
根的组成由引擎和宿主共同决定,可能包含活动执行上下文、全局对象、仍在运行的任务、模块环境和宿主持有的对象。不要把“浏览器的 window、整棵 DOM 树”当成所有宿主中的固定根集合。
根对象 → user → profile
→ cache → item
孤立对象 → oldData(没有从根可达的路径)

图片来源:原文配图。
3.1 碎片和整理
单纯清除后,存活对象可能分散在不同位置,空闲区域出现碎片。引擎可以使用标记整理(Mark-Compact)或其它分配策略,将存活对象移动到更紧凑的位置并更新引用。

图片来源:原文配图。

图片来源:原文配图。
原文讨论 First-fit、Best-fit 和 Worst-fit,它们是通用内存分配器中的历史策略;现代 JavaScript 引擎会使用分区、空闲列表、页和专用分配路径,不能据此推导 V8 的实际分配复杂度。GC 也不一定每轮都压缩整个堆,因为移动对象需要更新引用并带来额外成本。
四、引用计数:历史算法与循环引用
引用计数给对象维护一个计数:引用增加时加一,引用解除时减一,计数为零时回收。它的优点是简单、可以较快发现零引用对象;缺点是更新成本、计数溢出问题和循环引用。
let a = { name: 'A' }
let b = a
a = null
b = null
// 如果没有其它引用,原对象此时不可达
循环引用会让纯引用计数难以归零:
function createCycle() {
const a = {}
const b = {}
a.b = b
b.a = a
return null
}
createCycle()
函数结束后,如果 a 和 b 没有从根可达,追踪式 GC 仍然可以回收这个环;“互相引用”本身不等于内存泄漏。

图片来源:原文配图。
IE8 及以前的一些 IE 版本中,DOM/BOM 与 JavaScript 对象之间存在 COM 引用计数的历史问题,因此曾经需要手动断开 DOM 循环引用。IE9 之后的浏览器实现已经发生变化,这段内容只作为历史背景;现代网页不应把清空变量当成通用 GC API,也不应为了传闻中的 IE8 行为给所有代码添加 null 赋值。
五、V8 的分代式回收
V8 将堆划分为不同用途和代际,并使用多种回收器和辅助机制。常见的入门模型是:
- 新生代(young generation):新分配、生命周期较短的对象较多;
- 老生代(old generation):存活较久或较大的对象较多。
这不是两个固定大小的 JavaScript 内存区。容量、页大小、晋升条件和阈值会随平台、指针压缩、V8 版本和运行压力调整。原文的“新生代通常 1~8 MB”和“空闲区超过 25% 就晋升”等数字属于历史实现描述,不应作为当前 V8 的稳定 API 或性能预算。

图片来源:原文配图。
5.1 新生代回收和 Scavenger
新生代回收通常利用复制或疏散存活对象的思路:把存活对象移动到另一块空间,释放原空间;经历多轮回收仍存活的对象可能晋升到老生代。实际 V8 使用的 Scavenger、分区和并行策略会随版本变化,不能把教材里的“两个等大半空间”当作唯一布局。
5.2 老生代回收
老生代对象较多时,直接复制整个区域成本较高,引擎会结合标记、清除、整理、并发标记、增量工作和并行清理。回收器可以根据对象大小、碎片、分配压力和暂停目标选择不同路径。

图片来源:原文配图。
六、减少停顿的 GC 优化
6.1 Stop-The-World
某些 GC 阶段需要暂停 JavaScript 主线程,避免程序在对象图被扫描或移动时同时修改引用,这类暂停通常称为 Stop-The-World(全停顿)。暂停时间不是固定的 60ms 或某个教材示例中的数字,它取决于堆大小、存活对象数量、平台和当时的回收阶段。
6.2 并行(parallel)
并行回收让多个辅助线程共同完成同一个 GC 阶段。它可以缩短墙上时钟时间,但需要同步和任务分配开销;并行不等于 JavaScript 代码本身自动在多个线程执行。
6.3 增量标记(incremental marking)
增量标记把较长的标记工作拆成多个小片段,在片段之间让应用代码继续运行,从而降低单次停顿。应用代码运行期间可能修改对象图,所以引擎需要写屏障、重新扫描或其它维护机制。
三色标记常用如下抽象:
- 白色:尚未发现;
- 灰色:对象已发现,但引用还没有全部处理;
- 黑色:对象及需要遍历的引用已经处理。

图片来源:原文配图。
6.4 写屏障
如果一个已处理对象(黑色)在增量标记期间新增了对白色对象的引用,写屏障可以记录这种关系,防止新对象在本轮被误判为垃圾。具体采用哪一种不变式和屏障实现属于 V8 内部细节,文章中的“黑色引用白色就一定直接变灰”是帮助理解的简化说法。

图片来源:原文配图。
6.5 惰性清理(lazy sweeping)
标记结束后,清理不一定一次性完成。引擎可以在后续分配需要空间时分批清理空闲页,以减少连续长暂停;这不代表垃圾已经从所有物理内存中立刻归还操作系统。

图片来源:原文配图。
6.6 并发(concurrent)
并发 GC 工作可以在辅助线程上进行,同时让 JavaScript 主线程继续运行。并发标记需要处理主线程对对象图的修改,并在关键阶段进行同步;并发、并行、增量是不同维度:
- 并行:多个线程一起完成一个阶段;
- 增量:把工作拆成多个片段;
- 并发:GC 辅助工作与应用线程重叠执行。
现代 V8 的 Orinoco 项目组合使用并发、并行、增量和压缩等技术。具体调度会持续演进,阅读 V8 官方博客时应注意文章对应的版本和阶段。
七、GC 不会自动修复内存泄漏
内存泄漏通常不是“GC 算法失效”,而是应用仍然保留了不再需要的引用:
const cache = new Map()
function remember(key, value) {
cache.set(key, value)
}
// 如果 key 永远增长,cache 会让 value 一直可达
常见风险包括:
- 意外的全局变量;
- 未移除的 DOM 事件监听器;
- 没有清理的
setInterval、Observer 和订阅; - 闭包保留大型对象;
- detached DOM 节点仍被 JavaScript 引用;
- 无上限缓存和日志数组;
- 未关闭的 WebSocket、文件句柄或 Node.js 流。
需要按对象键保存且不希望阻止键被回收时,可以研究 WeakMap;但 WeakMap 不能遍历,也不能当作“定时清理缓存”的替代品。WeakRef 和 FinalizationRegistry 具有非确定性,只适合非常明确的高级场景,不应作为业务正确性的基础。
排查内存问题应使用 Chrome DevTools heap snapshot、allocation timeline、Node.js --inspect/heap snapshot 等工具,比较多个时间点的对象保留路径,而不是仅凭 RSS 或一次 GC 日志下结论。
八、总结
- JavaScript GC 的核心是从根出发判断可达性,而不是简单统计变量数量;
- 标记清除、标记整理和复制回收是理解追踪式 GC 的基础模型;
- 引用计数会被循环引用困住,但现代 JS 引擎的主 GC 不应简单等同于引用计数;
- V8 使用分代式和 Orinoco 等多种机制,具体阈值和调度不是稳定 API;
- 并行、增量、并发分别描述不同优化维度,不能混为“多线程 GC”一句话;
- 只要程序仍持有引用,GC 就有理由保留对象;内存泄漏需要从生命周期和保留路径解决。
参考资料: