一、为什么需要分代垃圾回收
你写了一个 Web 服务,每秒处理上千个请求。每个请求创建几十个临时对象——HTTP 上下文、JSON 解析中间结果、日志字符串。这些对象用完即弃,生命周期不过几毫秒。但你的 GC 不知道这一点,它老老实实标记整个堆,从根集合出发遍历所有可达对象,再把不可达的清理掉。堆越大,遍历越慢。
一次 Full GC 扫描 4GB 的堆可能暂停上百毫秒。用户正等着响应,你的服务却在标记一堆早就该死的临时变量。大部分对象活不过一次 Young GC,却每次都要陪跑 Full GC 的全堆扫描——这就是问题所在。
分代垃圾回收基于一个经验观察:大多数对象死得早。既然如此,就把堆分成年轻代和老年代。年轻代小,回收快,频繁执行;老年代大,回收慢,但执行次数少。回收年轻代只需要毫秒级停顿,Full GC 则降级为低频操作。
二、现实类比
办公室里的桌面和档案柜。桌面上堆着临时文件——草稿、便签、打印的邮件——每天下班清理一遍,大部分直接扔掉。档案柜里是长期保存的合同、报表,一年也未必整理一次。你不会每天翻档案柜来决定哪些文件该扔。
另一个类比:公司的新员工和老员工。新入职的人流动性高,三个月内离开的概率远大于待了五年的老员工。HR 不需要频繁评估老员工的去留,但需要快速处理试用期的人员变动。
三、核心思想
分代 GC 把堆划分成两个区域:年轻代(Young Generation)和老年代(Old Generation)。新对象分配在年轻代,熬过若干次回收的对象晋升到老年代。年轻代小而回收频繁,老年代大而回收稀疏。
堆内存布局(JVM 典型配置):
┌──────────────────────────────────────────────────────────┐│ Young Generation ││ ┌────────────┐ ┌──────────┐ ┌──────────┐ ││ │ Eden │ │Survivor 0│ │Survivor 1│ ││ │ (80%) │ │ (10%) │ │ (10%) │ ││ └────────────┘ └──────────┘ └──────────┘ │├──────────────────────────────────────────────────────────┤│ Old Generation ││ ┌──────────────────────────────────────────┐ ││ │ Tenured (长期对象) │ ││ └──────────────────────────────────────────┘ │└──────────────────────────────────────────────────────────┘
对象生命周期: new → Eden → S0 ↔ S1(每次复制年龄+1)→ 年龄 ≥ 阈值 → Old| 属性 | 值 | 说明 |
|---|---|---|
| Young GC 停顿 | 通常 1-10 ms | 只扫描年轻代,堆区域小 |
| Full GC 停顿 | 可达数百 ms | 扫描全堆,可能触发压缩 |
| 晋升阈值 | 默认 15(JVM) | 对象经历 N 次 Young GC 后晋升 |
| 写屏障开销 | 约 5-10% | 跟踪老年代到年轻代的引用 |
3.1 弱分代假说
分代 GC 的理论根基是弱分代假说(Weak Generational Hypothesis):大多数对象死得早。统计表明,80%-98% 的对象在第一次 GC 时就已不可达。这意味着年轻代回收的性价比极高——扫描少量存活对象,回收大量空间。
3.2 写屏障
年轻代回收只扫描年轻代,但老年代的对象可能持有年轻代的引用。如果不去扫描老年代,这些引用就会被漏掉,导致存活对象被错误回收。
遍历老年代找这些引用?那就失去了分代的意义。解决方案是写屏障(Write Barrier):每次老年代对象写入对年轻代对象的引用时,记录到一个记忆集(Remembered Set)。Young GC 时只扫描记忆集,而不是整个老年代。
写屏障工作原理:
Old.ref = Young.obj ← 触发写屏障 ↓将 Old 加入记忆集(Remembered Set) ↓Young GC 时只扫描记忆集中的 Old 对象作为额外根3.3 晋升与分配保证
对象在 Survivor 区之间来回复制,每次复制年龄加一。当年龄达到阈值(JVM 默认 15),晋升到老年代。大对象可能直接在老年代分配,避免在年轻代来回复制的开销。
Young GC 还需要保证:即使最坏情况下所有年轻代对象都存活,老年代也能容纳。如果 Survivor 区放不下,对象直接晋升到老年代,这叫过早晋升,会导致老年代快速填满,触发 Full GC。
3.4 主流运行时的分代实现
JVM G1:把堆划分成等大小的 Region,每个 Region 可以是 Eden、Survivor、Old 或 Humongous。G1 优先回收垃圾最多的 Region(Garbage First),支持可配置的停顿目标。从 JDK 9 起成为默认 GC。
V8 Orinoco:Chrome 和 Node.js 的 V8 引擎使用 Scavenge 算法回收年轻代(半空间复制),Mark-Sweep/Mark-Compact 回收老年代。Orinoco 项目将标记、清除、压缩逐步改为并行和并发执行,减少主线程停顿。
Go 1.5+:Go 使用并发标记-清除,不是严格意义上的分代 GC。但它通过写屏障支持并发标记,用逃逸分析(Escape Analysis)减少堆分配——能分配在栈上的对象不进入堆,自然也不需要 GC 回收。Go 团队认为分代 GC 增加了实现复杂度,而逃逸分析已经消除了大量短命对象的堆分配。
四、变体与对比
| 运行时 | 年轻代算法 | 老年代算法 | 停顿特征 |
|---|---|---|---|
| JVM Serial | 复制算法 | Mark-Sweep-Compact | 单线程,停顿长 |
| JVM Parallel | 复制算法 | Mark-Sweep-Compact | 多线程,停顿中等 |
| JVM G1 | 复制算法(Region 级别) | 回收 Region + 压缩 | 可配置停顿目标 |
| JVM ZGC | 着色指针 + 读屏障 | 同左 | 亚毫秒级停顿 |
| JVM Shenandoah | Brooks 指针 + 读屏障 | 同左 | 亚毫秒级停顿 |
| V8 | Scavenge(半空间复制) | Mark-Sweep/Compact | Orinoco 并发减少停顿 |
| Go | 无分代 | 并发 Mark-Sweep | 通常 < 1ms |
| Python | 三代分代(0/1/2) | 同左(同一算法) | 全局解释器锁下停顿 |
为什么 Go 不用分代 GC? 三个原因:一,Go 追求极低实现复杂度,分代 GC 需要写屏障、记忆集、晋升逻辑,代码量翻倍;二,Go 的逃逸分析把大量短命对象留在栈上,削弱了”大多数对象死得早”的前提;三,Go 的并发标记-清除已经能在微秒级完成,分代的收益不足以抵消复杂度。Go 1.22 引入了分代 GC 实验性支持,但尚未成为默认。
五、多语言实现
5.1 Go 实现
Go 虽然没有分代 GC,但提供了 GC 调优和逃逸分析工具,让你理解运行时如何管理内存。
package main
import ( "fmt" "runtime" "runtime/debug" "time")
func createGarbage() { // 这些字符串会逃逸到堆上,成为 GC 的回收目标 for i := 0; i < 1000; i++ { _ = fmt.Sprintf("temp-object-%d", i) }}
func main() { // 查看当前 GOGC 设置(默认 100,意味着堆增长 100% 时触发 GC) fmt.Printf("GOGC: %d\n", debug.SetGCPercent(0)) // 读取当前值,0 不改变 debug.SetGCPercent(100) // 恢复默认
// 打印 GC 统计信息 printGCStats := func() { var stats debug.GCStats stats.Pause = make([]time.Duration, 10) debug.ReadGCStats(&stats) fmt.Printf("GC 次数: %d, 最近停顿: %v\n", stats.NumGC, stats.Pause[0]) }
// 强制触发一次 GC runtime.GC() printGCStats()
// 制造垃圾 createGarbage() runtime.GC() printGCStats()
// 调整 GC 频率:GOGC=200 减少触发频率,降低 CPU 开销,增大内存占用 debug.SetGCPercent(200) createGarbage() runtime.GC() printGCStats()
// GOGC=off 关闭 GC(仅用于基准测试,生产勿用) debug.SetGCPercent(-1)}逃逸分析——判断对象分配在栈还是堆:
# 编译时查看逃逸分析结果go build -gcflags="-m" main.go 2>&1
# 典型输出:# main.go:12:12: fmt.Sprintf escapes to heap → 逃逸到堆# var s string; s = "hello" → 栈上分配package main
import "fmt"
// stackAlloc 不会逃逸,编译器分配在栈上,无需 GC 回收func stackAlloc() int { x := 42 return x}
// heapAlloc 逃逸到堆上,因为返回了指针func heapAlloc() *int { x := 42 return &x // 逃逸:指针逃出函数作用域}
// sliceEscape 逃逸到堆上,因为切片可能被外部引用func sliceEscape() []int { s := make([]int, 1000) return s}
func main() { _ = stackAlloc() // 栈分配,零 GC 压力 p := heapAlloc() // 堆分配,GC 需要跟踪 _ = p sl := sliceEscape() // 堆分配 _ = sl}5.2 TypeScript 实现
V8 的 GC 可以通过 Node.js flags 观察和调优。
// 运行方式:node --expose-gc --trace-gc memory-pressure.ts
// 声明全局 gc() 函数(--expose-gc 提供)declare const gc: () => void;
interface GCStats { heapUsed: number; heapTotal: number; external: number;}
function getMemoryStats(): GCStats { const mem = process.memoryUsage(); return { heapUsed: Math.round(mem.heapUsed / 1024 / 1024), heapTotal: Math.round(mem.heapTotal / 1024 / 1024), external: Math.round(mem.external / 1024 / 1024), };}
// 短命对象:模拟 HTTP 请求中的临时数据function shortLivedObjects(): void { for (let i = 0; i < 100_000; i++) { // 这些字符串在 Young Generation 分配,很快被回收 const temp = JSON.parse(`{"id":${i},"name":"user-${i}"}`); void temp; }}
// 长命对象:模拟缓存,晋升到 Old Generationconst longLivedCache = new Map<string, object>();
function createLongLivedObjects(): void { for (let i = 0; i < 1000; i++) { longLivedCache.set(`key-${i}`, { data: new Array(100).fill(i) }); }}
// 观察内存变化console.log("初始状态:", getMemoryStats());
createLongLivedObjects();console.log("创建长命对象后:", getMemoryStats());
shortLivedObjects();console.log("短命对象创建后(未 GC):", getMemoryStats());
// 手动触发 GC(仅用于调试,生产代码不应调用)if (typeof gc === "function") { gc(); console.log("GC 后:", getMemoryStats());}
// 清理长命对象,观察 Old Generation 回收longLivedCache.clear();if (typeof gc === "function") { gc(); console.log("清理缓存后 GC:", getMemoryStats());}V8 GC 调优 flags:
# 查看详细 GC 日志(Scavenge 和 Mark-Sweep 都会打印)node --trace-gc --expose-gc app.js
# 常见 V8 GC 参数# --max-old-space-size=4096 老年代最大 4GB# --max-semi-space-size=2 年轻代半空间 2MB# --gc-interval=100 每 100 次分配触发 GC(调试用)# --expose-gc 暴露 gc() 全局函数六、生产验证
| 项目 | 源码位置 | 用途 |
|---|---|---|
| JDK G1 GC 配置实践 | LinkedIn 工程博客分享 G1 调优经验:-XX:MaxGCPauseMillis=200,Young GC 停顿控制在 50ms 内,Full GC 通过 Mixed GC 减少频率 | |
| Netflix | OpenJDK G1 | Netflix 使用 G1 + ZGC 实验性组合,大规模微服务集群中 GC 停顿从秒级降到毫秒级 |
| Chrome/Node.js | V8 Orinoco | V8 引擎的并发 GC 实现,Scavenge(年轻代)并行执行,Mark-Compact(老年代)并发标记和清除 |
| Uber | Go GC | Uber 技术博客分享 Go GC 调优:GOGC=200 减少触发频率,配合 ballast 扩展降低 GC 频率 |
| Twitch | Go Runtime | Twitch 使用 Go 构建 video transcoding 服务,GOMEMLIMIT 控制内存上限,GC 停顿 < 500μs |
七、小结
何时使用:
- 对象生命周期差异大——大量临时对象与少量长期对象共存,分代回收性价比最高
- 要求低停顿——Web 服务、游戏、实时系统无法容忍长时间 stop-the-world
- 堆较大——4GB 以上的堆,全堆扫描代价高,分代缩小每次扫描范围
- 已有成熟运行时支持——JVM G1/ZGC、V8 Orinoco、Python 三代 GC,开箱即用
何时不用:
- 对象生命周期统一——所有对象活同样长的时间,分代没有收益,Arena 更合适
- 极低延迟且堆小——Go 风格的并发 Mark-Sweep 可能比带写屏障的分代 GC 更简单高效
- 嵌入式/实时系统——写屏障开销和不可预测的晋升可能违反硬实时约束,引用计数更可控
- 大对象为主——大对象直接进老年代,年轻代回收回收不到,分代失去意义
八、参考资料
- V8 Orinoco: 并发垃圾回收 - V8 引擎并发 Scavenge 回收器设计与实现
- Go GC 指南 - Go 官方 GC 调优指南,GOGC 和 GOMEMLIMIT 详解
- JDK G1 垃圾回收器 - G1 GC 设计文档,Region 管理与停顿目标
- Jones & Lins, Garbage Collection - GC 算法经典教材,分代假说的理论基础
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






