"我的程序内存为什么涨了?""GC 为什么停顿这么久?"几乎每个写过 Java / Go / Node / Python 的人都问过。要回答这些问题,必须理解垃圾回收(GC)的几种基本算法和现代语言的实现选择。这篇文章用一致的视角讲完所有主流 GC,让你看到任何一篇 GC 调优文档都能知道它在说什么。
垃圾的定义
"垃圾"就是"程序运行不可达的对象"。GC 的核心问题是:如何高效判断一个对象是否还会被使用。所有 GC 算法的差别,都在回答这个问题的方式上。
算法 1:引用计数
每个对象记录"有多少个引用指向我"。引用 +1 时计数加,引用销毁时计数减。计数到 0 立即回收。Python、Swift、Rust 的 Rc/Arc 都用引用计数。
# Python 简单示例:每次赋值改计数
import sys
a = [1, 2, 3]
print(sys.getrefcount(a)) # 2 (变量 a 引用 + getrefcount 函数参数引用)
b = a
print(sys.getrefcount(a)) # 3
del b
print(sys.getrefcount(a)) # 2
优点:立即回收,无停顿;实现简单。
致命缺点:循环引用。A 引用 B,B 也引用 A,从外部都不可达,但各自计数都是 1,永远不会被回收。
# Python 演示循环引用泄漏
class Node: pass
a = Node(); b = Node()
a.next = b; b.next = a
del a; del b
# a 和 b 都还在内存里,只能等 Python 的"循环垃圾回收器"扫描时再清理
Python 实际上是"引用计数 + 周期检测器"组合:计数处理 99% 情况,周期检测器解决循环引用。
算法 2:可达性分析(Tracing GC)
现代 GC 几乎全部基于这个思路。从"GC Roots"(全局变量、栈上变量、寄存器里的引用)出发,沿引用链遍历,标记所有可达对象。没被标记的就是垃圾。
Mark-Sweep(标记-清除)
分两阶段:
- Mark:从 Roots 开始 DFS/BFS,给所有能到达的对象打标记。
- Sweep:遍历堆,把没标记的对象空间放进 free list。
// 伪代码
function gc() {
// Mark
const stack = [...roots];
while (stack.length) {
const obj = stack.pop();
if (obj.marked) continue;
obj.marked = true;
for (const ref of obj.refs) stack.push(ref);
}
// Sweep
for (const obj of heap) {
if (!obj.marked) free(obj);
else obj.marked = false; // 为下次 GC 清理标记
}
}
问题:内存碎片。释放后留下的空洞大小不一,分配新对象时可能整体空间够但放不下一个大对象。
Mark-Compact(标记-压缩)
解决碎片:Sweep 阶段不止释放,还把存活对象搬到堆的一端,空闲空间连续在另一端。代价:搬动对象意味着所有指向它的引用都要更新 —— 成本不小。
Copying(复制)
把堆分两半,只用一半。GC 时把存活对象从用着的半区复制到空着的半区,然后两个半区角色互换。优点:分配快(撞指针)、自动压缩、只看存活对象(死对象越多越快);缺点:浪费一半内存。常用于"新生代"(死亡率高,半区利用率本来就低)。
分代假说:GC 调优的根基
大量统计观察发现:大多数对象都是"朝生夕死"的(分配后很快变垃圾),只有少数能活很久。这就是分代假说(generational hypothesis)。现代 GC 据此把堆分成"新生代"(young)和"老年代"(old):
- 新生代:用 Copying 算法,因为大部分对象会死,复制少量存活对象很快。
- 老年代:用 Mark-Sweep 或 Mark-Compact,因为这里的对象死亡率低。
- 晋升:新生代里活过几次 GC 的对象,搬到老年代。
这就是 Java 经典 GC、V8、CPython 等的基本架构。
JVM 的 GC 演进
JVM 用一段不长的时间走过了所有主流 GC 思路,值得专门看看:
Serial GC 单线程,STW(stop-the-world),适合小堆
Parallel GC 多线程并行回收,但仍 STW,吞吐量优先
CMS 老年代并发标记清除,降低停顿但有碎片(已废弃)
G1 (Garbage First) 把堆切成 region,优先回收"垃圾最多"的 region
ZGC 亚毫秒级停顿,支持 TB 级堆
Shenandoah OpenJDK 的另一个低停顿 GC
实战配置:
# JDK 17 默认 G1
java -Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -jar app.jar
# 大堆 + 严格延迟要求:用 ZGC
java -Xmx32g -XX:+UseZGC -jar app.jar
# 看 GC 日志(Unified Logging)
java -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar app.jar
Go 的并发三色标记
Go 的 GC 是并发的三色标记 + 写屏障。三色:
- 白色:可能是垃圾(GC 开始时所有对象都白)。
- 灰色:本身可达,但子对象还没扫描。
- 黑色:本身可达且子对象都已扫描。
GC 把 Roots 涂灰,然后不断从灰色集合取一个,扫描它的子对象(子变灰),自己变黑。最后没变黑/灰的就是垃圾。
问题:GC 在跑的时候,用户代码还在改引用,可能把"黑对象指向白对象"且"灰对象不再指向那个白对象",导致这个白对象被漏标。写屏障解决这个问题:每次写引用时,把被写入的对象再次涂灰,保证不漏。Go 用的具体方式叫"混合写屏障",代价是写引用稍微慢一点,换来 GC 几乎不停顿(STW 通常 < 1ms)。
// Go 看 GC 行为
GODEBUG=gctrace=1 ./myapp
// gc 1 @0.012s 0%: ...
// 每次 GC 一行日志,看停顿时间和回收量
V8 的 GC 设计
V8(Chrome / Node)同样是分代:
- 新生代:用 Scavenge(半区复制),非常快。
- 老年代:Mark-Sweep + Mark-Compact。
- Orinoco:并发 + 并行 + 增量,大量工作放到后台线程,减少主线程停顿。
// Node 调整堆上限
node --max-old-space-size=4096 app.js // 老年代 4GB
// 主动触发 GC(需要 --expose-gc)
node --expose-gc -e 'global.gc(); console.log(process.memoryUsage());'
常见内存问题排查
Java:用 jmap / jstack / 火焰图
jmap -heap <pid> # 堆配置和使用情况
jmap -histo:live <pid> # 存活对象数量按类排序
jmap -dump:live,format=b,file=h.bin <pid> # dump 堆,用 MAT 打开分析
jstack <pid> # 线程栈
async-profiler # CPU/内存火焰图
Go:pprof
import _ "net/http/pprof"
go http.ListenAndServe("localhost:6060", nil)
# 看堆使用
go tool pprof http://localhost:6060/debug/pprof/heap
# 看分配统计(看是哪条路径在频繁分配)
go tool pprof http://localhost:6060/debug/pprof/allocs
Node:Chrome DevTools
node --inspect app.js
# 然后 chrome://inspect 连上,
# Memory 面板拍 Heap snapshot,看 Retained Size 排序找泄漏对象
内存泄漏的典型模式
1. 全局缓存只增不减:const cache = {},持续写入不清理,程序运行一段时间后内存炸。修复:用 LRU 或 TTL。
2. 监听器没解绑:对一个长生命周期对象 addEventListener,组件销毁时没 removeEventListener。Handler 持有组件实例,组件无法回收。
3. 闭包意外捕获大对象:JS/Python 闭包都会发生(参考闭包那篇文章)。
4. 大对象进入老年代,GC 始终碰不掉:Java 老年代缓存 100MB 数据没释放,即使新生代 GC 频繁,老年代占用从不下降。
5. 静态字段持有上下文:Java 里 static Map<String, Context> ctxs 持有 Android Context,Activity 一次都释放不掉。
给应用程序的几个原则
- 分配越少越好。GC 再快,也不如不让它跑。热点路径上避免 new、避免不必要的 boxing、复用对象池(sync.Pool / ByteBuffer pool)。
- 短命对象就让它短命。不要把"临时数据"塞到长生命周期的容器里,会逼着它进老年代。
- 调 Xmx 不是越大越好。堆越大,full GC 越久。能用 2GB 解决就别给 8GB。
- 监控 P99 GC 停顿,不只是平均。线上服务最怕"99% 时间 1ms,1% 时间 500ms"的尾延迟。
- 压测时模拟"长跑"。十分钟的压测能覆盖新生代行为,但老年代要靠几小时跑测试才暴露。
STW(Stop-The-World):为什么 GC 会"停下世界"
大多数 GC 步骤需要"暂停所有应用线程",叫 Stop-The-World。原因很直接:GC 在遍历对象图,如果用户线程同时改引用,可能漏标或多标,数据结构会被破坏。
STW 的影响在线上是切实的:JVM 默认 GC 一次几十毫秒到几百毫秒,期间所有请求都挂起。这正是为什么金融交易、实时游戏会优先选 ZGC、Shenandoah 这些"几乎不停顿"的 GC —— 它们用更复杂的并发标记和写屏障,把 STW 时间压到 1ms 以内。
# JVM 看 GC 停顿明细
# 加 -Xlog:gc*,safepoint 看安全点(STW 必须在安全点暂停)
java -Xlog:gc*,safepoint -jar app.jar 2>&1 | grep "Total time for which"
# 输出类似:
# Total time for which application threads were stopped: 0.0123 seconds
写屏障:并发 GC 的关键技术
前面提到 Go 用写屏障保证并发标记的正确性,这里展开一点。写屏障是一段"插入到每次引用赋值前后的代码",在某些情况下记录这次修改,让 GC 知道"这条边发生了变化"。
// 伪代码:Dijkstra 风格的"插入屏障"
function write(obj, field, newValue) {
if (newValue.color === white && obj.color === black) {
newValue.color = gray; // 让被新指向的对象变灰,避免漏标
gc_worklist.push(newValue);
}
obj.field = newValue;
}
// "删除屏障":在引用被覆盖前,把原值变灰
function writeDel(obj, field, newValue) {
const old = obj.field;
if (old.color === white) {
old.color = gray;
gc_worklist.push(old);
}
obj.field = newValue;
}
不同 GC 实现选不同的屏障组合(插入/删除/混合),取舍点在"标记精度 vs 写入开销"。Go 在 1.8 后用了"混合写屏障",让栈不需要重新扫描,简化了 GC 流程。这些细节对调用方透明,但理解了能解释"为什么 Go 的写指针稍慢、读指针无开销"。
分代假说不总是成立:大对象与 LOH
分代假说说"大部分对象短命",但有反例:
- 大对象(几 MB 起):它们本身就不该被频繁创建,如果创建了通常生命周期长。所以 JVM 会把超大对象直接分配到老年代,跳过新生代,避免在新生代里浪费复制成本。
- 大数组/大字符串:.NET 把它们放进"Large Object Heap (LOH)",单独管理,因为搬动大对象太贵。
- 缓存型对象:LRU 缓存里的对象既不短命也不长命,会让分代假说失效,堆里大量"中年对象"在新老代之间反复横跳,增加 GC 压力。
调优建议:看你的应用是不是"缓存重度型"。如果是,适当调大新生代(避免过早晋升)或干脆禁用某些缓存进入老年代,能显著降低 full GC 频率。
实战:一次内存泄漏排查
用一个真实场景串起来。Node 服务上线后内存每天涨 200MB,几天后 OOM。排查路径:
node --inspect启动,在 DevTools Memory 面板拍一张快照(基线)。- 模拟一轮典型业务流量(比如 1000 个请求)。
- 再拍一张快照,用 "Comparison" 模式对比两张,按 "Delta" 排序看哪些对象数增长最多。
- 展开增长最快的类,看 Retainers 列(谁还引用着它),沿引用链找到根源。
- 大概率是某个全局 Map、缓存、事件监听器没清理。
// 常见根源:用 Map 做缓存但没设上限
const cache = new Map(); // ⚠️ 没上限的 Map,迟早爆
function getUser(id) {
if (!cache.has(id)) cache.set(id, fetchUser(id));
return cache.get(id);
}
// 改成 LRU 或 TTL
const LRU = require('lru-cache');
const cache = new LRU({ max: 1000, ttl: 5 * 60 * 1000 });
排查 GC/内存问题最重要的不是"立刻去看堆 dump",而是建立对比:启动时多大、空闲时多大、负载下多大、跑了一天多大。有了这个时间序列,才知道是"正常波动"还是"持续泄漏"。监控普罗米修斯加 process_resident_memory_bytes 这样的指标,几天就能看出趋势。
什么时候手动 GC,什么时候完全不管
大多数情况下,不要手动触发 GC。语言运行时比你了解什么时候该跑。只在两种场景下考虑手动调用:
- 测试/基准测试:跑一次 GC 让基线干净,避免后续测量被前面残留对象干扰。
- 预知大量短命对象产生后:比如批量处理完一万条数据,主动建议 GC 一下,避免下一阶段触发 full GC 影响响应。
但即使这两种场景,也只是"建议",GC 不一定立刻跑。真要靠手动 GC 才能稳定的系统,通常说明你的内存使用模式本身有问题,先优化分配模式才是正解。
逃逸分析:让对象别上堆
分配越少 GC 越轻,所以"能不能不分配"是更上游的优化。逃逸分析(escape analysis)是编译器在编译期判断"一个对象的引用会不会跑出当前函数"。如果不会,这个对象就可以直接分配在栈上,函数返回时随栈一起回收,根本不进入 GC 视野。
// Go:用 -gcflags="-m" 看逃逸分析结果
go build -gcflags="-m" main.go
// 输出例子:
// main.go:10:11: &User{...} escapes to heap // 上堆了
// main.go:15:9: u.name does not escape // 没上堆,栈分配
// 典型让对象逃逸的写法:
func bad() *User {
return &User{ name: "x" } // 返回引用 -> 必须上堆
}
func good() User {
return User{ name: "x" } // 返回值 -> 栈分配 + 复制
}
Java 同样有逃逸分析,JIT 会把"明显不逃逸"的对象做标量替换 —— 把对象的字段直接展开成局部变量。这种优化对调用方完全透明,但意味着你写"小对象、不返回引用、不存进集合"的代码,JVM 会自动优化掉一大批分配。
对象池:复用比分配快得多
对那些"频繁创建、生命周期短、构造昂贵"的对象,显式池化能显著降低 GC 压力。前面在 Go 并发文章里已经讲过 sync.Pool,这里补两个其他语言的常见用法。
// Java:Apache Commons Pool 配置一个对象池
GenericObjectPool<ByteBuffer> pool = new GenericObjectPool<>(new BasePooledObjectFactory<ByteBuffer>() {
public ByteBuffer create() { return ByteBuffer.allocateDirect(8192); }
public PooledObject<ByteBuffer> wrap(ByteBuffer b) { return new DefaultPooledObject<>(b); }
});
ByteBuffer buf = pool.borrowObject();
try { processWith(buf); }
finally { buf.clear(); pool.returnObject(buf); }
// Netty:内置 PooledByteBufAllocator,默认就开,这是它高性能的关键之一
ByteBuf buf = ByteBufAllocator.DEFAULT.buffer(1024);
try { ... } finally { buf.release(); } // 必须 release,否则泄漏
注意池化不是银弹:对小对象、构造很快的对象,池化的同步开销可能比直接 new 还大。决定要不要池化前,先 profile 看分配是不是真热点。
弱引用 / 软引用:让 GC 帮你管理缓存
普通引用是"我用着这个对象,别 GC 它"。但缓存场景有更精细的需求:"内存够就缓存,内存紧就让 GC 把它丢掉"。语言层面提供了弱引用和软引用来表达这个意图。
// Java
SoftReference<byte[]> soft = new SoftReference<>(loadLargeFile());
// 内存够用时 soft.get() 返回数据,触发 OOM 前的最后时刻才被 GC
WeakReference<User> weak = new WeakReference<>(user);
// 只要没有强引用指向它,下次 GC 就回收(更弱)
// JS 弱引用
const weakMap = new WeakMap();
const key = { id: 1 };
weakMap.set(key, expensiveValue);
// 当 key 不再被引用,WeakMap 里的 entry 自动消失,不阻止 GC
WeakMap 在前端做"DOM 节点关联数据"特别有用:用 DOM 元素做 key 存元数据,DOM 节点被移除后,关联数据自动清理。普通 Map 在这种场景下就是泄漏温床。
不同语言 GC 调优的"该看哪些指标"
把"该监控什么"列成表,生产服务一定要把这些纳入告警:
语言/runtime 核心指标
Java/JVM Young GC 频率/耗时、Full GC 频率/耗时、老年代占用、晋升速率
jstat -gcutil <pid> 1s
Go gc 周期、stw 时间、heap_inuse、heap_idle
runtime.MemStats 或 expvar
Node.js process.memoryUsage().heapUsed / rss、GC 频率
--trace-gc
Python gc.get_stats() 三代统计、未回收循环引用
给监控加一个最朴素的告警:**RSS / heap 在过去 1 小时单调上涨且斜率 > 阈值** —— 它能在大多数泄漏导致 OOM 之前几小时甚至几天就提示你。
写在最后
GC 不是黑魔法,它是"在什么时候、用什么算法、对哪部分堆做事"这三个问题的工程实现。理解了引用计数、Mark-Sweep、Copying、分代假说这四件事,任何一门语言的 GC 文档都看得懂 —— 它们只是同一套思路的不同组合。
给后端工程师的最后建议:不要等到线上 OOM 才学 GC。每个生产服务上线前都跑一次"开 gc 日志 + 至少 1 小时压测",看内存曲线是否平稳、GC 频率和停顿是否符合预期。这点投入能帮你避免凌晨被叫起来排查"内存涨了"的故事。
—— 别看了 · 2026