☰
跨语言垃圾回收深度解析:从JVM到Go、Python与BuildKit
2026/10/5 3:22:53 网站建设 项目流程

聊 GC(垃圾回收)这件事,我一开始也是从 Java 调优入门的,后来写了几年 Go、用 Python 处理过数据分析、又折腾过前端和 Node 服务,才发现“垃圾回收”在不同语言里根本是四种不同的活儿。有人把 GC 等同于“运行时过一会儿自动清点内存的模块”,这没错,但真到线上排查问题的时候,这一定义帮不了你太多——你要搞清楚的是:它什么时候停、停多久、哪些对象会进入老年代、为什么内存越用越高。这篇文章我会从 GC 到底要解决什么根本问题讲起,然后用 JVM、Go、Python/JavaScript、.NET/Rust 这几条主线拆开各语言的实现思路,最后再把最近热搜里那串builder.gc的配置也顺便说清楚。不管你是刚写代码不久的新人,还是已经背过一堆启动参数的老手,这篇文章都适合当作一次系统性梳理来看。

1. 先想明白:GC 到底在解决什么问题

1.1 手动内存管理的三座大山

要理解 GC 的价值,最好先回到没有 GC 的世界。写过 C 的人都知道malloc和free有多麻烦,一个长期跑的服务如果内存管理不到位,会遇到的基本就三类问题:

  • 内存泄漏:分配了内存,忘了释放,或者释放路径被异常跳过。内存只增不减,最后被系统 OOM。
  • 悬垂指针:内存已经释放,但还有指针指向那块地址,一旦被别人复用,读写就变成“改别人家数据”,乱象丛生。
  • 双重释放:两个模块都认为自己对同一个指针负责,结果free了两次,堆元数据被破坏,崩溃方式还非常随机。

这三类问题在 C/C++ 里属于“能在线上跑好几年、某一天突然爆发”的类型。GC 的核心贡献,就是把这三类问题相当于直接从语言层面消灭掉:你只管创建对象、让引用自然失效,至于内存何时归还,由运行时统一裁决。

1.2 判断“死对象”的通用标尺:可达性

几乎所有现代 GC 语言都采用同一个判断准则:从根集合出发沿引用遍历,能到达的对象视为“活的”,不能到达的就是“死的”。根集合(root set)通常包括:

  • 每个线程的栈帧里的局部变量和参数
  • 全局变量、静态字段
  • 寄存器里正在参与计算的引用
  • JVM 里的 JNI 引用、Go 里的 goroutine 栈等等

这个准则的好处是天然规避了“循环引用是不是内存泄漏”的争论:两个对象哪怕互相引用,只要它们构成一个从根访问不到的小岛,那整个岛就是垃圾。

1.3 评价 GC 好坏的四个维度

衡量一个 GC 设计成功与否,业内大致看四个维度:

维度含义对应用体验的影响
吞吐量GC 之外的有效工作占 CPU 的比例越高越好,计算密集型业务敏感
停顿时间(STW)Stop-The-World 造成的用户线程暂停越低越好,交互/交易型业务敏感
内存占用GC 为完成回收所付出的额外堆和元数据代价云上跑服务时直接折算成本
扩展性面对超大堆、上百核时是否仍然可用决定你能不能把 GC 用到大数据场景

不同语言在设计 GC 时就是在这四个维度上做取舍。比如 JVM 的 G1 把用户可控停顿放在高位;Go 干脆放弃分代,换取实现简单和并发调度友好;Python 至今保留引用计数,因为跟 C 扩展的兼容性比优雅更重要。这些选择不是谁笨谁聪明,而是约束条件不同。

2. 两条技术路线:引用计数和追踪式收集器

2.1 引用计数:每一条指针都在记账

引用计数(Reference Counting)的思路非常直接:每个对象头部保存一个整型计数值,表示“当前有多少指针指向我”。每次指针赋值、传参、销毁时执行加减,归零了就立刻释放。

a = [] # 列表对象 refcount = 1 b = a # refcount = 2 del a # refcount = 1 del b # refcount = 0,内存立刻被回收
  • 优点:内存回收及时、顺手就做了,没有任何全局暂停;实现直观,不移动对象,和 C 扩展的交互也非常友好。
  • 缺点:最出名的是循环引用问题——a.append(a)会让a的引用计数永远为 1,因为“被自己引用”不计入根集合;其次,在多线程环境下,每做一次指针操作都要对计数值做原子自增自减,这个成本会落在日常所有赋值语句上。

