mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2258 字
6 分钟
分代垃圾回收(Generational GC)
2026-06-13

一、为什么需要分代垃圾回收#

你写了一个 Web 服务,每秒处理上千个请求。每个请求创建几十个临时对象——HTTP 上下文、JSON 解析中间结果、日志字符串。这些对象用完即弃,生命周期不过几毫秒。但你的 GC 不知道这一点,它老老实实标记整个堆,从根集合出发遍历所有可达对象,再把不可达的清理掉。堆越大,遍历越慢。

一次 Full GC 扫描 4GB 的堆可能暂停上百毫秒。用户正等着响应,你的服务却在标记一堆早就该死的临时变量。大部分对象活不过一次 Young GC,却每次都要陪跑 Full GC 的全堆扫描——这就是问题所在。

分代垃圾回收基于一个经验观察:大多数对象死得早。既然如此,就把堆分成年轻代和老年代。年轻代小,回收快,频繁执行;老年代大,回收慢,但执行次数少。回收年轻代只需要毫秒级停顿,Full GC 则降级为低频操作。

二、现实类比#

办公室里的桌面和档案柜。桌面上堆着临时文件——草稿、便签、打印的邮件——每天下班清理一遍,大部分直接扔掉。档案柜里是长期保存的合同、报表,一年也未必整理一次。你不会每天翻档案柜来决定哪些文件该扔。

另一个类比:公司的新员工和老员工。新入职的人流动性高,三个月内离开的概率远大于待了五年的老员工。HR 不需要频繁评估老员工的去留,但需要快速处理试用期的人员变动。

三、核心思想#

分代 GC 把堆划分成两个区域:年轻代(Young Generation)和老年代(Old Generation)。新对象分配在年轻代,熬过若干次回收的对象晋升到老年代。年轻代小而回收频繁,老年代大而回收稀疏。

flowchart LR subgraph Young["年轻代 Young Generation"] Eden["Eden\n新对象分配区"] S0["Survivor 0\n存活对象中转"] S1["Survivor 1\n存活对象中转"] end subgraph Old["老年代 Old Generation"] Tenured["Tenured\n长期存活对象"] end Eden -->|"Young GC\n存活对象复制"--> S0 S0 -->|"下次 Young GC\n存活对象复制"--> S1 S1 -->|"年龄超过阈值\n晋升 Promote"--> Tenured S1 -->|"下次 Young GC\n存活对象复制"--> S0
堆内存布局(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 ShenandoahBrooks 指针 + 读屏障同左亚毫秒级停顿
V8Scavenge(半空间复制)Mark-Sweep/CompactOrinoco 并发减少停顿
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 观察和调优。

memory-pressure.ts
// 运行方式: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 Generation
const 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() 全局函数

六、生产验证#

项目源码位置用途
LinkedInJDK G1 GC 配置实践LinkedIn 工程博客分享 G1 调优经验:-XX:MaxGCPauseMillis=200,Young GC 停顿控制在 50ms 内,Full GC 通过 Mixed GC 减少频率
NetflixOpenJDK G1Netflix 使用 G1 + ZGC 实验性组合,大规模微服务集群中 GC 停顿从秒级降到毫秒级
Chrome/Node.jsV8 OrinocoV8 引擎的并发 GC 实现,Scavenge(年轻代)并行执行,Mark-Compact(老年代)并发标记和清除
UberGo GCUber 技术博客分享 Go GC 调优:GOGC=200 减少触发频率,配合 ballast 扩展降低 GC 频率
TwitchGo RuntimeTwitch 使用 Go 构建 video transcoding 服务,GOMEMLIMIT 控制内存上限,GC 停顿 < 500μs

七、小结#

何时使用:

  • 对象生命周期差异大——大量临时对象与少量长期对象共存,分代回收性价比最高
  • 要求低停顿——Web 服务、游戏、实时系统无法容忍长时间 stop-the-world
  • 堆较大——4GB 以上的堆,全堆扫描代价高,分代缩小每次扫描范围
  • 已有成熟运行时支持——JVM G1/ZGC、V8 Orinoco、Python 三代 GC,开箱即用

何时不用:

  • 对象生命周期统一——所有对象活同样长的时间,分代没有收益,Arena 更合适
  • 极低延迟且堆小——Go 风格的并发 Mark-Sweep 可能比带写屏障的分代 GC 更简单高效
  • 嵌入式/实时系统——写屏障开销和不可预测的晋升可能违反硬实时约束,引用计数更可控
  • 大对象为主——大对象直接进老年代,年轻代回收回收不到,分代失去意义

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

分代垃圾回收(Generational GC)
https://blog.souloss.com/posts/programming/memory/memory-generational-gc/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时