你写的每一个对象,都在等死。
这话听着残酷,但 V8 引擎就是这么看你的代码的——创建一个对象,然后判断它什么时候该被回收。这个负责"判死刑"的模块叫垃圾回收器(Garbage Collector, GC)。
多数前端从没深想过 GC,觉得"引擎自己管,我不用操心"。但你有没有想过——页面越用越卡、长列表滑动掉帧、WebGL 游戏隔几秒卡一下——这些问题的根源,经常不是渲染,而是 GC 暂停了你的主线程。
今天这篇文章,我们从 V8 官方博客的一篇经典文章出发,聊聊 V8 的垃圾回收器 Orinoco 是怎么从"粗暴的全停"进化成"几乎无感"的。
核心结论先亮出来:短命对象很便宜,长命对象和引用关系才贵。理解这一点,你对 React 状态管理、对象池设计、缓存策略的判断都会不一样。
一、GC 到底在干什么?
任何垃圾回收器,干的活就三件事:
找出谁活着、谁死了
把死了的占用的内存回收
整理碎片(可选)
最原始的做法?暂停 JavaScript,在主线程上依次执行这三步。这叫 Stop-The-World——字面意思,世界暂停。
你的用户在滑动列表,GC 说"等等,我先打扫一下",然后页面就卡了。
Major GC 三阶段:标记、清除、压缩
V8 的 Major GC(全量回收)就是按这三步来的:标记 → 清除 → 压缩。
二、标记:谁还活着?
GC 怎么判断一个对象该不该留?答案是可达性。
从一组"根指针"出发(执行栈上的变量、全局对象等),沿着引用链递归遍历。能摸到的,标记为"活的";摸不到的,就是垃圾。
这跟你在一个城市里查"哪些房子还有人住"一样——从市中心出发,沿着每条路走,能走到的房子标记为"有人",走不到的就是"空置房"。
三、清除和压缩:回收空间
标记完之后,GC 扫描内存,把死对象留下的"空洞"加入空闲列表(free-list),按大小分类,方便下次快速分配。
但清除有个问题:内存会碎片化。一堆小空洞分散各处,想分配一个大对象却找不到连续空间。
压缩就是碎片整理——把活对象搬到一起,腾出大块连续空间。类似老式电脑的"磁盘碎片整理"。但搬运有成本,所以 V8 只挑碎片严重的页面做压缩,其他页面只做清除。
V8 的务实哲学:不追求完美整理,只解决最严重的碎片。
四、分代:垃圾分类的智慧
这是整篇文章最核心的设计思想。
V8 把堆分成两代:新生代和老生代。新生代又细分为"托儿所"(Nursery)和"中间代"(Intermediate)。
V8 堆的分代结构:对象从 Nursery → Intermediate → Old Generation
为什么要分代?因为一个关键假设——分代假说:
大多数对象,生下来就死了。
你在一次函数调用中创建的临时变量、中间数组、回调闭包……它们的生命周期极短,通常在下一次 GC 之前就已经没用了。
这就像城市垃圾回收:日常生活垃圾(厨余、快递盒)产生快、淘汰快,用小型垃圾车高频回收就行;但建筑垃圾、大型废弃物产生慢,需要专门的大型设备低频处理。
V8 用同样的策略:新生代用轻量级 Scavenger 高频回收,老生代用重量级 Mark-Compact 低频回收。
对象的一生
出生:分配在 Nursery
第一次 GC 存活:升级为 Intermediate
第二次 GC 仍然存活:晋升到老生代
两次考验都没被淘汰的对象,V8 认为它"大概率会长期活着",就搬到老生代,不再频繁检查它。
五、Scavenger:新生代的清道夫
新生代的回收器叫 Scavenger,用的是经典的"半空间"(Semi-Space)设计。
Scavenger 半空间设计:From-Space 和 To-Space
空间分为两半:From-Space(当前分配区)和 To-Space(空闲区)。
GC 时,扫描 From-Space,把活对象复制到 To-Space,然后两半角色互换。死掉的对象?根本不用管——它们留在 From-Space 里,整块空间被一次性回收。
对象在 Scavenge 中的晋升过程
这个设计有个极其精妙的结果:
GC 的成本只跟"存活对象的数量"成正比,跟"你创建了多少对象"无关。
也就是说——短命对象几乎是免费的。你创建 10000 个临时对象,如果它们在 GC 前都死了,回收成本几乎为零。
这是一个反直觉的"边际成本"故事:在经济学里,生产越多边际成本越低是好事。在 GC 的世界里,死得越快的对象越便宜,因为 Scavenger 只搬活的,不理死的。
写屏障:老生代到新生代的引用追踪
有个棘手的问题:如果老生代的一个对象引用了新生代的对象,Scavenger 怎么知道?总不能每次都遍历整个老生代去找吧。
V8 用了一个叫写屏障(Write Barrier)的机制:每当 JavaScript 代码让一个老生代对象指向新生代对象时,这条引用会被记录下来。Scavenger 只需要检查这个记录本,不用扫描全部老生代。
这本质上是一个负反馈回路——写屏障像一个传感器,实时监测堆中引用关系的变化,并把"需要关注"的变化反馈给 GC。就像自动驾驶中的传感器不断监测路况,只把"需要反应"的事件传给决策系统。
六、Orinoco:从全停到几乎无感
早期 V8 的 GC 是全停的——暂停 JS、执行 GC、恢复 JS。简单但粗暴。
Orinoco 项目引入了三种关键技术,让 GC 的暂停时间大幅降低:
1. 并行(Parallel)
并行 GC:主线程和辅助线程同时执行 GC
主线程和辅助线程同时做 GC。仍然是 Stop-The-World,但总暂停时间被线程数"除"了一下。
好比一个人扫地要 10 分钟,5 个人一起扫只要 2 分钟。虽然还是得暂停营业,但关门时间短多了。
2. 增量(Incremental)
增量 GC:主线程间歇执行 GC 小步骤
把 GC 拆成很多小步,穿插在 JS 执行之间。每次只做一小块,做完立刻还控制权给 JS。
好比不关门大扫除,而是趁顾客少的时候擦一下桌子、扫一下角落,全天保持整洁。
难点在于:JS 在 GC 步骤之间可能改变堆状态(比如创建新引用),这会让之前的标记工作失效。所以增量 GC 需要写屏障来跟踪这些变化。
3. 并发(Concurrent)
并发 GC:辅助线程在后台完全执行 GC,主线程不暂停
最激进的方案——辅助线程在后台做 GC,主线程完全不停。
这是难度最高的技术。因为 JS 随时可能改变堆状态,后台线程读到的数据可能下一秒就变了,存在读写竞争问题。
V8 的实际做法是三者组合使用,而不是只挑一个。
七、实战中的 GC 长什么样?
新生代:并行 Scavenger
并行 Scavenging:多线程协作清理新生代
多个辅助线程同时参与新生代回收,通过原子操作和转发指针同步。效果:新生代 GC 主线程时间减少 20%~50% 。
老生代:并发标记 + 并行压缩
老生代 GC:并发标记清除 + 并行压缩
堆接近上限时,后台线程并发标记(JS 不暂停)
标记完成后,主线程短暂暂停,做标记最终化
在这个短暂暂停里,启动并行压缩和指针更新
并发清除在后台运行,甚至在 JS 恢复执行后仍在继续
主线程的暂停时间被压缩到了极致——只有"标记最终化 + 并行压缩启动"这一小段。
空闲时间 GC:偷帧间的空闲
空闲时间 GC:利用帧间空闲执行
Chrome 以 60fps 渲染时,每帧预算 16.6ms。如果一帧只用了 10ms,剩下的 6.6ms 就是"空闲时间"。V8 可以利用这段空闲做 GC,用户完全无感。
效果实测:Gmail 在空闲时,JS 堆内存减少了 45% 。
八、这对你写代码意味着什么?
| 认知 | 对应实践 |
|---|---|
| 短命对象几乎免费 | React 组件中大胆创建临时对象、中间数组,不用怕"频繁分配" |
| 长命对象的引用关系才贵 | 全局缓存、事件监听器、闭包捕获的大数组——这些才是泄漏源 |
| 写屏障有成本 | 频繁修改老对象指向新对象的引用,会触发更多写屏障开销 |
| GC 在帧间空闲执行 | 保持每帧处理时间足够短,就能给 GC 留出空间 |
| 分代结构决定成本 | 对象池(Object Pool)在 Canvas/WebGL 场景下有用,因为复用 = 避免晋升 |
一个判断框架:
当你犹豫"要不要用对象池""要不要缓存这个计算结果"时,先问一个问题:
这个对象会活过几次 GC?如果只活一次,让它自然死亡。如果会反复被引用,考虑复用或
WeakRef。
如果你只想带走一句话,我建议记这个:
V8 不怕你创建对象,怕你不让它死。
参考来源
• Peter Marshall —— Trash talk: the Orinoco garbage collector | V8 Blog
• 原文链接:v8.dev/blog/trash-…