引用计数不是某一门语言的专属,Python、Objective-C/Swift 的 ARC、PHP 的 zval 都走这条路。严格来说它不算“垃圾回收派”的经典代表,但它是解决“什么时候回收”的一种重要答案。

2.2 追踪式收集:从根出发做全局扫描

追踪式收集器才是现代 GC 的主流。它不维护计数器,而是定期“扫街”:从根集合出发,走完整个对象图,把能到达的对象标记出来,剩下的统一清理。按后续处理方式又分出三种经典算法:

  • 标记-清除(Mark-Sweep):标记存活对象后,线性扫描堆把所有未标记对象释放。实现简单,但会产生碎片。
  • 标记-整理(Mark-Compact):标记存活对象后,把它们向堆的一端搬移,然后把边界之后的整段内存当成空块。消除碎片,但需要“搬动对象”并更新所有引用,成本更高。
  • 复制式(Copying):把存活对象从一个半区复制到另一个半区,旧半区直接作废。回收飞快,但空间利用率最高只能到一半,所以一般只用在新生代。

并发环境下的追踪式 GC 最核心的抽象是三色标记法:白色是未访问过的对象,灰色是“自己已访问但子引用还没处理”的对象,黑色是“自己和子引用都已处理”的对象。并发标记时为了防止“黑色对象偷偷指向白色对象”造成误回收,运行时必须在指针写入时用写屏障记录变化。很多语言调优的文章里反复出现“SATB”“写屏障”这些词,根源都在这。

2.3 分代假设:为什么大家都要“分代”

有一个经验规律在绝大多数程序里都成立:大部分对象朝生夕死。比如你循环 100 万次生成中间结果,这些临时对象活不过本次迭代。于是 GC 设计者把堆按年龄分组:

  • 新生代:放刚创建的对象,回收频率高,用复制式算法快速清理;
  • 老年代:放活过若干次回收的对象,回收频率低,用标记-整理或标记-清除。

这样做的本质是“把力气花在回报最高的地方”:新生代对象多但存活率低,用吞吐量换时间;老年代对象少但大多存活,不必天天打扰它。JVM 和 .NET 都是这套,V8 也是差不多的思路,唯独 Go 是个明显反例,后面细说。

3. JVM 系:从分代堆到 G1、ZGC 的演进

3.1 先看懂堆分区和几个“GC 术语”的坑

JVM 的堆默认分三块:Eden、两块 Survivor(S0/S1)、Old 老年代,另外还有存放类元数据的 Metaspace,它在堆外,但也参与某些回收。

  • Minor GC / Young GC:Eden 满了触发的年轻代回收,存活对象在 S0/S1 之间复制,年龄到达阈值就晋升到老年代。默认晋升阈值是 15,但 JVM 也会根据 Survivor 实际占用做动态调整。
  • Full GC:老年代满了、Metaspace 满了、System.gc()被调用、晋升失败等情况都会触发。Full GC 往往是应用长停顿的元凶。

这里有个很容易踩的坑:很多人以为“老年代只有 Full GC 才回收”,其实 G1 在混合回收阶段就会清理老年代中存活率低的区域。所以看监控时不要一看到“老年代使用率 60%”就紧张,要先确认当前的收集器行为和回收阶段。

3.2 为什么大家从 CMS 换到 G1

CMS(Concurrent Mark Sweep)是 JVM 历史上非常有名的低延迟收集器,问题在于它不整理内存,老年代会持续产生碎片。一旦碎片化严重,分配大对象失败就会退化成 Serial Old 的 Full GC,停顿时间反而吓人。G1 改变了玩法:

  • 把堆划分成大小相等的 Region(默认 1MB 到 32MB),每个 Region 可以动态扮演 Eden、Survivor 或 Old;
  • 并发标记阶段用 SATB(Snapshot-At-The-Beginning)记录引用变化,避免并发漏标;
  • **混合回收(Mixed GC)**会优先回收垃圾比例高的老年代 Region,既控制停顿,又不产生全堆碎片。

线上配置大概长这样:

java -Xms4g -Xmx4g -XX:+UseG1GC \ -XX:MaxGCPauseMillis=100 \ -XX:G1HeapRegionSize=4m

-XX:MaxGCPauseMillis是目标停顿值,不是硬上限。G1 会尽力满足,但如果分配压力实在太大,它会通过扩大年轻代、缩短回收周期等方式牺牲一些吞吐量来换取停顿稳定。

3.3 ZGC 和 Shenandoah:把停顿压到毫秒级

如果 G1 的几毫秒到几十毫秒停顿仍然不能接受,就该看 ZGC 和 Shenandoah 了。ZGC 的做法是给指针“上色”:用指针里未使用的位记录对象状态,配合读屏障在做指针访问时判断对象是否需要转发。它把最耗时的标记、对象搬移全部并发完成,STW 时间通常只有 0.2ms 到 1ms 级别,堆越大优势越明显。

代价也很真实:并发处理的 CPU 开销更高,吞吐量通常低于 G1,而且调参空间有限。所以我的经验是:堆小于 16GB、业务对几百毫秒停顿不敏感时,老老实实用 G1;只有超大堆加苛刻延迟要求(比如 8GB 以上的在线交易引擎),才值得上 ZGC。

收集器分代并发是否移动对象停顿目标典型场景
Serial是否是分钟级单线程/客户端
Parallel是否是秒级批处理、追求吞吐
CMS是标记并发否毫秒级已被 G1 取代
G1逻辑分代标记并发是可配置大多数服务端
ZGC否几乎全并发是亚毫秒超大堆 + 低延迟

4. Go 语言:为低延迟而生的并发标记清除

4.1 Go 为什么不做分代

Go 从 1.5 起重写了 GC,整体是一个非分代、并发的标记清除收集器。它没有新生代/老年代,也不移动对象。最初社区里很多人不理解:JVM 分代效果那么好,Go 为什么不抄作业?

答案藏在 Go 的运行时约束里。Go 的 goroutine 栈可以动态增长,配合非抢占式调度,移动对象会让栈上的指针失效,必须引入读屏障,而读屏障在 Go 这种“随手写个指针赋值”的场景下成本极高。另外 Go 的编译器很早就做了逃逸分析,大量短生命周期对象直接分配在栈上,堆里的对象普遍生命周期较长,分代带来的收益被摊薄了。所以 Go 选择了“并发标记”这条路:用并行的方式压低停顿,而不是用代际去区分对象热度。

4.2 它怎么做到“几乎不停顿”

Go 的 GC 周期是这样的:

  1. Mark Prepare:短暂 STW,打开写屏障,统计根对象;
  2. 并发标记(Concurrent Mark):大部分标记工作由后台 goroutine 与用户 goroutine 并行执行;
  3. Mark Termination:又一次短暂 STW,关闭写屏障、处理残余标记;
  4. Sweep:不做统一清扫,而是在后续分配内存时按需清扫,把成本摊到日常分配里。

实测下来,Go 的 STW 通常只有几百微秒级别,这对大多数后端服务来说几乎是感知不到的。代价是 GC 的 CPU 占用是一点一点持续发生的,而不是“平时不花钱,月底交一次大账单”。

4.3 调整节奏:GOGC 和 GOMEMLIMIT

Go 有两个核心参数。第一个是环境变量GOGC,默认 100,它决定 GC 的触发时机:

目标堆大小 ≈ 当前活跃内存 × (1 + GOGC/100)

也就是说,GOGC=100的意思是“等堆涨到活跃内存的 2 倍再触发 GC”。GOGC=200是 3 倍,GC 频率降低、内存峰值变高;反过来GOGC=50是 1.5 倍,内存峰值低但 GC 更频繁。

第二个是 Go 1.19 加入的GOMEMLIMIT,它是一个软内存上限:正常运行时按 GOGC 的节奏走,但如果堆快接近这个上限,Go 会主动加大 GC 频率,避免容器被 OOM。

export GOGC=200 export GOMEMLIMIT=4GiB

我做过一个挺典型的实验:一个只处理请求缓存的服务,活跃内存约 300MB,默认 GOGC 下堆峰值冲到 600MB 左右;把GOMEMLIMIT压到 400MB 后,GC 明显变频繁,CPU 使用率升了约 8%,但内存曲线稳定许多。这类取舍只能根据你在乎的是 CPU 还是内存来做决定。

4.4 实战中踩过的 Go GC 坑

第一类是分配量过大导致的 GC 频繁。Go 的 GC 压力和你分配了多少字节直接相关,哪怕这些对象都活不到回收。查这类问题用 pprof 的allocs视图很快,但根治通常要靠复用对象:比如sync.Pool、[]byte缓冲池、预分配切片容量。这里有一个容易忽略的细节:sync.Pool里的对象在每轮 GC 后会被逐步清空,所以它只适合存“重建成本较低”的临时对象,不能当跨请求的缓存用。

第二类是大对象兜底。我在线上见过一个服务 RSS 稳定上涨、GOGC调来调去都不管用,最后定位到一个全局map存了大量值得长时间保留的记录指针。Go 非分代的设计决定了 GC 每一轮都要扫描堆上的所有存活对象,大而活的容器是性能毒药。改成分片数组或减少指针数量后,GC 的 CPU 占用直接降了一截。

5. Python 与 JavaScript:解释器世界的两种 GC 口味

5.1 CPython:引用计数为主,分代循环检测为辅

CPython 每个对象头部有一个ob_refcnt字段,这是它的第一道回收机制。你写的每个变量赋值背后都在做引用计数增减:

import sys a = [] print(sys.getrefcount(a)) # 2 = 变量 a + getrefcount 临时参数 b = a print(sys.getrefcount(a)) # 3 del b print(sys.getrefcount(a)) # 2

引用计数让内存能“即时归还”,而且因为 GIL 的存在,计数增减不需要额外加锁,这对多线程场景反而简单。但循环引用它搞不定:a.append(a)之后a的引用计数不为零,也没人能从根走到它。所以 CPython 又带了一个分代循环检测器,只跟踪那些可包含引用的容器对象,分三代,默认阈值是(700, 10, 10):

import gc print(gc.get_threshold()) # 输出 (700, 10, 10) gc.collect() # 手动触发一次全量回收

日常 Python 服务遇到的“内存越跑越高”,大概率不是 GC 不干活,而是循环引用对象里挂了__del__方法。带__del__的对象出现在循环引用中时,Python 无法确定回收顺序,会把它们放进gc.garbage列表等你去处理。我见过好几个团队排查内存泄漏查到这一步才恍然大悟。解决办法要么避免在循环引用结构里定义__del__,要么使用weakref打破环。

5.2 PyPy 为什么反过来用追踪式

CPython 的引用计数方案方便了 C 扩展,但对 JIT 编译器来说并不友好。PyPy 干脆放弃了引用计数,改用分代追踪式 GC。原因是 JIT 需要把对象“搬来搬去”以优化布局和缓存局部性,追踪式收集器天然支持移动对象和更精确的代数划分。

所以如果你用 PyPy 跑长任务,会发现它的内存增长模式更像 JVM:平时不怎么回收,到达阈值后集中做一次大回收。这也是为什么 PyPy 跑大数据处理经常比 CPython 快,但内存占用也更高的原因之一。

5.3 V8/JavaScript:年轻代 Scavenger 与老年代并发标记

JavaScript 和 Node.js 用的是 V8 引擎。V8 的堆分为年轻代和旧生代:

  • 年轻代里的对象用 Scavenger 回收,本质是复制式算法:对象在 From 空间和 To 空间之间复制,存活几次后晋升到旧生代;
  • 旧生代用标记-清除 + 标记-整理,处理长生命周期对象。

V8 的 Orinoco 项目把标记过程变成并发执行,把整理过程变成并行执行,还引入了空闲时间 GC:浏览器页面空闲时偷偷做回收,不让用户感知到卡顿。Node.js 这边,你可以用--max-old-space-size控制堆上限:

node --max-old-space-size=4096 app.js

Node 服务常见的内存问题其实不是 GC 参数该背的锅,而是“全局缓存+事件监听器引用”导致对象一直可达。GC 定义里说得再清楚也没用,很多开发者连“可达性”这个概念都没意识到,只看到内存上涨就怪 V8。排查时先用 heap snapshot 看 Retainers 链,比盲目调参有用得多。

6. .NET 与 Rust:分代后台 GC 和“没有 GC”的另一极

6.1 .NET 的三代堆与 LOH

.NET 的 GC 是经典的分代实现:Gen0、Gen1、Gen2,另有一个大对象堆(LOH),对象超过 85KB 直接进 LOH——因为在大对象上做复制搬移成本太高。.NET 还有两个独占性很强的模式选项:

  • 工作站 GC(Workstation):默认模式,适合客户端应用,一个 GC 线程;
  • 服务器 GC(Server GC):多核服务器上为每个核建一个独立堆和独立 GC 线程,吞吐量高,适合 ASP.NET Core 这类高并发服务。

.NET Server GC 还有一个很实用的能力:后台 GC(Background GC)。Gen2、LOH 的回收可以在后台并发进行,同时允许 Gen0/Gen1 的前台回收先行完成,这样线上服务不会出现一个“全都在等 GC”的超长停顿。

调参时用环境变量的方式最方便,尤其部署在 Docker 里不需要写进代码:

export DOTNET_gcServer=1 export DOTNET_gcConcurrent=1 export DOTNET_gcHeapCount=4

我个人建议:容器里跑 .NET 服务一定显式设置DOTNET_gcServer,否则默认的工作站模式在容器环境下容易掉进“单线程回收”的坑。

6.2 Rust:用所有权替掉 GC

Rust 没有 GC,但不代表它不回应“内存安全”这个问题。Rust 用一套所有权 + 生命周期规则在编译期就堵死了悬垂指针、双重释放和大部分内存泄漏。对象离开作用域时,析构函数(Drop)会被确定性地调用,与 RAII 配合,资源释放时机是完全可预测的。

于是有人问:遇到循环结构怎么办?Rust 里有Rc<T>和Arc<T>提供带引用计数的共享所有权,配合Weak<T>防止环,本质是把“垃圾回收的活”下放到标准库层面,由开发者显式选择是否引入运行时开销。

Rust 的代价也很明显:思考和编写代码时需要时刻考虑所有权归属,处理环形数据结构、并发共享数据时,代码复杂度比托管语言高不少。这背后的取舍很清晰——把性能不确定性从运行时挪到了程序员身上。

6.3 语言选型时的 GC 现实

我接过的项目里,很多团队在选型时会把“有没有 GC、停顿怎么样”当成最重要标准,这其实是本末倒置。首先你要看业务对延迟和吞吐的敏感度,其次才是语言的 GC 设计。交易网关这类低延迟场景,与其硬调 JVM 的 G1,不如考虑 Rust/Go 这类可控性更强的方案;而对一个业务逻辑复杂、迭代快的 Web 服务,Java/C# 的分代 GC 已经能覆盖绝大多数需求。别让“我要极致掌控”害死项目进度。

7. 热搜里那串builder.gc配置:基础设施也在做回收

7.1 它根本不是语言层面的 GC

最近不少人在搜一串长得像 JSON 的配置:

{ "builder": { "gc": { "defaultkeepstorage": "20gb", "enabled": true } } }

这不是某个编程语言的 GC,而是构建工具链——具体来说是 BuildKit/Docker 构建器——的镜像构建缓存自动回收配置。enabled: true表示开启构建阶段的自动回收,defaultkeepstorage: 20gb表示默认保留最近 20GB 的缓存数据,超出部分会被自动清理。

如果你用 Docker 或 Kubernetes 生态里的 BuildKit 做镜像构建,应该熟悉这个痛苦:多阶段构建会产生大量中间层、缓存层和未被引用的旧镜像,磁盘空间嗖嗖往下掉。以前大家只能手动跑docker system prune,现在构建器可以在构建过程中自动判断“哪些缓存最近还在用、哪些已经没人引用”,然后静默清掉,和语言 GC 里“从根出发找不可达对象”的思路堪称异曲同工。

7.2 基础设施的“垃圾”判定标准

语言 GC 按“是否可达”判断垃圾,构建器则按“是否被引用且最近使用”判断。镜像之间通过 tag 和 manifest 形成引用关系,被某个镜像或当前 builder session 引用的层是有用的;剩下那些孤立的旧缓存层,就是等待回收的“垃圾”。defaultkeepstorage本质上相当于给磁盘堆设了一个“软上限”:没超过 20GB,系统不着急清理;超过了,就开始按时间戳回收最旧的缓存。

我在 CI 节点上踩过一次很实在的坑:GitLab Runner 的磁盘满了,所有构建任务排队失败,第一反应是加大磁盘,结果半个月后又满。后来把defaultkeepstorage调低到 10GB,再配合构建器 GC 的调度,问题就没有复发过。

7.3 实际配置位置和一两个经验

BuildKit 的配置放在不同的地方,取决于你的使用姿势。如果是 Docker 内置 builder,通常在/etc/docker/daemon.json里写:

{ "builder": { "gc": { "defaultkeepstorage": "20gb", "enabled": true } } }

如果是独立的 buildkitd,则在buildkitd.toml里写:

[worker.oci] enabled = true gc = true [worker.oci.gc] defaultKeepStorage = "20GB"

经验是:defaultkeepstorage不是越大越好。CI 节点上缓存太多,GC 扫描本身也会花时间;生产构建频繁的项目,建议 10GB 到 20GB 起步,观察一段时间的磁盘和构建耗时再调整。如果构建并发量大,还可以考虑按项目拆分 builder,避免一个 GC 周期把所有缓存都清掉。

最后聊一句个人体会。我排查线上问题的时候,顺序通常是:先看内存曲线是不是周期性阶梯上升,再决定要不要开 GC 日志;先确认对象有没有被谁“抓住”,再考虑调参数。GC 机制再复杂,落到实际不过一句话——不断问自己,这个对象到底还能不能被访问到,如果答案是否,就放心让它走。把这句想明白,不管你在 JVM、Go、Python 还是容器构建器里遇到“回收”问题,都不至于慌。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询