阅读视图
从 1960 到 2026:一文看透 Java、Go、Python 垃圾回收器的原理与演进

本文永久链接 – https://tonybai.com/2026/04/07/garbage-collectors-deep-dive
大家好,我是Tony Bai。
为什么 Java 的 G1GC 需要设置停顿目标?Go 的混合写屏障是如何消除栈重扫的?Python 又是如何解决引用计数无法处理的循环引用?
垃圾回收(GC)不仅是语言运行时的核心,更是理解高性能系统绕不开的坎。
本文翻译自Shubham Raizada的文章《Garbage Collection: From First Principles to Modern Collectors in Java, Go and Python》。
此文通过对历史经典论文的溯源和对现代主流语言底层实现的拆解,构建了一套完整的 GC 知识体系。
文章涵盖了从基础的标记-清除、复制与整理算法,到复杂的三色标记抽象、写屏障机制以及有色指针技术。
无论你是想调优 JVM 性能,还是试图理解 Go 并发垃圾收集的吞吐成本,这篇文章都将为你提供从理论支撑到代码实现的全景视角。

以下是译文全文:
在过去的几年里,我的技术栈经历了从 Java 到 Go,再到 Rust,现在又回到了 Java 的过程。
在这些语言之间切换时,一直绕不开的一个话题就是垃圾回收(Garbage Collection, GC)。Java 和 Go 有 GC,而 Rust 没有。
在基准测试、延迟讨论以及“为什么这个服务变慢了”的对话中,GC 总会出现在某个角落。我经常听到关于 GC pauses(GC 停顿)、throughput overhead(吞吐量开销)和 write barriers(写屏障)的讨论,但我并不完全理解底层发生了什么。
在追溯起源时,我读到了 McCarthy 1960 年的论文,这篇论文因引入 Lisp 而闻名,但它也是首次描述 mark-and-sweep(标记-清除)的地方。
这又引导我阅读了 Wilson 1992 年的综述《Uniprocessor Garbage Collection Techniques》,该文将随后的所有发展组织成了一个清晰的分类学。
阅读这两篇文献让我更容易理解现代垃圾收集器,因为 G1GC、ZGC、Go 的并发收集器以及 CPython 的混合方案全都是这些论文所描述思想的变体。我还用 Go 编写了一个简单的玩具级 GC,以便亲自观察其机制。
以下是我在这一过程中的笔记。
起源论文
McCarthy (1960): Recursive Functions of Symbolic Expressions and Their Computation by Machine
这篇论文因引入 Lisp 而闻名,但垃圾回收器几乎是作为实现细节被埋藏在其中的。McCarthy 需要一种方法来管理符号表达式的内存。Lisp 程序操作的是嵌套的列表(lists of lists of lists),这种递归结构使得要求程序员手动释放内存变得不切实际。因此,他描述了一种自动执行此操作的机制。
该机制分为两个阶段。首先,从程序正在活跃使用的 root(根)变量开始,遍历它们引用的每一个对象,将每个对象标记为 reachable(可达)。其次,扫描所有内存。任何未被标记的对象都是垃圾。将它们重新添加回 free list(空闲列表)。
这就是 mark-and-sweep(标记-清除)。它能自然地处理 cycles(循环引用,因为不可达的循环永远不会被标记),不需要逐个对象的簿记工作,并让程序员可以完全忽略内存管理。
其代价是程序在收集器运行时必须完全停止。每一次分配、每一次计算,所有一切都会冻结,直到标记和清除完成。对于 McCarthy 在 1960 年编写的程序来说,这完全是合理的。
随着程序规模变大并进入对延迟敏感的环境(如处理每秒数千次请求的 Web 服务器),stop-the-world(全线停顿)成了一个难以接受的权衡。现代 GC 研究产生的大部分成果都是为了回答一个问题:如何在不停止世界的情况下进行垃圾内存回收?
Wilson (1992): Uniprocessor Garbage Collection Techniques
到 1992 年,三十年的 GC 研究已经产生了许多想法,但缺乏统一的词汇。Wilson 的综述论文将这一切组织了起来。它不是一种新算法,而是一个分类学,为散落在几十年论文中的思想赋予了名称和结构。
Wilson 正式确立了所有后续算法构建其上的三种经典算法。
第一种是 mark-and-sweep(标记-清除),即 McCarthy 的原始算法。从 roots 开始,遍历对象图,标记你能触达的所有内容,然后扫过堆并释放任何未标记的内容。它自然处理循环引用,实现简单。缺点是经过足够多的分配和回收循环后,堆会变得 fragmented(碎片化)。存活对象最终散落在各处,中间夹杂着细小的空闲间隙,分配器(allocator)必须更费力地寻找空间。
第二种是 copying(复制算法),有时被称为 semi-space(半空间)。其想法是将堆分成两个相等的部分。你在其中一半进行分配,当它填满时,将所有存活对象拷贝到另一半,然后将第一半完全丢弃。碎片消失了,因为存活对象在拷贝过程中被紧密排列在一起。分配速度很快,因为你只需移动一个 bump pointer(碰撞指针)。代价是有一半的内存始终处于空闲状态,等待成为下一次拷贝的目标。
第三种是 reference counting(引用计数)。每个对象都记录有多少个指针指向它。当创建一个新引用时,计数增加;当移除一个引用时,计数减少。当计数归零时,对象立即被释放。没有追踪过程,没有停顿,销毁是确定性的。问题在于 cycles(循环引用)。如果两个对象相互指向,即使程序中没有任何其他部分可以触达它们,它们的计数也至少为 1。仅靠引用计数,它们永远不会被释放。
除了这三种算法,Wilson 还探讨了现代垃圾回收器赖以生存的两个观察结果。
第一个是 generational hypothesis(分代假说):大多数对象死得早。在实践中,程序分配的临时对象(中间值、请求作用域的缓冲区、循环变量)往往很快变成垃圾,而只有一小部分对象会贯穿整个程序生命周期。如果你频繁回收年轻对象,偶尔回收老对象,你就能将大部分工作集中在堆中主要是垃圾的部分,这比每次都扫描所有内容的代价要小得多。
第二个是 tricolor marking(三色标记),这是一种用于增量和并发收集的抽象。你不再简单地将对象标记为已访问或未访问,而是使用三种颜色:white(白色,尚未见到)、grey(灰色,已见到但子节点尚未扫描)和 black(黑色,已完全处理)。收集器一次处理一个灰色对象。结束时,白色对象即为垃圾。这种抽象使得收集器和应用程序可以同时运行,而不会破坏彼此对堆的视图。Go 的并发 mark-and-sweep 和 ZGC 的并发标记都是这一思想的直接后裔。
本文“现代 GC”部分中的所有内容都可以映射回 Wilson 的分类。工程实现已经变得更加复杂,但底层结构依然如故。
两种基本方法
几乎所有的垃圾回收器要么是 reference counting(引用计数),要么是 tracing(追踪),或者是两者的某种结合。Wilson 的论文围绕这一划分进行组织,三十年后依然成立。
Reference Counting (引用计数)
每个对象维护一个指向它的引用计数。当引用创建时,计数增加。当引用移除时,计数减少。当计数归零时,对象立即被释放。

这是 CPython 所使用的其主要机制。它很简单,并能提供确定性的销毁。当指向文件句柄的最后一个引用消失时,del 运行,文件当场关闭,而不是在以后的某个 GC cycle中。
有两个问题使得引用计数无法独立胜任。
Cycles (循环引用)。 如果对象 A 指向对象 B,且对象 B 指向 A,那么即使程序中没有任何其他部分能触达它们,两者的计数也至少为 1。两者都不会被释放。

这并非理论上的边缘案例。循环引用在链表数据结构、父子关系、观察者模式和缓存中自然出现。稍后在介绍 CPython 的 GC 时,我将讨论 Python 如何处理这个问题。
Per-mutation overhead (每次修改的开销)。 每次指针赋值都需要更新引用计数。在多线程程序中,这些必须是 atomic(原子)操作,成本昂贵得多。每当你将对象传递给函数、返回它或将其赋值给字段时,你都要支付这种代价。
Tracing (追踪式,即 Mark-and-Sweep)
追踪式收集器不跟踪单个引用,而是从一组已知的存活引用(称为 root set,根集合)开始,遍历整个对象图。它能触达的每个对象都被标记为存活。其他所有对象都被释放。
Root set 是起点,因此什么算作 root(根)至关重要。不同语言的答案是相同的:root 是 runtime(运行时)无需追踪就能找到的任何引用。这些指针锚定在程序当前的执行状态中,是在任何遍历开始之前你就知道是存活的东西。
在实践中,roots 分为以下几类。
每个活跃 stack frame(栈帧)中的 local variables(局部变量)和函数参数都是 roots。程序正在活跃地运行这些函数,因此它们引用的任何内容定义上都是在使用中的。
Global and static variables(全局变量和静态变量)是 roots,因为它们在程序的整个生命周期内都存在。
CPU registers(CPU 寄存器)是 roots。因为当 JIT 编译器优化一个热点方法时,它可能会将频繁访问的对象引用保留在 CPU 寄存器中,而不是写回栈。如果 GC 此时运行,寄存器保存着该对象的唯一存活引用。如果 GC 不扫描寄存器,它就会释放一个仍在使用中的对象。为了防止这种情况,运行时在代码中定义了 safe points(安全点),GC 只能在这些点发生,并且在这些点,它会快照寄存器状态以寻找持有的任何引用。
Runtime(运行时)本身也持有与用户代码无关的 roots。在 JVM 中,class loaders 是 roots:你加载的每个类都由其类加载器引用,只要类加载器存活,它加载的每个类(包括它们的静态字段)就保持存活。Interned strings(常量池字符串)是 roots,因为 String.intern() 将字符串存储在 JVM 维护的共享池中。JNI handles 是 roots,因为当原生 C 或 C++ 代码通过 Java Native Interface 持有 Java 对象的引用时,该引用存在于 Java 堆外的句柄表中,GC 必须扫描它。每个活跃线程都是一个 root,其整个调用栈帧都是 root set 的一部分。
Go 的运行时遵循同样的原则。每个 goroutine 都有自己的栈,必须扫描所有 goroutine 栈以寻找 roots。运行时还跟踪自己的内部数据结构,例如 finalizer 队列,作为 root set 的一部分。

核心见解是:roots 是由运行时在无需追踪的情况下就已经知道是存活的东西定义的。其他所有东西必须通过从 root 可达来证明自己的生存权。这就是为什么这个概念是与语言无关的。Java、Go 和 Python 之间的具体 roots 集合有所不同,但原则是一样的:从你知道是存活的地方开始,向外追踪,并回收其余部分。
循环引用被自然处理。如果 A 和 B 相互指向,但都无法从任何 root 到达,则标记阶段永远不会访问它们。它们保持未标记状态并被清除。
代价:朴素的 mark-and-sweep 必须在追踪堆时暂停整个程序。这种 stop-the-world(全线停顿)是早期垃圾回收器的核心问题,也是现代 GC 几十年来工程化改进的重点。
为什么大多数现代 GC 都是追踪式的
在具有高分配速率的服务器工作负载中,引用计数的逐次修改成本会积少成多。每次指针写入都会增减计数。在多线程程序中,这些更新必须是原子的,而原子操作很昂贵。在数十个线程中每秒进行数千次分配时,这种开销变得可衡量。此外,循环引用问题无论如何都需要一个补充的追踪步骤。而且追踪式收集器可以做成并发的,在应用程序运行的同时运行,只有简短的停顿。
Java 和 Go 使用追踪式收集器。Python 是一个显著的例外,它以引用计数为基础,并在此之上增加了一层用于追踪循环引用的检测器。
追踪式的变体
Wilson 的论文描述了实现追踪的四种方式,每种方式都有不同的权衡。
Mark-Sweep (标记-清除)
最简单的追踪式收集器。分为两个阶段:
- Mark (标记): 从 roots 开始,遍历对象图并在每个可达对象上设置标记位。
- Sweep (清除): 遍历整个堆。任何没有标记位的对象都是垃圾。释放它并将内存添加回空闲列表。

Mark-sweep 的主要问题是 fragmentation(碎片化)。经过足够的回收周期后,堆看起来就像瑞士奶酪:存活对象散布其间,中间有很小的空闲间隙。你总共可能有 100MB 空闲内存,但没有一个连续的块大到足以满足一次新分配。分配器必须维护一个 free list 并搜索合适的空间,随着堆变得碎片化,这会变慢。
Copying (Semi-Space,复制算法/半空间)
堆被分成两个相等的一半:from-space(源空间)和 to-space(目标空间)。分配发生在 from-space,使用简单的 bump pointer(碰撞指针)。当 from-space 填满时,收集器将所有存活对象拷贝到 to-space,更新所有指针,然后交换两者的角色。旧的 from-space 被完全丢弃。

分配速度极快,因为它只是一个指针移动。Compaction(压缩)自然发生。代价是任何时候只有一半的堆可用。
Mark-Compact (标记-整理)
标记阶段与 mark-sweep 相同,但收集器不是简单地释放未标记的对象,而是将所有存活对象滑动到堆的一端。这消除了碎片,且没有复制算法 50% 的内存开销。

缺点是整理需要对堆进行多次扫描:一次标记,一次计算新地址,一次更新所有指针,一次移动对象。
The Generational Hypothesis (分代假说)
Wilson 论文中最具影响力的观察之一是弱分代假说:大多数对象死得早。
在典型的 Web 服务器中,每个请求都会创建临时对象(解析器、中间字符串、响应构建器),它们只存活几毫秒。配置对象、连接池和缓存则贯穿整个应用程序生命周期。
分代收集器利用这一点,将堆划分为 generations(代)。新对象进入 young generation(年轻代)。如果它们在几次回收中幸存下来,就会被提升到 old generation(老年代)。年轻代回收频繁且速度快,因为那里的大多数对象已经死了。老年代回收较少发生。

Eden 是所有新对象出生的地方。每一个 new Object() 都去这里。它很快就会填满,因为大多数程序分配速率很高。
S0 和 S1 是两个较小的 survivor spaces(幸存者空间)。当 Eden 填满并运行 minor GC(次要回收)时,收集器将 Eden 中的每个存活对象拷贝到其中一个空间(比如 S0)。下一次回收时,来自 Eden 和 S0 的幸存者被拷贝到 S1。再下一次,回到 S0。它们在每个周期轮换。这是年轻代中的复制算法:没有碎片,没有空闲列表,只有两半空间轮流充当目标。代价是你需要两个幸存者空间,但它们保持得很小,因为到回收运行时,Eden 中的大多数对象都已经死了。
Promotion to old generation (提升到老年代)。 在对象在 S0 和 S1 之间反弹足够多次之后(JVM 中的默认阈值是 15 次),收集器认定它已赢得了一席之地,并将其提升到老年代。老年代回收频率低得多,并且使用更重的算法(标记-整理而非复制),因为那里的对象庞大且长寿。
关键的实现挑战是跟踪从老对象到新对象的引用。如果一个老对象指向一个年轻对象,即使没有年轻代 root 指向它,该年轻对象也绝不能被回收。这通过 write barrier(写屏障)解决,即在每次指针写入时注入的一小段代码,用于在 remembered set(记录集)中记录跨代引用。
用 Go 构建一个玩具级 Mark-and-Sweep GC
我写了一个极简的 mark-and-sweep 收集器来使这些概念具体化。它大约有 70 行代码,演示了完整循环:分配对象、构建对象图、从 roots 标记以及清除不可达对象。
package main
import "fmt"
// Object 代表一个在堆上分配的对象。
type Object struct {
name string
marked bool
children []*Object
}
// VM 是一个带有垃圾回收器的微型虚拟机。
type VM struct {
heap []*Object
roots []*Object // 模拟栈变量和全局变量
}
// NewObject 在 VM 的堆上分配一个对象。
func (vm *VM) NewObject(name string) *Object {
obj := &Object{name: name}
vm.heap = append(vm.heap, obj)
return obj
}
// mark 从每个 root 开始遍历并标记所有可达对象。
func (vm *VM) mark() {
for _, root := range vm.roots {
vm.markObject(root)
}
}
func (vm *VM) markObject(obj *Object) {
if obj == nil || obj.marked {
return
}
obj.marked = true
for _, child := range obj.children {
vm.markObject(child)
}
}
// sweep 释放未标记的对象并重置幸存者的标记。
func (vm *VM) sweep() {
alive := []*Object{}
for _, obj := range vm.heap {
if obj.marked {
obj.marked = false // 为下一个 GC 周期重置
alive = append(alive, obj)
} else {
fmt.Printf(" collected: %s\n", obj.name)
}
}
vm.heap = alive
}
// GC 运行一次完整的 mark-and-sweep 回收。
func (vm *VM) GC() {
fmt.Printf("gc: heap has %d objects\n", len(vm.heap))
vm.mark()
vm.sweep()
fmt.Printf("gc: %d objects remain\n\n", len(vm.heap))
}
func main() {
vm := &VM{}
a := vm.NewObject("A")
b := vm.NewObject("B")
c := vm.NewObject("C")
_ = vm.NewObject("D") // 已分配但从未链接到任何东西
// 构建图: A -> B -> C
a.children = append(a.children, b)
b.children = append(b.children, c)
// 只有 A 是 root
vm.roots = append(vm.roots, a)
fmt.Println("=== GC #1: D is unreachable ===")
vm.GC()
// 创建循环: C -> A, 然后移除所有 roots
c.children = append(c.children, a)
vm.roots = nil
fmt.Println("=== GC #2: A->B->C->A cycle, no roots ===")
vm.GC()
}
运行结果:
=== GC #1: D is unreachable ===
gc: heap has 4 objects
collected: D
gc: 3 objects remain
=== GC #2: A->B->C->A cycle, no roots ===
gc: heap has 3 objects
collected: A
collected: B
collected: C
gc: 0 objects remain
第一次回收:A、B 和 C 通过 root A 可达。D 没有任何 root 路径,因此被回收。
第二次回收:A、B 和 C 形成了一个循环(A->B->C->A),但没有 roots。标记阶段从未访问过它们中的任何一个。所有三个都被清除了。这正是击败引用计数的场景。循环中的每个对象都有非零的引用计数,但没有一个能从 root 到达。
追踪式 GC 不关心循环。它们只关心从 roots 开始的可达性。
有一点需要注意:markObject 函数使用了递归,这在深层对象图上会耗尽栈空间。真实的垃圾回收器使用显式的 worklist(工作列表)而不是调用栈。
现代 GC 实现
上面的玩具收集器为了整个标记和清除过程停止了世界。现代 GC 已经进化到在应用程序持续运行的同时并发完成大部分工作。
Go: 三色并发标记-清除 (Tri-Color Concurrent Mark-and-Sweep)
Go 的垃圾回收器是非分代的、非整理的且并发的。它不按年龄区分对象,也不在内存中移动对象。其重点是保持低停顿时间。
收集器使用三色抽象(tri-color abstraction)进行并发标记。每个对象处于三种状态之一:

- White (白色): 尚未访问。标记结束时仍为白色的任何东西都是垃圾。
- Grey (灰色): 已访问,但其子节点尚未全部扫描。遍历的前沿(frontier)。
- Black (黑色): 已访问,所有子节点已扫描。确定存活。
收集器开始时将所有对象设为白色,然后将 roots 设为灰色,并处理灰色对象直到不再剩余。所有仍为白色的内容都被清除。

开始: 所有对象为白色,roots 为灰色
步骤 1: 选取一个灰色对象,扫描其子节点
- 将子节点标为灰色
- 将扫描过的对象标为黑色
步骤 2: 重复直到没有灰色对象剩余
步骤 3: 所有白色对象都是垃圾
示例:
Roots: [A]
开始: A(grey) --> B(white) --> D(white)
A(grey) --> C(white)
扫描 A: A(black) --> B(grey) --> D(white)
A(black) --> C(grey)
扫描 B: A(black) --> B(black) --> D(grey)
A(black) --> C(grey)
扫描 C: A(black) --> B(black) --> D(grey)
A(black) --> C(black)
扫描 D: A(black) --> B(black) --> D(black)
A(black) --> C(black)
结果: 任何剩余的白色对象都是垃圾并被释放
难点在于应用程序在收集器遍历时持续运行并修改指针。这造成了一个需要仔细处理的正确性问题。
收集器认为黑色对象已完成。一旦对象变黑,收集器就不会再扫描它。它的所有子节点都已被访问并设为灰色。但是,如果应用程序在收集器仍在运行时,将一个指向白色对象的指针写入黑色对象,收集器就有麻烦了。黑色对象已经处理完了。该白色对象也无法从任何灰色对象触达。当标记阶段结束并清除运行时,该白色对象将被释放,即便有一个存活的黑色对象指向它。
这被称为 tricolor invariant(三色不变性):黑色对象绝不能直接指向白色对象。如果发生了这种情况,白色对象对收集器是不可见的,会被错误释放。write barrier(写屏障)的存在专门用于在并发标记期间应用程序修改对象图时维护这一不变性。
Go 通过 hybrid write barrier(混合写屏障,Go 1.8 引入)解决了这个问题。要理解它为什么有效,看看它结合的两种旧屏障会有所帮助。
Dijkstra’s 插入屏障 (1978):每当一个指针被写入对象时,将新的被引用者设为灰色。如果一个黑色对象存储了对白色对象的引用,该白色对象会在收集器错过它之前变灰。这维护了三色不变性。
问题在于 goroutine 栈与堆对象不同。编译器在堆指针写入处注入写屏障,例如写入结构体字段或切片元素。栈写入是局部变量赋值,编译器对其分别处理。在每一个局部变量赋值上放屏障会使函数调用和基本操作变得极其昂贵,所以屏障不覆盖它们。这意味着在并发标记期间,goroutine 可以自由地将指向白色对象的指针写入局部变量,而没有屏障触发。收集器不知道发生了这事。
为了修复这一点,在并发标记结束时,Go 曾经必须停止世界并从头重新扫描每个 goroutine 的整个栈。重新扫描时发现的任何指向白色对象的指针都会变灰,防止它们被错误释放。此步骤的停顿时间随着 goroutine 数量和其栈大小而增加。拥有成千上万个 goroutine 的程序可能会看到数毫秒的 STW 停顿,仅仅是为了这次重新扫描。这是 Go 1.8 之前主要的 STW 停顿来源。
Yuasa’s 删除屏障 (1990) 采取相反的方法:每当一个指针即将被覆盖时,在旧引用消失前将其变灰。这确保了在标记开始时可达的任何东西直到结束都保持可达,即便应用程序在标记期间丢弃了它的引用。缺点是标记期间死亡的一些对象会存活到下一个周期(floating garbage,浮动垃圾),因为屏障保守地让它们活着。
Go 的混合屏障结合了两者。在堆写入时,它同时应用两种屏障:将旧引用变灰(Yuasa)并将新引用变灰(Dijkstra)。在栈写入时,不运行屏障,但栈上新分配的对象开始时就是黑色而不是白色。这种组合赋予了收集器足够强的不变性,使其在标记结束时永远不需要重新扫描栈。STW 停顿从几十毫秒降到了不到一毫秒。
// 混合屏障在堆指针写入时的逻辑:
// *slot = new_ptr
shade(*slot) // 将旧引用变灰 (Yuasa: 不要丢掉之前在那里的内容)
shade(new_ptr) // 将新引用变灰 (Dijkstra: 不要错过新到来的内容)
*slot = new_ptr
这就是并发垃圾回收的吞吐量成本:标记阶段的每一次堆指针写入都要运行此 shade 逻辑。单次操作开销虽小,但在高分配速率下会累积。权衡的结果是你获得了亚毫秒级的 STW 停顿,而不是几十毫秒。
Go 仅简短地停止世界以扫描 goroutine 栈并切换写屏障的开关。实际的标记和清除与应用程序并发进行。
No compaction (无整理)。 Go 在分配后不移动对象。相反,Go 使用 tcmalloc 风格的分配器,将内存划分为 size classes(大小类),并从每个处理器的缓存(per-processor caches)中分配。对象被分组为固定的大小类(8 字节、16 字节、32 字节,最高达 32 KB)。分配时从空闲列表中选取合适大小的槽。这减少了碎片而无需移动对象,但并不能完全消除碎片。
No generational collection (无分代收集)。 Go 团队的理由是,考虑到 Go 典型的带有 goroutine 和并发工作负载的分配模式,分代 GC 增加的复杂性(用于跟踪老到新指针的写屏障、提升逻辑、分代大小调优)带来的收益是不确定的。Go 通过使其并发标记器足够快来补偿,从而使额外的回收频率变得可以接受。
关键里程碑:
- Go 1.5 (2015):引入并发 GC。在此之前,Go 使用全停顿收集器,停顿时间达 10-100ms 或更多。此版本使 Go 能够胜任延迟敏感型服务。
- Go 1.8 (2017):混合写屏障。降低了在并发标记期间维护三色不变性的开销。
- Go 1.19 (2022):GOMEMLIMIT。使 Go 程序能在容器环境的内存预算内工作。
GOGC 调节旋钮。 Go 提供了一个主要的调优参数:GOGC。它控制在下一次 GC 触发之前堆可以增长多少。默认值是 100,意味着当堆自上次回收以来翻倍时触发 GC。

GOGC=100 (默认):
GC 后,存活堆 = 500MB
下次 GC 触发点: 500MB * (1 + 100/100) = 1000MB
GOGC=50 (更激进):
GC 后,存活堆 = 500MB
下次 GC 触发点: 500MB * (1 + 50/100) = 750MB
GOGC=200 (较保守):
GC 后,存活堆 = 500MB
下次 GC 触发点: 500MB * (1 + 200/100) = 1500MB
更低的 GOGC 意味着更频繁的回收(更低的内存占用,更高的 CPU 开销)。更高的 GOGC 意味着较少的回收(更高的内存占用,更低的 CPU 开销)。
Go 1.19 增加了 GOMEMLIMIT,这是一个软内存限制。在具有硬性内存预算的容器环境中,GOMEMLIMIT 告诉 GC pacer(步调算法)在内存使用接近限制时变得更加激进。
亲自尝试:
package main
import (
"fmt"
"runtime"
"time"
)
var longLived []*[1024 * 1024]byte
func main() {
fmt.Println("Go version:", runtime.Version())
for round := 0; round < 50; round++ {
// 短寿对象: 分配小对象,让它们死亡
for i := 0; i < 5000; i++ {
_ = make([]byte, 1024)
}
// 长寿对象: 每 10 轮保留一个
if round%10 == 0 {
arr := new([1024 * 1024]byte)
longLived = append(longLived, arr)
}
time.Sleep(50 * time.Millisecond)
}
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
fmt.Printf("Total GC cycles: %d\n", stats.NumGC)
fmt.Printf("Total STW pause: %v\n", time.Duration(stats.PauseTotalNs))
fmt.Printf("Long-lived objects: %d\n", len(longLived))
}
运行并开启 GC 追踪:
GODEBUG=gctrace=1 go run gcdemo.go
观察输出内容:
gc 1 @0.011s 1%: 0.044+0.56+0.13 ms clock, 0.62+0.21/0.57/0+1.8 ms cpu, 3->4->0 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 14 P
从左到右阅读:
- gc 1: GC 周期编号
- @0.011s: 自程序启动的时间
- 1%: 到目前为止 GC 消耗的 CPU 百分比
-
0.044+0.56+0.13 ms clock: GC 周期的三个阶段:STW 标记开始 (0.044ms) + 并发标记和扫描 (0.56ms) + STW 标记结束 (0.13ms)。STW 停顿是 clock 字段中的第一个和第三个数字。在此例中,应用程序被冻结的总墙钟时间是 0.044 + 0.13 = 0.174ms。中间的 0.56ms 是并发的:你的应用程序一直在运行。在 Go 中,STW 停顿通常在 1ms 以下,往往远低于 0.1ms。
-
0.62+0.21/0.57/0+1.8 ms cpu: CPU 时间细目。格式为:STW-开始 + 辅助/背景/空闲 + STW-结束。每个数字代表:
- 0.62ms — STW 标记开始时所有核心的 CPU 总时间。高于墙钟时间 (0.044ms),因为 Go 会在多个核心上并行化初始栈扫描。
- 0.21ms — 应用程序 goroutine 执行 mutator assists(赋值器辅助)所花费的 CPU 时间。当某个 goroutine 分配速度超过 GC 处理速度时,它会被“征税”,必须在允许其分配之前自己做一些标记工作。
- 0.57ms — 专用背景 GC 工作 goroutine 执行并发标记所使用的 CPU 时间。
- 0 — 空闲 GC 工作者的 CPU 时间(仅在调度器没有其他任务运行时才领取 GC 任务的 goroutine)。此处为零意味着专用工作者处理了所有事情。
- 1.8ms — STW 标记结束时所有核心的 CPU 总时间。高于墙钟 (0.13ms),因为多个核心并行工作以排空剩余任务并禁用写屏障。

当多个核心并行工作时,CPU 时间可以超过墙钟时间。并发阶段的 CPU 时间可能少于墙钟时间,因为 GC 与你的应用程序共享核心。
- 3->4->0 MB: GC 开始时的堆大小、GC 触发点的堆大小、GC 完成后的存活堆大小
- 4 MB goal: 下次 GC 触发前的目标堆大小(基于 GOGC 和当前存活堆)
- 0 MB stacks: goroutine 栈使用的内存
- 0 MB globals: 标记期间扫描的全局变量使用的内存
- 14 P: 逻辑处理器数量 (GOMAXPROCS)
Java: G1GC (Garbage First Collector)
G1GC 自 JDK 9 以来一直是 Java 的默认垃圾回收器。它是一个分代的、基于区域(region)的收集器。它进行追踪、标记和整理,但它是增量式进行的,而不是一次性完成。
Region layout (区域布局)。 G1 将堆划分为大小相等的区域,通常每个区域为 1MB 到 32MB,取决于堆的大小。每个区域在任何时候扮演四种角色之一:Eden(伊甸园)、Survivor(幸存者)、Old(老年代)或 Humongous(巨型对象,用于超过半个区域大小的对象)。区域的角色可以在不同回收周期之间改变。

Young collection (次要 GC)。 Eden 区域填满。G1 停止世界,使用并行多线程标记器标记 Eden 和 Survivor 区域中的存活对象,将幸存者拷贝到新的 Survivor 区域或提升到 Old 区域,并完全丢弃旧的 Eden 区域。这是一个并行的 STW 停顿,但很短,因为年轻代区域较小且年轻对象大多已死。
Mixed collection (混合回收)。 G1 周期性地运行并发标记周期,以找出哪些老年代区域包含的垃圾最多。然后运行混合回收:同时疏散(evacuating)年轻代区域和最具“盈利价值”的老年代区域。这就是“Garbage First”名称的由来。G1 总是优先选取垃圾密度最高的老年代区域,从而在单位停顿时间内实现最大的回收量。
SATB (Snapshot-At-The-Beginning,起始快照)。 在并发标记期间,应用程序持续运行并修改对象图。G1 使用 SATB 维护正确性。在标记开始时,G1 对哪些对象存活进行逻辑快照。该快照中存活的对象在此周期被视为存活,即使应用程序在标记期间丢弃了它们。写屏障将修改字段的旧值记录到 SATB 队列中。这种做法是保守的(一些垃圾会存活到下个周期),但是正确的。
并发标记正在运行。应用程序执行:
obj.field = null (原本指向 X)
没有 SATB: X 可能没有其他引用,未被标记,在使用中被释放。
有 SATB: 写屏障记录“此处曾有 X”,将 X 标为灰色。安全。
Pause target (停顿目标)。 你可以通过 -XX:MaxGCPauseMillis 配置 G1 的目标最大停顿时间。默认值是 200ms。G1 通过调整区域数量、回收集合大小和时机,尝试将停顿保持在目标范围内。它并不总是能成功,特别是在 Full GC 期间,但它是主要的调优旋钮。
亲自尝试:
import java.util.ArrayList;
import java.util.List;
public class GCDemo {
static List<byte[]> longLived = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
System.out.println("Starting GC demo...");
for (int round = 0; round < 50; round++) {
// 短寿对象:创建并立即丢弃
for (int i = 0; i < 1000; i++) {
byte[] tmp = new byte[10 * 1024]; // 每个 10KB
}
// 长寿对象:保留一些对象以构建老年代
if (round % 5 == 0) {
longLived.add(new byte[1024 * 1024]); // 1MB
}
Thread.sleep(50);
}
System.out.println("Done. Long-lived objects: " + longLived.size());
}
}
使用 G1GC 日志运行:
# 编译
javac GCDemo.java
# 使用 G1GC (Java 9+ 默认) 并开启 GC 日志
java -Xmx256m \
-XX:+UseG1GC \
"-Xlog:gc*:file=gc_g1.log:time,uptime,level,tags" \
GCDemo
# 或者,使用简洁的一行输出
java -Xmx256m -Xlog:gc GCDemo
观察日志:
[0.005s][info][gc] Using G1
[0.135s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 26M->3M(256M) 0.644ms
[0.812s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 132M->7M(256M) 0.707ms
[1.710s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 165M->13M(256M) 1.019ms
[2.528s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 171M->19M(256M) 0.964ms
阅读日志:
- Using G1: 确认 G1GC 是活跃收集器
- Pause Young (Normal): 回收 Eden 和 Survivor 区域的次要 GC
- G1 Evacuation Pause: G1 正在将存活对象从回收区域拷贝(疏散)到新区域
- 26M->3M(256M) 0.644ms: 堆之前是 26MB,之后是 3MB,总堆容量 256MB,停顿耗时 0.644ms
- 在 2.5 秒的运行时中进行了四个 GC 周期,每个周期在 1.1ms 内完成。大多数分配的对象都是短寿的,并在年轻代被回收。
Java: ZGC (Z Garbage Collector)
ZGC 自 Java 11 起可用,并在 Java 15 中达到生产就绪状态。扩展了分代收集的 Generational ZGC 在 Java 21 中引入。ZGC 的目标是无论堆大小如何(包括数百 GB 的堆),停顿时间均保持在亚毫秒级。
G1 在年轻代回收时停顿较短,但随着堆的增长,在并发标记设置和混合回收期间会有更长的停顿。ZGC 的方法不同:它几乎将所有工作(标记、重定位、引用处理)并发进行,将 STW 工作降至最低。
Colored pointers (有色指针)。 ZGC 直接在指针位中编码 GC 元数据。在 64 位平台上,指针宽度为 64 位,但你实际上并不需要所有 64 位来寻址内存。2^42 就能给你 4TB 的可寻址空间,这超出了大多数应用程序的使用范围。这使得每个指针中留有超过 20 位空闲。ZGC 重新利用其中一些空闲位,直接在指针内部存储 GC 状态。

每个元数据位都有特定用途:
-
M0 和 M1 (标记位): 用于跟踪对象是否已被标记为存活。ZGC 在每个 GC 周期中交替使用 M0 和 M1。在周期 1,收集器对每个可达对象设置 M0。在周期 2,它改用 M1。这样收集器就能区分“本周期标记”和“上个周期标记”,而无需在周期之间清除所有标记位。
-
Remap (R,重映射): 此位跟踪在对象重定位(relocated)后指针是否已更新。在并发重定位期间,ZGC 将对象移动到新地址,但并不立即更新堆中的每一个指针。相反,它保留旧指针,并使 remap 位处于未设置状态。当应用程序加载这些过时指针之一时,load barrier(读屏障/加载屏障)会注意到 remap 位未设置,并对其进行修正。
-
Finalizable (F): 表示该对象具有一个需要在释放前运行的 finalizer。
巧妙之处在于元数据随指针移动。GC 不需要一个单独的侧表来查找对象的 GC 状态。每个指针都已经携带了它。
Load barriers (加载屏障)。 每次应用程序从堆加载引用时,ZGC 都会插入一个加载屏障。屏障检查指针的颜色位,如果它们不处于预期状态,则采取行动。
以下是实际操作中的情况。假设收集器在并发重定位阶段将一个对象从地址 0×1000 移动到了 0×2000。应用程序仍然持有一个地址为 0×1000 且 remap 位未设置的指针。
应用程序代码:
Object x = obj.field;
实际执行的内容:
raw_ptr = load obj.field // raw_ptr = 0x1000, remap bit = 0
if (raw_ptr.color != expected) { // remap bit 为 0, expected 为 1 → 进入 slow path
new_addr = forwarding_table[0x1000] // 查找: 对象已移动到 0x2000
raw_ptr = set_address(raw_ptr, 0x2000)
raw_ptr = set_remap_bit(raw_ptr)
obj.field = raw_ptr // 就地修正指针,以便下次使用
}
x = raw_ptr // x 现在指向 0x2000
下次任何线程加载 obj.field 时,remap 位已经设置好了。屏障检查通过 fast path,没有额外工作。过时指针在第一次访问时被惰性修正。
这是关键机制。与其像 G1 在疏散期间那样让 GC 停止世界以一次性更新所有指向重定位对象的指针,ZGC 让应用程序在遇到指针时逐个修正。代价是每次指针加载都要支付屏障检查的开销,即便没有任何东西被重定位。在实践中,fast path(检查几位)执行代价足够小,与避免 STW 重定位停顿带来的收益相比,开销很小。
Concurrent relocation (并发重定位)。 G1 停止世界以将存活对象从回收区域中疏散。ZGC 在应用程序运行的同时重定位对象。它能做到这一点是因为加载屏障处理了指针修正。在启动和结束每个阶段(标记开始、标记结束、重定位开始)时有简短的 STW 停顿,但这些通常远低于 1ms。拷贝对象和修正指针的实际工作是并发发生的。
Generational ZGC (Java 21+)。 最初的 ZGC 不按年龄划分堆。分代 ZGC 增加了年轻代和老年代,同时保留了亚毫秒级停顿的保证。它更频繁地回收年轻区域(垃圾最多的地方),较少回收老年代区域。加载屏障和有色指针机制被扩展以处理分代写屏障。
何时使用 ZGC vs G1:

亲自尝试:
# 使用 ZGC 运行
java -Xmx256m \
-XX:+UseZGC \
"-Xlog:gc*:file=gc_zgc.log:time,uptime,level,tags" \
GCDemo
# 使用分代 ZGC (Java 21+)
java -Xmx256m \
-XX:+UseZGC -XX:+ZGenerational \
-Xlog:gc \
GCDemo
观察日志:
[0.318s] GC(0) Garbage Collection (Warmup) 28M(11%)->12M(5%)
[0.321s] GC(0) Pause Mark Start 0.023ms
[0.489s] GC(0) Concurrent Mark 168.123ms
[0.491s] GC(0) Pause Mark End 0.019ms
[0.492s] GC(0) Concurrent Select Relocation Set 1.234ms
[0.502s] GC(0) Concurrent Relocate 10.456ms
STW 停顿是标记为“Pause”的行。其他所有内容都是并发的。将此处的停顿持续时间与 G1 的输出进行对比。
Python: 引用计数加循环 GC
CPython(Python 的参考实现)是“追踪式收集器占主导”模式的主要例外。它使用引用计数作为主要机制,并在之上增加了一层用于追踪循环引用的检测器。
CPython 中的引用计数。
每个 Python 对象都有一个 ob_refcnt 字段。Python 的 C API 在 Py_INCREF 时增加,在 Py_DECREF 时减少。当计数归零时,对象在 _Py_Dealloc 中被立即释放。这赋予了 Python 确定性的销毁:del 方法和上下文管理器的 exit 调用在最后一个引用掉落的那一刻发生。
import sys
x = []
print(sys.getrefcount(x)) # 2: 1个来自x,1个来自getrefcount参数本身的临时引用
y = x
print(sys.getrefcount(x)) # 3: 1个x, 1个y, 1个getrefcount参数
del y
print(sys.getrefcount(x)) # 2: 回到1个x, 1个getrefcount参数
循环引用问题。 仅靠引用计数无法回收循环垃圾。
import gc
# 创建循环引用
class Node:
def __init__(self, name):
self.name = name
self.ref = None
a = Node("A")
b = Node("B")
a.ref = b
b.ref = a # cycle: A -> B -> A
# a 和 b 的计数都 >= 1(由于相互引用)。
# 仅靠引用计数,两者都不会被释放。
del a
del b
# a 和 b 依然存活!Refcount: A 为 1 (来自 b.ref), B 为 1 (来自 a.ref)
# 显式触发循环检测器
collected = gc.collect()
print(f"Collected {collected} objects") # 收集了 4 个对象 (2个node + 2个dict)
引用计数处理了常见情况,但它无法收集循环引用。CPython 的答案是运行在引用计数系统之上的独立循环检测器。其实现在 Modules/gcmodule.c 中。
循环检测器是一个追踪式收集器,但它并不追踪整个堆。它仅跟踪能够参与循环引用的对象:如列表、字典、集合及用户自定义类实例等容器对象。字符串和整数无法持有对其他对象的引用,因此无需跟踪它们。
与 Java 的收集器一样,循环检测器使用分代方法。共有三代,编号为 0、1 和 2。思路与我们之前讨论的分代假说相同:大多数对象死得早,所以经常检查年轻对象,少打扰老对象。默认阈值硬编码在 CPython 的 Modules/gcmodule.c 中:
struct gc_generation generations[NUM_GENERATIONS] = {
/* PyGC_Head, threshold, count */
{ {(uintptr_t)_GEN_HEAD(0), (uintptr_t)_GEN_HEAD(0)}, 700, 0},
{ {(uintptr_t)_GEN_HEAD(1), (uintptr_t)_GEN_HEAD(1)}, 10, 0},
{ {(uintptr_t)_GEN_HEAD(2), (uintptr_t)_GEN_HEAD(2)}, 10, 0},
};
你可以验证你的运行时实际使用的是什么:
python3 -c "import gc; print(gc.get_threshold())"
# (700, 10, 10)
请注意,某些框架和发行版会在启动时通过 gc.set_threshold() 覆盖这些默认值,因此你的环境可能显示不同的值。
第 0 代持有新分配的容器对象。当自上次回收以来的新分配数量超过阈值(默认 700)时,回收第 0 代。幸存的对象被提升到第 1 代。在第 0 代被回收 10 次后,第 1 代被回收一次。幸存者移至第 2 代。在第 1 代被回收 10 次后,第 2 代被回收一次。
效果是第 0 代大约每 700 次分配回收一次,第 1 代大约每 7,000 次,第 2 代大约每 70,000 次。进入第 2 代的长寿对象几乎永远不会被打扰。检测器将其大部分时间花在最年轻的对象上,这些对象最有可能最近变成了垃圾。
你可以看到这些计数:
import gc
# 当前各代阈值
print(gc.get_threshold()) # (700, 10, 10)
# 当前分配计数: (gen0分配, 自上次gen1回收以来的gen0回收数, 自上次gen2回收以来的gen1回收数)
print(gc.get_count()) # 例如 (342, 8, 2)
# 强制进行全量回收
gc.collect()
# 完全禁用循环检测器 (如果你确定代码中没有循环引用)
gc.disable()
当检测器在某一代码代上运行时,它需要找出哪些对象仅被循环引用保持存活。通过一个例子更容易理解算法。
假设检测器正在查看三个被跟踪的对象:X、Y 和 Z。X 指向 Y 和 Z。Y 指回 X。还有一个局部变量持有对 X 的引用。

步骤 1:拷贝引用计数。X=2, Y=1, Z=1。
步骤 2:减去内部引用。Y 指向 X,所以从 X 的副本中减 1 (X 从 2 变为 1)。X 指向 Y,所以从 Y 的副本中减 1 (Y 从 1 变为 0)。X 指向 Z,所以从 Z 的副本中减 1 (Z 从 1 变为 0)。
步骤 3:检查剩余部分。X 的调整后计数为 1。被跟踪集合之外的某些东西(局部变量)仍然指向它。X 存活。Y 和 Z 虽然调整后计数为 0,但它们可以从 X 到达,因此它们也幸存下来。
现在想象局部变量消失了。X 的引用计数掉到 1 (只有 Y 指向它)。运行相同算法:拷贝 X=1, Y=1, Z=1。减去内部引用:X 变为 0, Y 变为 0, Z 变为 0。每个调整后的计数都是零。被跟踪集合之外没有任何东西指向它们。它们仅因彼此而存在。三者都是垃圾。
这就是核心思想。算法寻找那些存在的唯一理由是同一集合中其他对象的目标。
有一个边缘案例困扰了多年:finalizers(终结器)。
终结器是运行时在对象被销毁前调用的方法,给予其清理外部资源(如文件句柄或网络连接)的机会。在 Python 中,这就是 del 方法。
假设 A 和 B 处于循环中,且两者都有 del 方法。检测器知道它们是垃圾,但要释放它们,它需要打破循环。问题是:哪个 del 先运行?如果你先运行 A 的终结器,而它尝试使用 B,但 B 已经正在被销毁,你就会崩溃。如果你先运行 B 的,而它使用 A,同样的问题。没有安全的顺序。
在 Python 3.4 之前,CPython 直接放弃处理这些对象。它将它们放入名为 gc.garbage 的列表中,且永远不释放它们。如果你的代码创建了带有 del 的循环引用,你就会有一个静默的内存泄漏。PEP 442 通过在打破任何引用之前调用终结器修复了这个问题。当 A 和 B 的 del 运行时,两者都保持完整。只有在所有终结器执行完毕后,检测器才会打破循环并释放对象。
关于 CPython 的内存模型还有一件事值得了解。每当 Python 执行类似 x = some_object 的操作时,它会增加 some_object 的引用计数(C 语言中的 Py_INCREF)。每当变量超出作用域时,它减少计数 (Py_DECREF)。在 C 中这些是普通的整数操作:refcount += 1, refcount -= 1。没有锁,没有原子指令。
在多线程程序中,这是一个问题。两个线程可能同时增加同一个对象的引用计数。如果没有同步,一个增加操作会丢失(经典的竞态条件),之后该对象可能会在有人仍在使用时被释放。
GIL (全局解释器锁) 防止了这种情况。一次只有一个线程执行 Python 字节码,因此两个线程永远不会同时修改同一个引用计数。GIL 免费使所有引用计数操作变得安全,而无需任何原子指令。
这也是移除 GIL 如此困难的原因。如果拿掉它,整个代码库中的每一个 Py_INCREF 和 Py_DECREF 都需要变成原子操作。原子操作比普通整数增量要昂贵得多。Python 3.13 开始附带实验性的 free-threaded 模式,它使用 biased reference counting(偏向引用计数)来降低这种成本:创建对象的线程可以对引用计数进行廉价的非原子更新,只有访问该对象的其他线程才支付原子操作的代价。
映射回 Wilson:全景图
每一种现代垃圾回收器都可以映射回 Wilson 在 1992 年描述的两个家族。它们之间的区别在于关于如何最小化停顿、处理并发以及高效管理内存的工程决策。

从这一对比中可以观察到几点:
Wilson 的追踪式家族在服务器运行时占据主导地位。 引用计数用于 Swift、Python 和 Rust 的 Arc,但对于具有高分配速率的托管运行时,追踪式收集器是标准做法。循环引用问题无论如何都需要补充追踪步骤,这增加了复杂性,且无法消除每次修改时的引用计数开销。
分代收集除 Go 以外随处可见。 Java 重度利用了分代假说。Python 的循环检测器使用了三代。Go 最初选择不使用分代收集,因为跨代指针写屏障的开销对 Go 的典型工作负载来说不划算。这种情况可能正在改变:最近的 Go 版本中已经开发了实验性的分代支持。
Compaction (整理) vs No compaction 是一个真正的设计分歧点。 Java 收集器进行整理,这允许 bump-pointer 分配(非常快)并消除碎片。Go 不整理,这意味着它永远不需要更新指向已移动对象的指针(更简单的写屏障,无需读屏障以保证正确性)。Go 通过大小类分配器(size-class allocator)来补偿。这是经典的 Wilson 权衡:拷贝和整理收集器以内存开销和指针更新成本换取分配速度和碎片消除。
ZGC 的有色指针是 Wilson 指针标记 (pointer-tagging) 思想的现代实现。 Wilson 提到过在指针中使用位来存储 GC 元数据。ZGC 将此进一步发展,将标记状态、重映射状态和终结状态直接嵌入 64 位指针。在每次指针加载时检查这些位的加载屏障是 ZGC 为亚毫秒级停顿支付的代价。
基本问题从未改变。 从 roots 开始追踪,标记存活内容,回收其余部分。自 1960 年以来的所有发展都是对 McCarthy 原始洞察的工程改进。
参考资料
- McCarthy, J. (1960). Recursive functions of symbolic expressions and their computation by machine, Part I
- Wilson, P. R. (1992). Uniprocessor Garbage Collection Techniques. IWMM ‘92
- A Guide to the Go Garbage Collector
- Getting to Go: The Journey of Go’s Garbage Collector
- Proposal: Eliminate STW stack re-scanning – Austin Clements (2016)
- Java Garbage Collection: The G1 Garbage Collector
- ZGC: The Z Garbage Collector – JEP 333
- Generational ZGC – JEP 439
- PEP 442: Safe object finalization
你的“停顿”时刻
GC 的艺术在于平衡。在你的开发生涯中,是否遇到过因为 GC 停顿导致的生产事故?你是倾向于 Go 的极致低延迟,还是 Java G1GC 的高吞吐?
欢迎在评论区分享你的调优经历或吐槽!
还在为“复制粘贴喂AI”而烦恼?我的新专栏 《AI原生开发工作流实战》 将带你:
- 告别低效,重塑开发范式
- 驾驭AI Agent(Claude Code),实现工作流自动化
- 从“AI使用者”进化为规范驱动开发的“工作流指挥家”
扫描下方二维码,开启你的AI原生开发之旅。

你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?
- 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
- 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
- 想打造生产级的Go服务,却在工程化实践中屡屡受挫?
继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!
我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。
目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!

原「Gopher部落」已重装升级为「Go & AI 精进营」知识星球,快来加入星球,开启你的技术跃迁之旅吧!
我们致力于打造一个高品质的 Go 语言深度学习 与 AI 应用探索 平台。在这里,你将获得:
- 体系化 Go 核心进阶内容: 深入「Go原理课」、「Go进阶课」、「Go避坑课」等独家深度专栏,夯实你的 Go 内功。
- 前沿 Go+AI 实战赋能: 紧跟时代步伐,学习「Go+AI应用实战」、「Agent开发实战课」、「Agentic软件工程课」、「Claude Code开发工作流实战课」、「OpenClaw实战分享」等,掌握 AI 时代新技能。
- 星主 Tony Bai 亲自答疑: 遇到难题?星主第一时间为你深度解析,扫清学习障碍。
- 高活跃 Gopher 交流圈: 与众多优秀 Gopher 分享心得、讨论技术,碰撞思想火花。
- 独家资源与内容首发: 技术文章、课程更新、精选资源,第一时间触达。
衷心希望「Go & AI 精进营」能成为你学习、进步、交流的港湾。让我们在此相聚,享受技术精进的快乐!欢迎你的加入!

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

© 2026, bigwhite. 版权所有.
2026 编程语言“饱和度”榜单出炉:JavaScript/Python 已“烂大街”,Go/Rust 成最大赢家?

本文永久链接 – https://tonybai.com/2026/04/02/2026-programming-language-saturation-rankings-go-rust-winners
大家好,我是Tony Bai。
在这个技术浪潮汹涌、AI 随时可能掀翻牌桌的时代,每一个程序员心中都悬着一个终极问题:
“我现在的技术栈,还能吃几年饭?”
我们每天都在焦虑地刷着各种技术文章,试图从 Google、Anthropic、OpenAI、Nvidia等的风向中,窥探下一个技术红利期。但这些信息往往零散、矛盾,甚至充满了各种培训机构的“幸存者偏差”。
就在半个多月前,X 平台上的一位技术博主 Mojisola Alegbe,基于 Stack Overflow、GitHub Trends、JetBrains 等多方数据,整理并发布了一份极其残酷的私房版《2026 编程语言“饱和度”榜单》。
这篇推文就像一颗深水炸弹,在短短几天内获得了 41.2 万的惊人阅读量。大批开发者涌入评论区,有人哀嚎,有人庆幸,有人愤怒,有人不屑。这张榜单之所以能引爆全网,因为它赤裸裸地揭示了我们这个行业最真实的“供需关系”和“内卷现状”。
今天,我们就来深度扒开这张榜单背后的血泪与真相。看看你我手中的“锤子”,到底还能敲几年钉子。

榜单冲击:你的技术栈,在鄙视链的哪一层?
让我们先深吸一口气,看看这份令人心跳加速的榜单:
- JavaScript (66%): 极度饱和 (Extremely Saturated)
- Python (58%): 非常饱和 (Very Saturated)
- SQL (49%): 非常饱和 (Very Saturated)
- TypeScript (35-40%): 高度饱和,且仍在快速增长
- Java (26%): 成熟/稳定饱和
- C# (18%): 中度饱和
- PHP (10-11%): 正在衰退,但仍很普遍
- C++ (6-7%): 小众,但用于关键系统
- Go (4-5%): 低饱和,需求增长中
- Kotlin (4-5%): 中度小众 (安卓)
- Swift (2%): 小型但专业的生态系统
- Rust (2-3%): 低饱和,但正在崛起
看完这张图,我猜很多人的第一反应是:
- 前端/Python 工程师:完了,彻底“烂大街”了,明天就去送外卖。
- Java 工程师:稳如老狗,任你风吹雨打,我自岿然不动。
- Go/Rust 工程师:心中窃喜,果然选对了赛道,未来可期!
- PHP 工程师:……(我 PHP 是最好的语言!)
但如果事情真的这么简单,那我们这个行业也未免太无趣了。这张榜单真正有价值的地方,在于它炸出了评论区里无数资深架构师和一线开发者的“人间清醒”。
社区百态:饱和、内卷与“幸存者偏差”
在这张榜单的评论区,你可以看到整个技术圈最真实的生态缩影。
阵营一:饱和焦虑派
“完了,我刚想学编程,这可怎么办?”
“怪不得现在工作这么难找……”
阵营二:不屑一顾派
“语言只是工具,解决问题才是关键。”
“这种指标毫无意义。”
阵营三:人间清醒派(重点看这里!)
这部分评论,往往来自那些穿越了数个技术周期的老炮。他们的观点,破具含金量。
一位开发者一针见血地指出:
“语言的饱和度是个误导性指标。真正的问题不是有多少开发者懂它,而是有多少开发者能用它构建出真正有价值的系统。”
另一位开发者则更加直接:
“饱和度百分比毫无意义。重要的是:你能交付吗(Can you ship)?我只看三个信号:1. 真实的生产环境部署(而不是教程);2. 系统设计的深度(而不只是 CRUD);3. 在压力下调试复杂问题的能力。JavaScript 饱和度 66%?那又怎样,其中 90% 的人连一个可扩展的架构都设计不出来。”
而一位博主,更是给出了顶级玩家的“搞钱思路”:
“聪明的开发者从不追逐‘流行’的语言,他们追逐的是‘高价值’的行业
– Python → AI
– C++ → 高性能系统(游戏、金融)
– Rust → 安全基础设施(区块链、操作系统)
– Go → 云平台(K8s、Docker)
追逐金钱,而不是追逐炒作(Follow the money, not the hype)。”
架构师的破局之道:从“横向内卷”到“纵向深耕”
扒开社区的口水战,我们可以总结出三条极其宝贵的“反内卷”生存法则。
第一条:停止在“语言层”的低水平竞争
如果你是一个 Python 开发者,你的核心竞争力绝对不是“比别人多会几个 itertools 的函数”。
评论区里的一条建议非常中肯:
“不要只学 Python 的语法。去学它底层的 C++ 和 CUDA。这才是 2026 年 AI 热潮中真正值钱的地方。”
同样的道理,如果你是一个前端开发者,让你在面试中脱颖而出的,绝不是多会几个 CSS 动画技巧,而是你对 V8 引擎的内存管理、对大规模前端项目的架构设计、对 WebAssembly 的底层原理的深刻理解。
饱和的永远是“表层应用”,而“底层原理”的护城河,深不见底。
第二条:将你的技术栈,锚定在高价值的“产业赛道”
你选择的语言,决定了你的“工具”;而你选择的行业,决定了你“工具”的价值。
如果你用 Go,但每天只是在写一些简单的 CRUD 业务,那你和用 PHP 的同行并没有本质区别。
但如果你用 Go,去深耕 Kubernetes Operator 开发、去搞 Service Mesh、去做 eBPF 的底层监控,那你将进入一个截然不同的“高价值稀缺区”。
对于大多数开发者来说,最好的策略不是去学一门全新的、不饱和的语言(比如 Zig 或 OCaml),而是在你现有的、最熟悉的语言生态里,找到那个与“高利润、高壁垒”行业结合最紧密的纵深方向,然后一头扎进去。
第三条:从“语言专家”进化为“系统架构师”
评论区里,有一个非常有趣的现象:初级开发者在讨论“哪个语言好”,而资深开发者在讨论“如何交付(Ship)”。
当一个系统变得复杂时,瓶颈往往早已不在于某个语言的语法特性,而在于:
- 你如何设计一套可观测的日志与监控体系?
- 你如何在不同的微服务之间做好 API 的版本管理与兼容性?
- 你如何设计数据库的 Schema,才能在未来两年内扛住 10 倍的流量增长?
这些“跨语言”的系统设计能力,才是拉开普通程序员和架构师之间收入差距的根本原因。
语言的红利期是短暂的,而架构的复利是终身的。
小结:你的价值,由你定义
这张“饱和度”榜单,与其说是一份“死亡通知单”,不如说是一张“体检报告”。它提醒我们,如果你安于现状,只停留在语言的表层舒适区,那么无论你现在用的是 Go 还是 Python,你都随时可能被更便宜、更年轻的开发者所取代。别忘了还有不断“蚕食”初级甚至中高级程序员工作的AI!
在这个充满不确定性的时代,真正的安全感,来源于:
- 向下扎根,掌握技术栈的底层原理。
- 向高处走,将你的能力锚定在高价值的产业。
- 向外看,建立跨越语言鸿沟的系统架构思维。
不要再为“哪个语言是宇宙第一”而进行无意义的口水战了。
你的价值,从来不是由你用什么语言决定的,而是由你能用这门语言,解决多大、多复杂、多有价值的问题决定的。
资料链接:https://x.com/yehhmisi/status/2031715243622015239
今日互动探讨:
看完这份榜单,你对自己目前的技术栈感到了焦虑,还是庆幸?在你看来,一个语言的“饱和”是危机,还是意味着更成熟的生态和机会?
欢迎在评论区分享你的看法!
还在为“复制粘贴喂AI”而烦恼?我的新专栏 《AI原生开发工作流实战》 将带你:
- 告别低效,重塑开发范式
- 驾驭AI Agent(Claude Code),实现工作流自动化
- 从“AI使用者”进化为规范驱动开发的“工作流指挥家”
扫描下方二维码,开启你的AI原生开发之旅。

你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?
- 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
- 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
- 想打造生产级的Go服务,却在工程化实践中屡屡受挫?
继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!
我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。
目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

© 2026, bigwhite. 版权所有.
硬核测评:哪门语言最受 AI 宠爱?13 种语言横向对比,Go 表现如何?

本文永久链接 – https://tonybai.com/2026/03/09/hardcore-review-13-languages-ai-favorite-go-performance
大家好,我是Tony Bai。
随着 Claude Code、Gemini Cli、Codex 等 AI 编程工具的全面普及,“让 AI 写代码”已经从极客的玩具变成了日常的生产力。随之而来的是一个触及灵魂的问题:哪种编程语言最适合交给 AI 去写?
作为 Gopher,我们一直为 Go 语言的“极简语法”、“极速编译”和“强类型安全”感到自豪。我们理所当然地认为,这种没有任何隐式魔法、像白开水一样的语言,绝对是 LLM 的最爱。
然而,现实总是比直觉更骨感。近日,Ruby 核心提交者 Yusuke Endoh(@mame)发布了一份名为 ai-coding-lang-bench 的硬核定量测评报告。他使用 Claude Code(Opus 4.6 模型)对 13 种主流编程语言进行了系统性横向对比。
在这场涵盖了动态语言、静态语言和函数式语言的混战中,Go 语言的表现究竟如何? 是力压群雄,还是黯然失色?那些备受人类推崇的静态类型系统,在 AI 面前是否成了累赘?
本文和大家一起阅读和拆解这份报告,为你揭晓 AI 时代的语言偏好图谱。

实验设计:让 AI 写一个 Mini-Git
在评价这份报告之前,我们先来看看它的实验设计,这是目前业内少见的、针对 AI Agent 的工程化能力的量化评估。
任务目标:让 Claude Code (Opus 4.6) 从零开始实现一个 mini-git(简化版的 Git)。这是一个极具代表性的任务,它包含了文件 I/O、哈希计算、数据结构操作以及命令行接口,足以考验模型对语言生态的综合运用能力。
测试被巧妙地分为两个阶段,模拟了真实的软件生命周期:
- v1 (新项目构建):实现基础的 init, add, commit 和 log。
- v2 (特性扩展):在 v1 的基础上,增加 status, diff, checkout, reset, rm, show 等复杂指令。
提示词(Prompt)极其极简:“阅读 SPEC-v1.txt,实现它,并确保 test-v1.sh 测试通过。”这种设计最大程度地减少了人类指令的干预,纯粹考验 AI 代理在闭环环境下的自主编码、调试和测试能力。
参赛选手(13种语言/15种配置):
- 动态语言:Python, Ruby, JavaScript, Perl, Lua
- 动态+类型检查器:Python/mypy, Ruby/Steep
- 静态语言:TypeScript, Go, Rust, C, Java
- 函数式语言:Scheme (动态), OCaml (静态), Haskell (静态)
每种语言配置运行 20 次,以消除 LLM 生成的随机性带来的误差,并统计其耗时(Time)、成本(Cost,即 Token 消耗)和代码行数(LOC)。
核心发现:动态语言逆袭,Go 位居第二梯队
如果仅看总耗时和总成本(v1 + v2),测试结果呈现出了令人瞩目的阶梯式分布。

第一梯队:Ruby, Python, JavaScript 的绝对统治
在这场 AI 编程竞速中,Ruby(73.1s)、Python(74.6s)和 JavaScript(81.1s)组成了无可争议的第一阵营。
它们不仅生成速度最快、消耗 API 成本最低(均在 $0.40 以下),而且在 20 次测试中表现出了极高的稳定性(标准差极小)。
对于 AI 来说,生成这三种语言的代码就像呼吸一样自然。它们无需繁琐的项目初始化配置(如 Cargo.toml 或 package.json),可以做到“建个文件直接跑”,这种极简的工作流在 v1 阶段(新项目构建)优势极大。
第二梯队:被“强类型”拖慢脚步的 Go 与 Java
现在,来回答大家最关心的问题:Go 表现如何?
答案是:位居第二梯队。Go 的总耗时为 101.6s,平均成本 $0.50。中规中矩。Go 虽然在语法上非常克制,但依然落后于 Python 和 JS 等动态语言。与之类似,Java(115.4s)也因为繁琐的语法结构和强类型约束,留在了这一梯队。
尽管如此,Go 在整个 20 次测试中没有出现一次失败(0 次 fail),这证明了 Go 的编译器在防止 AI 产生“幻觉 Bug”方面,发挥了极其可靠的安全网作用。
“后进生”阵营:Rust 与 C 的挣扎
备受人类极客推崇的 Rust(113.7s,且有 2 次失败)和底层的 C(155.8s)在测试中显得步履维艰。
尤为值得注意的是,在总共 600 次的独立运行中,只有 Rust (2次) 和 Haskell (1次) 出现了测试失败(未能最终跑通 Shell 脚本)的情况。这两门语言都以其极高的心智负担和“编译器教你做人”的严格程度而闻名。
这也是将Rust列入“后进生”阵营的主要原因。如果用《飞驰人生》的拉力赛来比喻,Rust 相当于在40站的赛季中,有两站没能完赛!
深度剖析:为什么 AI 更偏爱动态语言?
在传统的工程视角中,“静态类型防止低级 Bug”、“动态语言难以维护”是金科玉律。但在 LLM 驱动的 Agent 开发流中,这个逻辑为何失效了?作者 Yusuke Endoh 提出了几个关键的解释维度。
训练数据的“虹吸效应”
LLM 的能力直接取决于训练语料的规模和质量。Python、JavaScript 和 Ruby 是过去十几年 Web 开发的绝对主流。GitHub 上海量的这三种语言的开源代码、StackOverflow 上的问答,为 Claude Code 提供了极其丰富的“预训练肌肉记忆”。
当 AI 需要实现一个功能时,它在 Python 的隐空间(Latent Space)中寻找最优解的路径,远比在 Haskell 甚至 Rust 中要清晰、笔直得多。
静态类型的“双刃剑”与重构阻力
静态类型系统的初衷是约束人类,防止我们在重构时犯错。但在 AI 的“ Prompt -> 生成 -> 测试报错 -> 思考 -> 再生成”的迭代循环中,严格的类型检查反而成了巨大的“摩擦力”。
- 编译成本与调试死锁:在 Rust 或 C 中,当 AI 生成的代码出现类型不匹配时,它需要花费大量的 Token 去阅读复杂的编译器报错信息。有时,为了解决一个简单的借用检查器(Borrow Checker)报错,AI 可能会陷入漫长的、无休止的“试错-编译失败”死循环。
- 重构牵一发而动全身:在 v2 特性扩展阶段,往往需要修改现有的数据结构。对于 Python,AI 只需要在字典里加个 key;而对于 Rust 或 Java,这可能意味着需要重构一系列的 Struct、更新类型签名、甚至修改与之相关的无数个函数的参数声明。这种“爆炸半径”极大地增加了耗时。
“附加类型检查”的巨大损耗
报告中一个非常有意思的对照组是:原生动态语言 vs 附加类型检查器的动态语言。
- Python (74.6s) vs Python/mypy (125.3s) —— 变慢了 1.6~1.7 倍。
- Ruby (73.1s) vs Ruby/Steep (186.6s) —— 变慢了 2.0~3.2 倍!
这证明了,迫使 AI 在动态语言中编写严谨的类型注解(Type Annotations),是一项极其昂贵的任务。模型需要耗费额外的算力去推导类型、生成类型声明文件,并且在类型检查器报错时,还要去修复那些在纯动态模式下可能根本不影响运行的“伪 Bug”。
代码量(LOC)的迷思:越短越好吗?
我们通常认为,写得越少,跑得越快。但数据打破了这个迷思。
Haskell 和 OCaml 生成的最终代码行数(224行和 216行)是所有语言中最少的,甚至少于 Python 和 Ruby。然而,它们在生成时间上的表现却排在倒数(Haskell 耗时最长,达 174s)。
这表明,对于 AI 来说,函数式语言那种高度抽象、信息密度极大的代码,生成和推理的成本远高于像 Python、Go 那种稍微啰嗦但逻辑平铺直叙的“大白话”代码。浓缩的未必是精华,对于 LLM 来说,高度浓缩往往意味着更高的生成熵和更高的试错概率。
行业启示:我们需要重新思考 AI 时代的技术栈选型
面对这份详实的基准测试报告,无论你是 CTO 还是普通开发者,都必须开始重新审视未来的技术选型逻辑。
动态语言是快速原型的“绝对王者”
如果你正在启动一个新项目,或者需要用 AI Agent 快速验证一个业务流程,Python 和 TypeScript 是首选(报告中 JavaScript 表现优于 TS,但在实际工程中 TS 的综合权衡更佳)。
不要迷信“大型项目必须一开始就上强类型编译语言”。在需求快速变化的初期,让 AI 用动态语言狂飙突进,是获取业务反馈最高效的手段。
性能王者们的困境:Go 与 Rust 在 AI 时代掉队了吗?
看到测评数据,很多 Gopher 可能会感到失落:难道注重工程严谨性和系统级性能的静态语言,真的在 AI 时代掉队了吗?
结论并非如此悲观。我们需要明确一点:Agent 测评的速度,不等于软件最终运行的速度。
- 业务试错 vs 基础设施:AI Agent 目前最擅长、也最快速能完成的,是写“胶水逻辑”和“业务 CRUD”。在这些领域,Python 确实快。但当你的系统涉及到高并发、内存精细控制、或者需要打包为轻量级容器部署时,人类依然需要 Go。
- 容错的底线:在这场 600 次的庞大测试中,只有 Rust 和 Haskell 出现了最终测试失败,而 Go 保持了完美的 100% 成功率。这恰恰说明,Go 在“极度灵活(易幻觉)”与“极度严格(难生成)”之间,找到了一个非常微妙的平衡点。它可能不是 AI 写得最快的,但它一定是 AI 写出来最让人放心的系统级语言。
我们不应期待 AI Agent 能够像写 Python 脚本一样,如德芙般丝滑地生成出一个复杂的 Go 并发系统。但在 AI 给出的初稿之上,Go 语言极佳的可读性和统一的规范,将为人类工程师的最终审查(Code Review)节省巨大的精力。
小结:下一个十年的编程语言,长什么样?
ai-coding-lang-bench 给我们上了生动的一课。它揭示了当前 LLM 的偏好:它们喜欢有海量训练数据的、灵活的、不需要应对死板编译器的语言。
但我们必须认识到,这只是一份基于 2026 年初模型(Claude Opus 4.6)的快照。未来的 AI 编程语言形态,可能会朝着两个方向演进:
- AI Native 语言的诞生:抛弃目前设计给人类阅读的语法,出现一种专门为了降低 LLM 生成 Token 成本、且天然抗幻觉的机器中间语言。
- 现有静态语言的“Agent 友好化”编译模式:Go 和 Rust 可能会进化出一种特殊的编译模式。在这个模式下,编译器不仅是冷冰冰地报错,还能以结构化的、对 LLM 更友好的方式提供“修复建议”,从而大幅缩短 Agent 修复编译错误的反馈回路。
无论如何,浪潮已经来临。在 AI 主导代码生成的新时代,我们评价一门编程语言的标准,正在从“它对人类大脑是否友好”,悄然转变为“它对大模型推理是否友好”。
而在这场新赛道上,动态语言们,已经抢跑了。
本文核心数据与图表均来源于 GitHub 项目 mame/ai-coding-lang-bench。
你的 AI 编程初体验
看完这个排名,你是感到意外,还是早已感同身受?在你日常使用 AI 编程时,你觉得它写哪种语言最让你省心?你是否也曾为了修一个 AI 写的编译错误而陷入“死循环”?
欢迎在评论区分享你的“AI 协作”红黑榜!
“语言的严格性正在变成 AI 的摩擦力?在 AI 时代,掌握一套能驱动 Agent 自动化、自修复的‘工作流’比死磕语法更重要。我的新专栏 《AI原生开发工作流实战》 将教你如何利用 Claude Code 结合 Spec 驱动开发,构建真正高产出的‘软件工厂’。”
- 告别低效,重塑开发范式
- 驾驭AI Agent(Claude Code),实现工作流自动化
- 从“AI使用者”进化为规范驱动开发的“工作流指挥家”
扫描下方二维码,开启你的AI原生开发之旅。

你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?
- 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
- 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
- 想打造生产级的Go服务,却在工程化实践中屡屡受挫?
继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!
我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。
目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!

原「Gopher部落」已重装升级为「Go & AI 精进营」知识星球,快来加入星球,开启你的技术跃迁之旅吧!
我们致力于打造一个高品质的 Go 语言深度学习 与 AI 应用探索 平台。在这里,你将获得:
- 体系化 Go 核心进阶内容: 深入「Go原理课」、「Go进阶课」、「Go避坑课」等独家深度专栏,夯实你的 Go 内功。
- 前沿 Go+AI 实战赋能: 紧跟时代步伐,学习「Go+AI应用实战」、「Agent开发实战课」、「Agentic软件工程课」、「Claude Code开发工作流实战课」、「OpenClaw实战分享」等,掌握 AI 时代新技能。
- 星主 Tony Bai 亲自答疑: 遇到难题?星主第一时间为你深度解析,扫清学习障碍。
- 高活跃 Gopher 交流圈: 与众多优秀 Gopher 分享心得、讨论技术,碰撞思想火花。
- 独家资源与内容首发: 技术文章、课程更新、精选资源,第一时间触达。
衷心希望「Go & AI 精进营」能成为你学习、进步、交流的港湾。让我们在此相聚,享受技术精进的快乐!欢迎你的加入!

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

© 2026, bigwhite. 版权所有.
“棘手”难题:为什么 Go、Rust 与 Java 等语言的包管理永远无法达到完美?

本文永久链接 – https://tonybai.com/2026/03/04/package-management-unsolvable-problem-programming-languages
大家好,我是Tony Bai。
每天,全世界的开发者敲击下数以亿计的 npm install、go get、cargo build 或是 pip install。我们将这些包管理器视作理所当然的基础设施,仿佛它们就像水龙头一样,拧开就有源源不断的开源代码流出。然而,在这些看似简单的命令行背后,隐藏着计算机科学中最复杂、最容易引发争议,且永远无法找到“完美答案”的深水区。
近期,一篇名为《Package Management is a Wicked Problem》(包管理是一个“棘手”问题)的文章在技术社区引发了广泛关注,其姊妹篇《Package Management Namespaces》更是深度拆解了包命名的底层逻辑。作者以其多年参与包管理器数据和工具开发的经验,向我们揭示了一个残酷的真相:包管理不仅仅是一个纯粹的计算机科学问题,它是一个融合了社会工程学、经济学、安全性和向后兼容性的无底洞。 任何在这个层面上的微小改进,都会引发波及全球数千万个项目、数亿个版本的海啸。
本文将结合这两篇深度文章的核心观点,带你全景式地审视现代主流编程语言(如 Go、Rust、Python、JavaScript、Java)在包管理与“命名空间”上的激烈博弈与艰难演进。

包管理为何是一个“棘手问题”(Wicked Problem)?
为了准确描述包管理的困境,原作者借用了 1973 年社会规划学者 Horst Rittel 和 Melvin Webber 提出的“棘手问题”(Wicked Problem)这一经典概念。
在城市规划或公共政策领域,“棘手问题”通常指的是那些没有明确边界、没有唯一正确答案、且试图解决它的行为本身就会改变问题定义的问题。作者指出,在涉及万亿次下载和全球协作的今天,包管理完美地契合了 Rittel 和 Webber 提出的“棘手问题”的多个核心特征:
问题的表述与解决方案是同一件事
当我们谈论“包管理”时,我们到底在谈论什么?
作者敏锐地指出,这个词本身就是模棱两可的。对于前端开发者,它可能意味着用 npm 管理构建时的依赖树;对于系统管理员,它可能意味着用 apt 或 Homebrew 在操作系统上安装已编译好的二进制工具。
即使在同一个生态系统中,命名也充满了争议:我们称其为 package(包)、module(模块)、crate(板条箱)还是 distribution(发行版)?这些并非简单的同义词替换,它们各自编码了对“什么东西被版本化”、“什么东西被发布”以及“什么东西被安装”的深刻假设。正如作者所说:“包管理一路向下都关乎命名,而命名众所周知是计算机科学中的两大难题之一。”
当你决定引入 Lockfile(锁文件)时,你并不是在解决一个大家事先都同意的问题,你实际上是在重新定义“安装依赖”这个行为本身。这正是“棘手问题”的典型特征:解决方案定义了问题。
根本没有绝对的“对与错”,只有“好与坏”
在包管理的世界里,几乎没有任何技术决策可以被客观地评判为“真”或“假”。
作者以 Homebrew 为例:早期 Homebrew 选择直接使用 Git 作为其软件包数据库。这在当时是一个绝妙的设计,降低了门槛,极大促进了早期的繁荣。但随着规模的爆炸,Git 仓库的拉取成了巨大的性能瓶颈。那么,当初选择 Git 是错的吗?这取决于你是看重早期的简单性,还是看重长期的扩展性。
作者还深入剖析了语义化版本控制(SemVer)的困境。SemVer 试图将版本更新变成一种“非黑即白”的契约:引入破坏性变更(Breaking Change)就必须升级主版本号。但在实际操作中,这完全沦为了主观判断。
这里作者引入了著名的海勒姆定律(Hyrum’s Law):“当一个 API 拥有足够多的用户时,你在契约中承诺什么已经不重要了,你系统的所有可观测行为都将被某些用户所依赖。”
这意味着,对于某个开发者来说仅仅是修复了一个底层的 Bug,但对于恰好依赖这个 Bug 特性运行的另一个用户来说,这就是一次彻头彻尾的破坏性变更。版本号的跳动永远无法客观地评估对所有人的影响,它只能是“对特定人群好”或“对特定人群坏”。
不可逆的深远后果与试错的代价
在科学研究中,你可以提出假设并在实验室中进行 A/B 测试。但在包管理器设计中,你没有这种奢侈。作者强调:“每一个实施的解决方案都会留下无法消除的痕迹。”
当年 Python 的包索引(PyPI)决定接受无命名空间的扁平包名时,拼写抢注(Typosquatting)攻击就成为了这个生态不可避免的宿命。即便 PyPI 明天决定强制引入层级命名空间,它也无法改变全球数以千万计的存量 requirements.txt 文件,更不能直接使那些旧代码失效。
同样,RubyGems 至今仍托管着自 2007 年以来就未曾更新的古老包。在这个领域,没有推倒重来的机会(No do-overs)。
当年 npm 社区发生的 left-pad 事件(作者因为不满而撤下了一个仅有 11 行代码的基础库,导致全球无数基于 Babel、React 的项目构建失败),就是一个惨痛的教训。当你允许“取消发布”时,你不仅是在做一个功能,你是在制定一项将永久塑造开发者行为的政策。
利益相关者的根本冲突与多重因果
包管理到底应该优化什么?作者为我们罗列了一系列相互冲突的诉求:
- 注册中心运营者想要极简的存储和极致的稳定性。
- 安全研究员想要可审计性和不可变性。
- 库作者想要发布时的灵活性。
- 企业应用开发者想要绝对的构建可重复性。
这些目标是内在矛盾的。一个允许库作者轻松推送更新的系统,必然也是一个更容易受到供应链攻击的系统;一个能够捕获每一层深层依赖的 Lockfile,必然也是一个在执行安全升级时更痛苦的组件。
npm、Yarn 和 pnpm 能够在前端生态中三足鼎立,正是因为它们对这些冲突的诉求做出了不同的妥协。Yarn 的诞生是因为 Facebook 迫切需要 npm 早期未能提供的绝对可重复性;而 pnpm 的崛起则是因为开发者对磁盘空间和安装速度的渴望压倒了对传统 node_modules 结构的兼容性需求。
命名空间之战——安全与便利的生死博弈
在理解了包管理的“棘手”本质后,原作者将目光投向了包管理的核心战场:“命名机制”。你如何为一个包赋予一个全球唯一的标识符?这不仅决定了开发者的使用体验,更直接决定了整个生态的安全性架构。
作者在其姊妹篇《Package Management Namespaces》中,详细梳理了主流语言生态演化出的四种截然不同的命名范式。
扁平命名空间(Flat Namespaces):“先到先得”的蛮荒时代
代表生态: RubyGems, PyPI (Python), crates.io (Rust)
这是历史最悠久、设计最直观的模式:一个巨大的、全局共享的名称池。规则很简单:先到先得。如果你抢到了 requests,那就是你的。
- 开发者的蜜月期:在生态初期,这种模式极度舒适。名称简短、好记,在命令行里敲下 gem install rails 或 cargo add serde 时,体验极其顺滑。
- 作者指出的致命缺陷:命名稀缺与安全梦魇。
随着生态规模的爆炸式增长(如 PyPI 目前已有超过 60 万个项目),好名字很快被耗尽。许多简短的、有意义的词汇被一些只有个位数下载量的废弃项目永久“占坑”。新开发者被迫使用 python-dateutil 或 beautifulsoup4 这样带有笨拙前缀或数字后缀的名称。
更严重的是,这种模式为拼写错误抢注(Typosquatting)提供了完美的温床。攻击者只需注册 reqeusts(对应合法的 requests)然后守株待兔。因为在用户的键盘敲击和注册表查找之间没有任何组织层级的校验,也没有层级结构需要导航,这种基于简单字符串匹配的攻击防不胜防。
作用域命名空间(Scoped Namespaces):组织的介入与权力的转移
代表生态: npm (JavaScript), Packagist (PHP)
为了解决扁平命名的稀缺和冲突,npm 在 2014 年引入了作用域(Scopes)。你可以发布 @babel/core 而不是去争抢早已被占用的 babel-core。PHP 的 Packagist 更是从一开始就强制要求使用 vendor/package 的格式(如 symfony/console)。
- 空间的释放:这极大地缓解了命名冲突。不同的组织可以安全地使用相同的叶子节点名称,例如 @types/node 和 @anthropic/node 可以和平共处,互不干扰。
- 作者提示的挑战:治理成本的飙升与“上移的占坑”。
作用域引入了复杂的治理问题。谁有权决定 @babel 属于 Babel 团队?这就需要平台提供账号管理、所有权转移机制甚至处理商标纠纷的流程。
此外,作者犀利地指出,在 Packagist 这种强制模式中,虽然包名(package)不冲突了,但“供应商(Vendor)”名称本身依然是先到先得的。如果有人提前在 Packagist 上抢注了 google 这个供应商名称,那么 Google 官方的所有包都会被拦截在生态之外。这等于是把“占坑”的问题向上推了一个维度,其潜在的破坏力实际上更大。
层级命名空间(Hierarchical Namespaces):绑定全球 DNS 体系
代表生态: Maven Central (Java, Clojure)
Java 生态极其聪明地将包命名的治理权“外包”给了全球最大的、已经建立共识的分布式治理系统——DNS(域名系统)。你必须拥有 example.com 的域名所有权,才能发布前缀为 com.example 的包。
- 秩序的建立:这几乎彻底消除了无意义的恶意占坑。像 Apache、Google 这样的庞大组织拥有了极其清晰、权威的代码家园。
- 作者揭示的致命隐患:MavenGate 与域名复活攻击。
这种看似无懈可击的设计,依然存在致命的盲区。域名的所有权并不是永恒的,公司会倒闭,域名会过期。作者引用了安全公司 Oversecured 在 2024 年初发布的 “MavenGate” 报告:在与 Maven 关联的 3 万多个域名中,有近 18%(约 6170 个)域名已经过期或重新流入市场挂牌出售!
这其中甚至包含了被广泛使用的 co.fs2、com.opencsv 等知名库的根域名。这意味着,恶意攻击者只需花费极低的成本(几十美元)买下这些过期的域名,就能顺利通过 Maven Central 的 DNS TXT 记录验证,以合法原作者的身份接管整个命名空间,并发布带有后门的恶意新版本。由于大多数自动化构建工具倾向于拉取最新版本,这种基于“域名复活”的供应链攻击将具有毁灭性的穿透力和隐蔽性。
基于 URL 的标识符:去中心化的乌托邦与残酷现实
代表生态: Go, SwiftPM
Go 模块(Go Modules)做出了一个在当时看来非常激进的选择:直接使用代码托管地址(如 github.com/gorilla/mux)作为包名标识符,彻底取消中心化的“注册(Registry)”步骤。
- 优雅的直达:这实现了零注册摩擦。URL 在结构上天然保证了全球唯一性,且通过对 Git 仓库的所有权,自然而然地确立了对代码包的所有权。
- 作者分析的隐藏代价:被基础设施绑架与脆弱的信任链。
这种模式将包的命运与底层的托管平台(特别是 GitHub)进行了深度且危险的绑定。如果一个 GitHub 组织改名了,或者一个生气的开发者删除了他的仓库,所有依赖这些路径的下游系统都会瞬间崩溃。
为了弥补这个“去中心化”带来的巨大可用性缺陷,Go 团队不得不花费数年时间,在核心机制之外构建了极其庞大的辅助基础设施:
- Module Proxy(模块代理):用于持久化缓存源码。这样即使 GitHub 上的原仓库被彻底删除,只要代理中有缓存,全球的 Go 构建就不会中断。
- Checksum Database (SumDB):这是一个基于透明日志(Transparency Log)的校验和数据库。它提供了一个不可篡改的全局信任锚点,保证了任何人、在任何时间、从任何代理拉取同一个版本的 Go 模块,得到的哈希值必须绝对一致。它防止了作者恶意 force-push 篡改代码,甚至连运营该数据库的 Google 自己也无法在不被察觉的情况下篡改历史记录。
作者通过对比指出,苹果生态的 SwiftPM 起初也采用了类似的 Git URL 模式,但并未配套建立 Proxy 和校验数据库。这导致如果 GitHub 仓库消失,Swift 的构建就会直接面临失败。更糟糕的是,2022 年的安全研究发现,大量 Go 和 Swift 包容易受到“仓库劫持”(Repo-jacking)攻击(即攻击者重新注册已注销的 GitHub 用户名,并重建同名的旧仓库)。Go 因为有强悍的 Proxy 和 SumDB 作为护城河,成功抵御了此类攻击;而 SwiftPM 至今仍暴露在巨大的软件供应链风险之中。

深重历史包袱下的“痛苦迁徙”
我们现在已经通过学术分析和前车之鉴,知道了理想的包管理应该是什么样。但原作者指出了一个无情的现实:大部分语言的包管理器早在十多年前就已经定型,它们带着最初的缺陷狂奔至今,积累了如同天文数字般的历史包袱。 如今想要修复这些缺陷,无异于给一架正在高速飞行的跨洋客机在空中更换引擎。
作者以 Rust (Cargo/crates.io) 的演进为例,生动地展示了这种深度重构的痛苦与艰难。
Rust 社区作为一个极其注重工程严谨性的生态,早在 2014 年起就在讨论引入命名空间。由于一开始选择了扁平命名,优质的单词已被大量占用。直到 2025 年,Rust 社区才终于正式推进了由 Manish Goregaokar 起草的 RFC 3243(可选命名空间) 提案。
他们的过渡方案设计得极其精妙且克制:不引入新的顶级前缀,而是将现有的合法包名升级为潜在的命名空间根节点。
这意味着,如果你当前拥有 serde 这个基础包的所有权,你就可以顺理成章地发布 serde::derive(使用双冒号 :: 是为了与 Rust 原生的模块语法保持高度一致)。这种设计完美地做到了向后兼容:现有的扁平命名继续有效,新的层级命名以一种非常“Rust”的方式平滑引入。
但这依然无法避免阵痛。
作者举例说,像 tokio-macros 这样已经广泛存在于扁平空间中的包,如果未来想将其规范化迁移到 tokio::macros,所有依赖它的下游用户的代码都需要跟着进行繁琐的改写。而对于那些名字被别人占用的项目(比如知名的异步运行时 async-std 团队,其实并不拥有 async 这个基础包名的所有权),这个优雅的方案对他们来说依然是无解的。
Rust 社区作为一个资源充足、治理严密的顶级生态,依然需要花费数年的时间、跨越编译器、Cargo 工具和注册中心三大团队来协调设计和实施这个补救方案。
这充分印证了作者的观点:如果在发布 Registry 的第一天,你没有保留哪怕一丁点命名空间的扩展性(比如预留一个特殊的分隔符),那么一旦生态成型,后续的重构成本将是难以估量的。 同样,Python 的 PyPI 目前也在通过 PEP 752 提案如履薄冰般地尝试让大厂保留包名前缀(如 google-cloud-),但这只对未来的新包有效,对于存量系统依然是一笔难以理清的糊涂账。
小结——放弃“完美”,拥抱“演进”

纵观这两篇深度探讨,无论是 npm 为了处理历史包袱而维护的并行命名系统,还是 Go 利用强大的 SumDB 来硬核弥补 URL 导入天然缺陷的工程奇迹,亦或是 Rust 正在小心翼翼进行的痛苦命名空间迁移,所有的现象都在向我们诉说同一个真理:
包管理(Package Management)作为一个“棘手问题”,永远不会被真正“解决”。
我们无法像推导一个数学定理那样,给出一个让所有人都满意的、完美的包管理公式。我们所能做的,是在不断变化的安全性要求、开发者的灵活性需求、系统的可用性以及沉重的历史包袱之间,寻找属于这个时代的最优解(Trade-offs)。
对于语言和工具的设计者而言,在系统上线的第一天保持足够的克制和选项预留价值千金;而对于广大的应用开发者而言,正如作者所呼吁的,我们需要深刻理解这些构建工具背后的“棘手”本质。
当我们面对依赖冲突或奇怪的版本解析时,少一些诸如“为什么这个工具这么蠢”的情绪化抱怨,多一些对供应链安全的审慎态度(如定期审查依赖树、使用内部可信代理、开启严格的校验和哈希验证),才是面对现代软件工程深水区时,我们应有的专业素养与敬畏之心。
下一次,当你敲下那行习以为常的 go get、npm install 或 cargo build 时,不妨停下来思考一秒钟:为了将这几 KB 的代码安全、无误地送到你的硬盘里,背后那套由无数妥协与智慧构筑的庞大机器,是如何在无声中疯狂运转的。
资料链接:
- https://nesbitt.io/2026/01/23/package-management-is-a-wicked-problem.html
- https://nesbitt.io/2026/02/14/package-management-namespaces.html
你最想吐槽哪家的包管理?
每一个“依赖地狱”的背后,都有一位在深夜叹气的程序员。在你的开发经历中,哪门语言的包管理最让你感到顺手?哪门又最让你抓狂?你认为 Go 的“URL 导入+校验和数据库”模式是目前的终极答案吗?
欢迎在评论区分享你的“包管理血泪史”!
还在为“复制粘贴喂AI”而烦恼?我的新专栏 《AI原生开发工作流实战》 将带你:
- 告别低效,重塑开发范式
- 驾驭AI Agent(Claude Code),实现工作流自动化
- 从“AI使用者”进化为规范驱动开发的“工作流指挥家”
扫描下方二维码,开启你的AI原生开发之旅。

你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?
- 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
- 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
- 想打造生产级的Go服务,却在工程化实践中屡屡受挫?
继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!
我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。
目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!

想系统学习Go,构建扎实的知识体系?
我的新书《Go语言第一课》是你的首选。源自2.4万人好评的极客时间专栏,内容全面升级,同步至Go 1.24。首发期有专属五折优惠,不到40元即可入手,扫码即可拥有这本300页的Go语言入门宝典,即刻开启你的Go语言高效学习之旅!

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

© 2026, bigwhite. 版权所有.
Java、JVM、并发进阶
20 年 Java 老店的“背叛”:WSO2 为何高呼“Goodbye Java, Hello Go”?

本文永久链接 – https://tonybai.com/2026/01/29/wso2-goodbye-java-hello-go-tech-stack-shift
大家好,我是Tony Bai。
“当我们 2005 年创办 WSO2 时,开发服务端企业级基础设施的正确语言毫无疑问是:Java。然而,当我们走过第 20 个年头并展望未来时,情况已经变了。”
近日,全球知名的开源中间件厂商 WSO2 发布了一篇震动技术圈的博文——《Goodbye Java, Hello Go!》。
这是企业级软件在云原生时代技术风向标的一次重要偏转。作为 Java 时代的既得利益者,WSO2 曾在 API 管理、集成中间件领域构建了庞大的 Java 帝国。为何在今天,他们会做出如此激进的转向?Java 真的不适合未来了吗?Go 到底赢在哪里?
让我们深入剖析这背后的技术逻辑、架构变迁与社区的激烈争议。

时代的变迁——从“服务器”到“函数”
WSO2 的转向并非一时冲动,而是基于对过去 15 年基础设施软件形态深刻变化的洞察。其博文中极其精准地总结了这一变迁:
“服务器”概念的消亡
在 2010 年代之前,中间件是以独立“服务器”(Server)的形式交付的。
- 应用服务器 (App Servers):如 WebLogic, WebSphere, Tomcat。
- 企业服务总线 (ESB):集成了各种协议适配器的庞然大物。
- 业务流程服务器 (Process Servers):管理长周期的业务状态。
那是一个“重量级”的时代。你部署一个服务器,然后把你的业务逻辑(WAR 包、JAR 包)扔进去运行。这正是 Java 和 JVM 的黄金时代——JVM 作为一个强大的运行时环境,提供了热加载、动态管理、JIT 优化等一系列高级功能,完美匹配了这种“长时间运行、多应用共享”的服务器模式。
然而,容器化时代终结了这一切。
现在的“服务器”不再是一个独立的实体,而变成了一个库 (Library)。
- 你的业务逻辑不再是“寄生”在服务器里,而是包含了服务器。
- 整个应用打包成一个 Docker 镜像,作为一个独立的进程运行。
- 任务完成后,容器销毁,进程结束。
在 WSO2 看来,“独立软件服务器的时代已经结束了”。这对于 Java 来说,是一个底层逻辑的打击。
生命周期:从“月”到“毫秒”
在过去,一个服务器启动慢点没关系,因为它一旦启动,可能会运行数月甚至数年。JVM 的 JIT(即时编译)机制通过预热来换取长期运行的高性能,这是一种非常合理的权衡。
但在 Kubernetes 和 Serverless 主导的今天,服务器变得极度短暂 (Ephemeral)。
- 容器根据负载自动扩缩容,新实例必须瞬间就绪。
- Serverless 函数可能只存活几秒钟。
在这种场景下,启动时间就是服务质量 (SLA)。
WSO2 指出:“容器应该在毫秒级内准备好起舞,而不是秒级。” Java 庞大的生态依赖(Spring 初始化、类加载、注解扫描)和 JVM 的启动开销,在云原生环境下显得格格不入。内存膨胀(Memory Bloat)也直接推高了云厂商的账单。
生态位的错位:修补 vs. 原生
面对挑战,Java 社区并非无动于衷。GraalVM Native Image 试图通过 AOT(提前编译)解决启动速度问题;Project Loom 试图通过虚拟线程解决并发资源消耗问题。
但在 WSO2 的架构师们看来,这些努力更像是一种“追赶式的修补”。
“这些解决方案感觉就像是在为一个不同时代设计的语言和运行时进行翻新。”
GraalVM 虽然强大,但带来了构建时间的剧增、反射的限制以及调试的复杂性。相比之下,Go 语言在设计之初就原生 (Native) 地考虑了这些问题:编译即二进制,启动即巅峰,并发即协程。这是一种“原生契合”与“后天适配”的本质区别。
WSO2 的架构重构——前端不动,后端大换血
WSO2 并没有盲目地全盘推翻,他们对企业级软件的三层架构(前端、中间层、后端)进行了冷静的评估:
前端 (Frontend):维持现状
- 现状:Web (JS/TS), iOS (Swift/Flutter), Android (Kotlin/Java)。
- 未来:No Change。
- 理由:前端技术栈受限于终端设备(浏览器、手机 OS),且更新换代极快(“fad-driven”,时尚驱动)。目前没有改变的必要。
中间层 (Middle Tier):Ballerina 的独角戏
- 现状:Java, Ballerina。
- 未来:Ballerina。
- 核心逻辑:这一层通常被称为 BFF (Backend for Frontend),负责 API 聚合、编排。WSO2 自研的 Ballerina 语言正是为此而生,它将网络原语(Network Primitives)作为语言的一等公民,极其适合做集成工作。
后端 (Backend):Go 与 Python 的双雄会
- 现状:Java, Go, NodeJS, Python。
- 未来:Go, Python。
- 核心逻辑:这是基础设施逻辑的核心。Python 将继续统治 AI/ML 领域,而 Go 将彻底接管原本属于 Java 的领地,成为构建高性能、高并发基础设施的首选。
为什么是 Go,而不是 Rust?
这是一个每个技术决策者都会面临的灵魂拷问:既然要追求性能和原生编译,为什么不选 Rust?它不是更快、更安全吗?
WSO2 的回答展现了极高的工程务实精神。他们确实评估了 Rust,但最终选择了 Go。理由如下:
抽象层级的匹配
- Rust 的战场:操作系统内核、浏览器引擎、嵌入式设备。这些场景需要对内存布局、生命周期做极致的微操,且进程几乎永不重启。
- Go 的战场:中间件、API 网关、编排系统。
WSO2 构建的是中间件基础设施(如 API Gateway, Identity Server)。在这个层级,“我们总是比裸金属 (Bare Metal) 高那么一点点”。Go 提供的自动垃圾回收 (GC) 和高效的并发原语,恰好处于这个“甜点”位置。
避免“过度杀伤” (Overkill)
Rust 的所有权模型 (Ownership) 和借用检查器 (Borrow Checker) 虽然保证了内存安全,但也带来了极高的学习曲线和开发摩擦。对于大多数企业级业务逻辑来说,Rust 提供的控制力是多余的,而为此付出的开发效率代价是昂贵的。
云原生生态的引力
这是一个无法忽视的因素。Go 是云原生的“普通话”。
Kubernetes、Docker、Prometheus、etcd、Terraform…… 几乎所有现代基础设施的基石都是用 Go 构建的。选择 Go,意味着:
- 库的复用:可以直接调用 K8s 的库,而不是通过 API。
- 人才的复用:DevOps 工程师和 SRE 通常都懂 Go,可以无缝参与开发。
- 社区的共鸣:更容易融入 CNCF 生态,获得社区贡献。
实战验证——WSO2 的 Go 之旅
WSO2 并非纸上谈兵,他们在过去十年中已经在多个关键项目中验证了 Go 的能力:
OpenChoreo (CNCF Sandbox Project)
这是 WSO2 最具野心的项目之一,一个面向 Kubernetes 的开发者平台(IDP)。
- 挑战:需要深度集成 K8s,处理复杂的 GitOps 流程,且自身必须轻量、快速。
- Go 的价值:作为 K8s 原生语言,Go 让 OpenChoreo 能够像原生组件一样运行在集群中,资源占用极低。
Ballerina 编译器的彻底重写
这是一个惊人的决定。Ballerina 语言最初是基于 Java 实现的(运行在 JVM 上)。现在,WSO2 正在用 Go 完全重写 Ballerina 编译器。
- 目标:摆脱 JVM 的束缚,实现瞬间启动。
- 新架构:前端编译器用 Go 编写,直接生成基于 Go 的中间表示 (BIR),这让 CLI 工具的体验得到了质的飞跃。
Thunder:下一代身份认证平台
身份认证(IAM)通常处于请求链路的关键路径上,对延迟极其敏感。Thunder 利用 Go 的高并发处理能力,实现了在高负载下的低延迟认证,且在容器化环境中具备极快的冷启动能力。
社区激辩——理性的探讨与情绪的宣泄
这篇博文在 Reddit 的 r/golang 板块引发了数百条评论的激烈讨论。这不仅仅是语言之争,更是两种工程文化的碰撞。
反方阵营:Java 依然是王者
-
“这是管理层的愚蠢决定”:
一位愤怒的网友评论道:“计算资源是廉价的,开发人员的时间才是昂贵的。” 他认为,虽然 Go 节省了内存,但在业务逻辑极其复杂的企业级应用中,Java 强大的 IDE 支持、成熟的设计模式和庞大的生态库能显著降低开发成本。强行切换到 Go,可能会导致开发效率的崩塌。 -
“Java 并没有停滞不前”:
很多 Java 支持者指出,WSO2 对 Java 的印象似乎还停留在 Java 8 时代。现代 Java (21+) 引入了 Virtual Threads (Project Loom),在并发模型上已经可以与 Go 的 Goroutine 媲美;而 GraalVM 的成熟也让 Java 能够编译成原生镜像,启动速度不再是短板。 -
“生态位的不可替代性”:
在处理遗留系统(如 SOAP, XML, 复杂的事务处理)方面,Java 积累了 20 年的库是 Go 无法比拟的。用 Go 去重写这些复杂的业务逻辑,无异于“重新发明轮子”,且容易引入新的 Bug。
正方阵营:Go 是未来的选择
-
“运维友好才是真的友好”:
一位 DevOps 工程师反驳道:“在微服务架构下,运维成本是巨大的。” Go 生成的静态二进制文件(Static Binary)是运维的梦想——没有依赖地狱,没有 JVM 版本冲突,所有东西都打包在一个几 MB 的文件里。这种部署的便捷性,是 Java 永远无法达到的。 -
“简洁是一种防御机制”:
Java 项目容易陷入“过度设计”的泥潭——层层叠叠的抽象、复杂的继承关系、魔法般的注解。Go 的强制简洁性(没有继承、显式错误处理)虽然写起来啰嗦,但读起来轻松。在人员流动频繁的大型团队中,Go 代码的可维护性往往优于 Java。 -
“云原生的网络效应”:
正如 WSO2 所言,如果你在写 K8s Controller,如果你在写 Sidecar,如果你在写网关,Go 就是默认语言。这不仅仅是语言特性的问题,这是生态引力的问题。逆流而上使用 Java 编写这些组件,会让你失去整个社区的支持。
小结:没有终极语言,只有最适合的工具
WSO2 的声明并非要“杀死” Java。他们明确表示,现有的 Java 产品线将继续得到长期支持。但在新一代的云原生基础设施平台上,他们坚定地选择了 Go。
这一选择揭示了软件行业的一个趋势:通用编程语言的时代似乎正在结束,“领域专用语言”的时代正在到来。
- 做前端?选 TS/JS。
- 做 AI 模型训练?选 Python。
- 做操作系统、浏览器或者嵌入式系统?选 C/Rust/C++。
- 做企业级业务逻辑(尤其是遗留系统)?Java 依然稳健。
- 做云原生基础设施、中间件、高并发服务?Go 是当之无愧的王者。
对于 Gopher 而言,WSO2 的转型是一个强有力的信号:你们选对了赛道。Go 不仅是 Google 的语言,它正在成为定义未来十年企业级基础设施的通用语。
资料链接:
- https://wso2.com/library/blogs/goodbye-java-hello-go
- https://www.reddit.com/r/golang/comments/1qomr6g/goodbye_java_hello_go/
你的技术栈“保卫战”
WSO2 的转身,是时代的缩影,也是个体的写照。在你的团队中,是否也发生过类似的“去 Java 化”或“拥抱 Go”的讨论?你认为在云原生时代,Java 还能守住它的江山吗?
欢迎在评论区分享你的观点或经历,无论是坚守者还是转型者,我们都想听听你的声音!
如果这篇文章引发了你的思考,别忘了点个【赞】和【在看】,并转发给你的架构师朋友,看看他们怎么选!
还在为“复制粘贴喂AI”而烦恼?我的新专栏 《AI原生开发工作流实战》 将带你:
- 告别低效,重塑开发范式
- 驾驭AI Agent(Claude Code),实现工作流自动化
- 从“AI使用者”进化为规范驱动开发的“工作流指挥家”
扫描下方二维码,开启你的AI原生开发之旅。

你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?
- 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
- 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
- 想打造生产级的Go服务,却在工程化实践中屡屡受挫?
继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!
我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。
目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

© 2026, bigwhite. 版权所有.
学习体验下Koupleless框架-浅析代码
上次简单体验了下koupleless框架,可以发现ark模块的加载运行是非常有优势的,
这次就来看下这个模块式怎么加载的
我们可以通过启动基座使用调试模式,然后打下断点在ArkClient这个类的几个核心方法
比如com.alipay.sofa
重新复习理解下java的类加载器
之前比较粗浅的写过一点类加载器的相关知识,最近因为在看相关的内容,所以打算来复习也简单分享下
首先是我们常规用户自定义类的类加载器,
Java进阶知识
Java 25:告别臃肿代码的时代开启

解决访问 https 网站时,后端重定向或获取 URL 变成 http 的问题
一种常见的服务部署架构是 Nginx 反向代理后端 Java 应用服务器,Nginx 监听 443 端口处理 https 请求,然后转发给后端服务器。

对应的 Nginx 配置大致如下:
upstream www {
server 192.168.1.101:8080 weight=100 max_fails=3 fail_timeout=10s;
server 192.168.1.102:8080 weight=100 max_fails=3 fail_timeout=10s;
}
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://www;
}
}
即:客户端与 Nginx 之间是 https,Nginx 与后端 Java 应用服务器之间是 http。
这样可能会遇到一些问题,如:
HttpServletRequest.getRequestURL()获取到的 URL 是 Nginx 与后端服务器之间的 http URL,比如http://192.168.1.101:8080/xxx;HttpServletResponse.sendRedirect()生成的重定向 URL 也是 http URL。
要解决这些问题,可以通过 Nginx 配置 + 少量后端代码修改来实现。
解决应用中获取到的 URL 的问题
用户实际访问的是 https://example.com/xxx,但是后端应用获取到的 URL 是 http://192.168.1.101:8080/xxx,如何让后端应用获取到正确的 URL 呢?
第一步,Nginx 可以通过 proxy_set_header Host 指令将客户端请求的 Host 头传递给后端服务器:
location / {
# ...
proxy_set_header Host $host;
}
这样,后端应用通过 HttpServletRequest.getRequestURL() 获取到的 URL 就是 http://example.com/xxx 了。
但此时,协议仍然不对,还是 http。
要给后端应用传递正确的协议,通常的做法是使用 X-Forwarded-Proto 头:
location / {
# ...
proxy_set_header X-Forwarded-Proto $scheme;
}
添加这个头之后并不会让 HttpServletRequest.getRequestURL() 直接返回 https URL,需要在后端应用中做一些处理。以 Java 应用为例,可以通过一个过滤器(Filter)来修改 request 的 scheme:
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class XForwardedProtoFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (request instanceof HttpServletRequest) {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String xForwardedProto = httpRequest.getHeader("X-Forwarded-Proto");
if (StringUtils.isNotBlank(xForwardedProto) && !xForwardedProto.equalsIgnoreCase(httpRequest.getScheme()) && xForwardedProto.equalsIgnoreCase("https")) {
httpRequest = new HttpServletRequestWrapper(httpRequest) {
@Override
public String getScheme() {
return xForwardedProto;
}
@Override
public StringBuffer getRequestURL() {
StringBuffer requestURL = super.getRequestURL();
if (requestURL != null && requestURL.length() > 0) {
int index = requestURL.indexOf("://");
if (index > 0) {
requestURL.replace(0, index, xForwardedProto);
}
}
return requestURL;
}
};
}
chain.doFilter(httpRequest, response);
} else {
chain.doFilter(request, response);
}
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void destroy() {
}
}
至此,后端应用通过 HttpServletRequest.getRequestURL() 获取到的 URL 就是 https://example.com/xxx 了。
解决重定向 URL 的问题
后端应用通过 HttpServletResponse.sendRedirect() 生成的重定向 URL 也是 http URL,如何让它变成 https 呢?
这个问题可以通过 Nginx 的另一指令 proxy_redirect 来解决,该指令用于修改从后端服务器返回的 Location 和 Refresh 响应头。
location / {
# ...
proxy_redirect http:// $scheme://;
}
这样,当后端应用返回一个重定向响应时,Nginx 会将 Location 头中的 http:// 替换为 $scheme://,即 https://。
进一步思考:当 Nginx 前面还有负载均衡器时
在很多情况下,Nginx 前面可能还有商用负载均衡器(如 AWS ELB、阿里云 SLB 等),这时需要考虑负载均衡器与 Nginx 之间的协议问题。
如果负载均衡器与 Nginx 之间是 http,而 Nginx 与后端应用之间是 http,那么就需要在负载均衡器和 Nginx 之间添加 X-Forwarded-Proto 头,以便 Nginx 能够正确地识别原始请求的协议。
主流的负载均衡器配置项里应该都有添加 X-Forwarded-Proto 头的选项开关,比如阿里云:

需要注意的是这样配置后,Nginx 配置也需要做相应的调整,将 $scheme 替换为 $http_x_forwarded_proto:
(此种场景 $scheme 为负载均衡器与 Nginx 之间的协议 http,$http_x_forwarded_proto 为负载均衡器通过 Header 透传过来的前端访问协议 https。)
location / {
# ...
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_redirect http:// $http_x_forwarded_proto://;
}
参考链接
Java 的26年演变历程

Android Perfetto 系列 8:深入理解 Vsync 机制与性能分析
本篇是 Perfetto 系列文章的第八篇,主要深入介绍 Android 中的 Vsync 机制及其在 Perfetto 中的表现形式。文章将从 Perfetto 的角度来分析 Android 系统如何基于 Vsync 信号进行帧渲染和合成,涵盖 Vsync、Vsync-app、Vsync-sf、VsyncWorkDuration 等核心概念。
随着高刷新率屏幕的普及,理解 Vsync 机制变得更加重要。本文将以 120Hz 刷新率为主要叙事线,帮助开发者理解现代 Android 设备中 Vsync 的工作原理,以及如何在 Perfetto 中观察和分析 Vsync 相关的性能问题。
注:本文内容基于 Android 13~16 的公开实现与演进;文中代码以 AOSP main 的“签名对齐精简摘录”为主,少量位置使用
...省略非主线逻辑,请以当前分支源码为准。
本文目录
- 系列文章目录
- 什么是 Vsync
- Android 中 Vsync 的基本工作原理
- 在 Perfetto 中观察 Vsync
- Android App 每一帧是如何基于 Vsync 工作的
- 参考文档
- 关于我 && 博客
系列文章目录
- Android Perfetto 系列目录
- Android Perfetto 系列 1:Perfetto 工具简介
- Android Perfetto 系列 2:Perfetto Trace 抓取
- Android Perfetto 系列 3:熟悉 Perfetto View
- Android Perfetto 系列 4:使用命令行在本地打开超大 Trace
- Android Perfetto 系列 5:Android App 基于 Choreographer 的渲染流程
- Android Perfetto 系列 6:为什么是 120Hz?高刷新率的优势与挑战
- Android Perfetto 系列 7 - MainThread 和 RenderThread 解读
- Android Perfetto 系列 8:深入理解 Vsync 机制与性能分析
- Android Perfetto 系列 9 - CPU 信息解读
- Android Perfetto 系列 10 - Binder 调度与锁竞争
- 视频(B站) - Android Perfetto 基础和案例分享
- 视频(B站) - Android Perfetto 分享 - 出图类型分享:AOSP、WebView、Flutter + OEM 系统优化分享
如果大家还没看过 Systrace 系列,下面是传送门:
- Systrace 系列目录 : 系统介绍了 Perfetto 的前身 Systrace 的使用,并通过 Systrace 来学习和了解 Android 性能优化和 Android 系统运行的基本规则。
- 个人博客 :个人博客,主要是 Android 相关的内容,也放了一些生活和工作相关的内容。
欢迎大家在 关于我 页面加入微信群或者星球,讨论你的问题、你最想看到的关于 Perfetto 的部分,以及跟各位群友讨论所有 Android 开发相关的内容
什么是 Vsync
Vsync(Vertical Synchronization,垂直同步)是 Android 图形系统的核心机制,它的存在是为了解决一个根本性的问题:如何让软件的渲染节奏与硬件的显示节奏保持同步。
在没有 Vsync 机制之前,常见问题是屏幕撕裂(Screen Tearing)。当显示器读取 framebuffer 的同时,GPU 写入了下一帧,就会在同一次刷新中出现上下两部分不一致的画面。
Vsync 解决什么问题?
Vsync 机制的核心思想非常简单:让所有的渲染工作都按照显示器的刷新节拍来进行。具体来说:
- 同步信号:显示器每次开始新的刷新周期时,都会发出一个 Vsync 信号。
- 帧节拍与生产:应用侧在 Vsync 到来时由 Choreographer 驱动开始一帧的生产(Input/Animation/Traversal);CPU 提交渲染命令后,GPU 异步流水执行。SurfaceFlinger 侧在 Vsync 到来时进行 Buffer 的合成操作。
- 缓冲机制:使用双缓冲或三缓冲技术,确保显示器总是读取完整的帧数据。
这样,帧的生产与显示以 Vsync 为节拍对齐。以 120Hz 为例,每 8.333ms 会有一个显示机会;应用需要在该窗口前把可合成的 Buffer 提交给 SurfaceFlinger。关键约束是 queueBuffer/acquire_fence/present_fence 的时序;若未赶上本周期,会顺延到下一个周期显示。
Android 中 Vsync 的基本工作原理
Android 系统的 Vsync 实现比基本概念复杂得多,需要考虑多个不同的渲染组件,以及它们之间的协调工作。
Vsync 信号的分层架构
在 Android 系统中,并不是只有一个简单的 Vsync 信号。实际上,系统维护着多个不同用途的 Vsync 信号:
硬件 Vsync(HW Vsync):
这是最底层的 Vsync 信号,由显示硬件(HWC,Hardware Composer)产生。它的频率严格对应显示器的刷新率,比如 60Hz 的显示器会每 16.67ms 产生一次 HW Vsync,120Hz 的显示器会每 8.333ms 产生一次。(硬件 Vsync 回调由 HWC/SurfaceFlinger 管理,详见 frameworks/native/services/surfaceflinger 相关实现)
但是,HW Vsync 并不是一直开启的。由于频繁的硬件中断会消耗较多的电量,Android 系统采用了一种智能的策略:只有在需要精确同步的时候才开启 HW Vsync,大部分时间使用软件预测的方式生成 Vsync 信号。
Vsync-app(应用 Vsync):
这是专门用于驱动应用层渲染的 Vsync 信号。当应用需要进行 UI 更新时(比如用户触摸、动画运行、界面滚动等),应用会向系统申请接收 Vsync-app 信号。
1 | // frameworks/base/core/java/android/view/Choreographer.java |
Vsync-app 是按需申请的。如果应用界面是静态的,没有任何动画或用户交互,那么应用不会申请 Vsync-app 信号,系统也就不会为这个应用生成 Vsync 事件。
Vsync-sf(SurfaceFlinger Vsync):
这是专门用于驱动 SurfaceFlinger 进行图层合成的 Vsync 信号。SurfaceFlinger 是 Android 系统中负责将所有应用的图层合成为最终画面的服务。
Vsync-appSf(应用-SurfaceFlinger Vsync):
Android 13 引入的新信号类型。为消除旧设计中 sf EventThread 既唤醒 SurfaceFlinger 又服务部分 Choreographer 客户端带来的时序歧义,系统将两类职责分离:vsync-sf 专注唤醒 SurfaceFlinger,vsync-appSf 面向需要与 SurfaceFlinger 同步的客户端。
在 Perfetto 中观察 Vsync
Perfetto trace 中包含多个与 Vsync 相关的 Track,理解这些 Track 的含义有助于分析性能问题。
在 SurfaceFlinger 进程中:
vsync-app
显示应用 Vsync 信号状态,数值在 0 和 1 之间变化。每次数值变化代表一个 Vsync 信号。
**vsync-sf **
显示 SurfaceFlinger Vsync 信号状态。无 Vsync Offset 时与vsync-app同步变化。
vsync-appSf
Android 13+ 新增,服务于需要与 SurfaceFlinger 同步的特殊 Choreographer 客户端。
HW_VSYNC
显示硬件 Vsync 开启状态。值为 1 表示开启,值为 0 表示关闭。为节省电量,硬件 Vsync 仅在需要精确同步时开启。
在应用进程中:
FrameDisplayEventReceiver.onVsync Slice Track:
显示应用接收 Vsync 信号的时间点。该事件连接通过 Binder 建链、通过 BitTube/Looper 通道分发事件,时间可能略晚于 SurfaceFlinger 中的 vsync-app。

UI Thread Slice Track:
包含 Choreographer#doFrame 及相关的 Input、Animation、Traversal 等 Slice。每个 doFrame 对应一帧的处理工作。

RenderThread Slice Track:
包含 DrawFrame、syncAndDrawFrame、queueBuffer 等 Slice,对应渲染线程工作。

Android App 每一帧是如何基于 Vsync 工作的
Android 应用的每一帧基于 Vsync 机制完成从渲染到显示的完整过程涉及多个关键步骤。

流程总览(按顺序)
- 触发重绘/输入:
View.invalidate()、动画、数据变化或输入事件触发 →ViewRootImpl.scheduleTraversals()→Choreographer.postCallback(TRAVERSAL) - 申请 Vsync:
Choreographer通过DisplayEventReceiver.scheduleVsync()申请下一次 Vsync(app 相位) - 接收 Vsync:
DisplayEventReceiver.onVsync()收到 Vsync 后,向主线程消息队列投递异步消息 - 主线程帧处理:
Choreographer.doFrame()按顺序执行五类回调:INPUT → ANIMATION → INSETS_ANIMATION → TRAVERSAL → COMMIT - 渲染提交:
RenderThread执行syncAndDrawFrame/DrawFrame,CPU 记录 GPU 命令,queueBuffer提交到 BufferQueue - 合成显示:
SurfaceFlinger在vsync-sf到来时合成(GPU/或HWC),生成present_fence,输出到显示 - 帧完成度量:通过
FrameTimeline(PresentType/JankType)与acquire/present_fence判定是否按期显示
下面分别展开每一步的关键实现与 Perfetto 观测点。
App 什么时候会申请 Vsync 信号
应用并不是时刻都在申请 Vsync 信号的。Vsync 信号是按需申请的,只有在以下情况下,应用才会向系统申请下一个 Vsync:
触发申请 Vsync 的场景:
- UI 更新需求:当 View 调用
invalidate()时 - 动画执行:ValueAnimator、ObjectAnimator 等动画开始时
- 用户交互:触摸事件、按键事件等需要 UI 响应时
- 数据变化:RecyclerView 数据更新、TextView 文本改变等
App 申请 Vsync 的完整流程
当应用需要更新 UI 时,会通过以下流程申请 Vsync 信号:
1 | // 1. UI 组件请求重绘 |
TRAVERSAL仍然是最常见触发源,但从 AOSP main 实现看,并非“只有 TRAVERSAL 才申请 Vsync”。
主线程如何监听 Vsync 信号
应用主线程通过 DisplayEventReceiver 来监听 Vsync 信号。这个过程涉及几个关键步骤:
1. 建立连接:
1 | // frameworks/base/core/java/android/view/Choreographer.java |
2. 接收 Vsync 信号:
1 | @Override |
几个遗留问题
Q1:为什么不在 onVsync() 中直接执行 doFrame()?
- 线程边界:在
Choreographer场景下,onVsync()回调运行在其绑定的 Looper(通常就是主线程);通过消息队列再进入doFrame(),可统一调度并保持帧处理时序一致 - 调度控制:通过
sendMessageAtTime()精确对齐执行时刻 - 队列语义:进入主线程 MessageQueue,确保与其他高优先级任务协同
Q2:Vsync 消息来了但主线程在忙,会丢吗?
- 不完全是“不会丢”。单次
scheduleVsync()只请求一次事件;主线程长期繁忙时会出现“跳过多个硬件节拍、最终只处理较新的一帧”的现象。实际分析应结合 FrameTimeline 判断是否产生可见卡顿。 - AOSP
DisplayEventDispatcher::processPendingEvents明确会用“后到达的 vsync 覆盖先到达的 vsync”(只保留最近一次用于分发)。
Q3:CPU/GPU 是否必须在单个 Vsync 周期内完成?如果任何一个环节超过 1 个 vsync ,都会导致掉帧?
现代 Android 系统采用多缓冲(通常是三缓冲)机制:
应用端:Front Buffer(显示中)+ Back Buffer(渲染中)+ 可能的第三个 Buffer
SurfaceFlinger 端:也有类似的缓冲机制
这意味着即使应用的某一帧超过了 Vsync 周期,也不一定会立即掉帧。
GPU 异步流水;关键是
queueBuffer是否赶上 SF 合成窗口,多缓冲可掩盖单帧延迟但可能引入额外时延,可以看到下图里面,App 端的 BufferQueue 和 SurfaceFlinger 端的 Buffer 都是充足的,且有冗余,所以没有掉帧。但是如果 App 在之前没有堆积 Buffer ,则还是会出现掉帧。

Q5:GPU 和 CPU 是怎么协同的?:
GPU 渲染是异步的,这带来了额外的复杂性:
- CPU 工作正常,GPU 成为瓶颈:即使应用主线程在 Vsync 周期内完成工作,GPU 渲染耗时过长仍会导致掉帧
- GPU Fence 机制:在 Buffer 被 SF latch 的阶段,关键同步点通常是
acquire fence(Buffer 何时可安全读取);present fence更偏向“该帧何时真正送显”的完成信号。根据系统Latch Unsignaled Buffers策略,SurfaceFlinger 在特定条件下可先推进流程,再在真正需要时等待 fence 信号,以此隐藏部分延迟。

Q6:Vsync Phase(相位差)的真正作用是什么?:
- 提升跟手性:通过调整 sf vsync 的相位差,可以让应用从开始绘制到显示在屏幕上的时间从 3 个 Vsync 周期缩短到 2 个 Vsync 周期。这对于触摸响应等交互场景非常重要。
- 解决应用绘制超时问题:当应用绘制超时时,合理的 sf 相位差可以为应用争取更多的处理时间,避免因为时序不当导致的掉帧。
VsyncWorkDuration更接近调度预算(workDuration/readyDuration)的可视化,不等价于单一 appOffset 数值;分析时建议结合vsync-app/sf与 FrameTimeline 联动判断。- 下图中显示的时间段就是我手上的手机配置的 app offset (13.3ms)

Vsync Offset / WorkDuration 的技术实现
在当前 AOSP main 中,配置入口是 VsyncConfiguration 抽象接口,返回的是按场景组织的 VsyncConfigSet。实现上 PhaseOffsets 属于旧路径,WorkDuration 是新路径中更常见的实现之一:
1 | // frameworks/native/services/surfaceflinger/Scheduler/VsyncConfiguration.h |
关键概念:
workDuration/readyDuration:调度时的“工作预算”和“就绪提前量”,用于计算回调唤醒时刻app/sf offset:仍可作为常用分析口径,但本质是配置集合与调度模型共同作用的结果- 常用口径里“app/sf offset 差值”指两者相位差(通常看
|sfOffset - appOffset|的绝对值,具体符号以设备实现与统计口径为准)
实际的优化效果
以 120Hz 设备为例,配置 3ms Offset 的效果:
无 Offset(传统方式):
- T0:应用和 SurfaceFlinger 同时接收 Vsync
- T0+3ms:应用完成渲染
- T0+8.333ms:下一个 Vsync,SurfaceFlinger 开始合成
- T0+16.666ms:用户看到画面(总延迟 16.666ms)
有 Offset(优化方式):
- T0+1ms:应用接收 Vsync-app,开始渲染
- T0+3ms:应用完成渲染,提交 Buffer
- T0+4ms:SurfaceFlinger 接收 Vsync-sf,立即开始合成
- T0+6ms:SurfaceFlinger 完成合成
- T0+8.333ms:用户看到画面(总延迟 8.333ms)
通过合理配置 Offset,可以将延迟从 16.666ms 减少到 8.333ms,提升一倍的响应性能。
实际的时间预算分配:
以 120Hz 设备为例(8.333ms 周期):
- 理想情况:应用 4ms + SurfaceFlinger 2ms + 缓冲 2.333ms
- 但实际可以接受:应用 6ms + SurfaceFlinger 3ms(如果有足够的 Buffer 缓冲)
- GPU 限制:在低端设备上,GPU 渲染可能需要 10-15ms,成为真正的瓶颈
掉帧的真正原因:
- 应用端超时 + Buffer 耗尽:连续多帧超时导致 BufferQueue 没有可用 Buffer
- GPU 渲染超时:即使 CPU 工作正常,GPU 渲染超时也会掉帧
- SurfaceFlinger 超时:系统级合成超时,影响所有应用
- 系统资源竞争:CPU/GPU/内存等资源被其他进程占用
Vsync 信号的完整代码流程
Vsync 信号从硬件传递到应用层的完整链路如下。
按 AOSP main 分支对齐的关键代码(精简摘录)
下面片段都按当前 AOSP main 分支的方法签名整理,省略了与主线无关的分支与日志代码。
1)Choreographer 申请下一次 Vsync(Java)
1 | // frameworks/base/core/java/android/view/Choreographer.java |
2)Choreographer 接收 Vsync(Java)
1 | // frameworks/base/core/java/android/view/Choreographer.java |
3)JNI 层桥接:DisplayEventDispatcher(C++)
1 | // frameworks/base/core/jni/android_view_DisplayEventReceiver.cpp |
4)Native 收发通道:DisplayEventReceiver + BitTube(C++)
1 | // frameworks/native/libs/gui/DisplayEventReceiver.cpp |
5)SurfaceFlinger 调度与分发(C++)
1 | // frameworks/native/services/surfaceflinger/Scheduler/VSyncDispatch.h |
1 | // frameworks/native/services/surfaceflinger/Scheduler/EventThread.cpp |
关键时序点分析
通过上述代码流程,我们可以看到完整的时序链路:
- HWC 产生硬件 Vsync → SurfaceFlinger Scheduler 获取硬件节拍
- Scheduler 计算唤醒窗口 →
VSyncDispatch::schedule(...) - EventThread 生成/派发事件 → 写入
DisplayEventReceiver::Event(通过BitTube) - App 侧 Native 收到事件 →
DisplayEventDispatcher::dispatchVsync(...) - Java
FrameDisplayEventReceiver回调 → 异步消息切到 Looper 队列 Choreographer#doFrame(...)执行 → Input/Animation/Traversal/Commit
各环节的职责和优化点不同,理解完整流程有助于在 Perfetto 中分析 Vsync 相关性能问题。
FrameTimeline
App 和 SurfaceFlinger 都有 FrameTimeline

- 轨道:
Expected Timeline、Actual Timeline - PresentType/JankType:
- PresentType 指示本帧呈现方式(例如 On-time、Late),JankType 指示卡顿类型来源
- 常见 JankType:
AppDeadlineMissed、BufferStuffing、SfCpuDeadlineMissed、SfGpuDeadlineMissed等
- 操作步骤(Perfetto UI):
- 在应用进程选择目标
Surface/Layer或使用 FrameToken 过滤 - 对齐 Expected 与 Actual,查看偏移与颜色编码
- 向上钻取:
Choreographer#doFrame、RenderThread、queueBuffer、acquire/present_fence
- 在应用进程选择目标
- 误判规避:
- 仅凭
doFrame时长判断掉帧不可靠;以 FrameTimeline 的 PresentType/JankType 为准 - 多缓冲可能掩盖单帧超时,需要看连续帧与 Buffer 可用性
- 仅凭
刷新率/显示模式/VRR 对 Vsync 与 Offset/预测的影响
- 模式切换:刷新率变更会重新配置
VsyncConfiguration,影响 app/sf Offset 与预测模型;- Perfetto:查
display mode change事件与随后的vsync间隔变化
- Perfetto:查
- VRR(可变刷新率):目标周期不恒定,软件预测更依赖 present_fence 反馈校准;
- Perfetto:观察
vsync间隔分布与present_fence偏差
- Perfetto:观察
- 多显示/外接显示:硬件层可按
physicalDisplayId上报 vsync;但应用侧 Choreographer 通常仍以内屏/pacesetter 时序为主(实现细节随版本演进)。分析时先确认你看的到底是 HWC/SF 轨道,还是 app 轨道;- 版本差异:官方文档明确 Android 10 及以下“Per-display VSYNC 不支持”;Android 11+ 该限制在框架/HWC 能力层面已移除,但应用侧
Choreographer的请求路径在 main 分支仍有“internal display”相关注释,需结合目标系统分支实测判断 - Perfetto:按显示 ID 过滤相关 Counter/Slice
- 版本差异:官方文档明确 Android 10 及以下“Per-display VSYNC 不支持”;Android 11+ 该限制在框架/HWC 能力层面已移除,但应用侧
Perfetto 实战 Checklist(建议按序查看)
- Vsync 信号与周期
vsync-app / vsync-sf / vsync-appSf间隔是否稳定(60/90/120Hz 对应周期)- 是否存在异常密集/稀疏的 Vsync(预测抖动)

- Vsync 相位差配置
VsyncWorkDuration是否符合机型预期的 app/sf Offset- app 与 sf 的先后是否匹配“先绘制后合成”的策略

- FrameTimeline 判读
- 先看
PresentType,再看JankType;确认是 app 还是 SF/GPU 侧问题 - 选择目标 Surface/FrameToken 定位具体帧

- 先看
- 应用主线程与渲染线程
Choreographer#doFrame各阶段耗时(Input/Animation/Traversal)RenderThread的syncAndDrawFrame/DrawFrame耗时是否异常
- BufferQueue 与 Fence
- 生产者:RenderThread
queueBuffer之后,Buffer 进入可消费队列;但 SF 是否能立刻 latch 还要看acquire fence。present fence主要用于确认该帧实际送显完成时间。新版本在特定策略下可对 unsignaled buffer 先推进,再在需要时等待 fence。
- 消费者 SF 与 BufferTX:SF 在每个合成节拍会尝试为目标 layer 取最新可用 Buffer。若某 layer 的 BufferTX 为 0,通常表示该 layer 暂无新 Buffer,SF 会沿用旧内容继续合成;对这个 App 来说表现为画面停滞/卡顿,但不代表 SF 全局“停止合成”。

- 生产者:RenderThread
- 合成策略与显示
- SF 是否频繁走 ClientComposition;HWC validate/present 是否异常
- 多显示/模式切换/VRR 时是否伴随明显预测偏差

- 资源与其他干扰
- CPU 竞争(大核占用)、GPU 忙、IO/内存抖动(GC/compaction)
- 其他前台应用/系统服务是否占用关键资源

参考文档
- Android Graphics Architecture
- VSYNC Implementation Guide
- Frame Pacing
- Perfetto Documentation
- Android Perfetto 系列 5:Android App 基于 Choreographer 的渲染流程
- Android Perfetto 系列 6:为什么是 120Hz?高刷新率的优势与挑战
- Vsync offset 相关技术分析
- Android 13/14高版本SurfaceFlinger出现VSYNC-app/VSYNC-appSf/VSYNC-sf剖析
- AOSP - Choreographer.java(main)
- AOSP - android_view_DisplayEventReceiver.cpp(main)
- AOSP - DisplayEventDispatcher.h(main)
- AOSP - DisplayEventReceiver.cpp(main)
- AOSP - VSyncDispatch.h(main)
- AOSP - EventThread.cpp(main)
- Android Multi-display(官方文档)
- AOSP - VsyncConfiguration.h(main)
- AOSP - DisplayEventDispatcher.cpp(main)
- Unsignaled buffer latch(官方文档)
关于我 && 博客
下面是个人的介绍和相关的链接,期望与同行的各位多多交流,三人行,则必有我师!
- 博主个人介绍 :里面有个人的微信和微信群链接。
- 本博客内容导航 :个人博客内容的一个导航。
- 个人整理和搜集的优秀博客文章 - Android 性能优化必知必会 :欢迎大家自荐和推荐 (微信私聊即可)
- Android性能优化知识星球 : 欢迎加入,多谢支持~
一个人可以走的更快 , 一群人可以走的更远

Java|FreeMarker 复用 layout
项目里的页面一多,重复的页面布局就不可避免地冒了出来,作为程序员,消除重复,义不容辞。那么,今天就来聊聊如何在 FreeMarker 中复用页面 layout,让代码更优雅、更易维护。
常规做法:include
FreeMarker 提供了 include 指令,可以把一些公共页面元素单独提取出来,然后在需要的地方通过 include 引入,例如:
<#-- includes/header.ftl -->
<p>我来组成头部</p>
<#-- includes/footer.ftl -->
<p>我来组成底部</p>
<#-- somepage.ftl -->
<#include "./includes/header.ftl">
<p>我是页面内容</p>
<#include "./includes/footer.ftl">
<script>
// 这里是一些 JavaScript 代码
</script>
去除重复:抽象 layout
但是所有类似的页面都要手写这个结构也挺麻烦的,更糟糕的是,一旦这些页面的结构发生变化,得在 N 个页面里反复修改,想想都头大。
很多博客引擎(比如 Jekyll)都支持 layout 功能,允许我们定义统一的页面布局,具体页面只需专注于内容。
FreeMarker 虽然没有内置 layout,但我们可以用 macro 来实现类似的效果。
比如,抽象出一个 layout/page.ftl 文件,作为布局模板:
<#-- layout/page.ftl -->
<#macro layout body js="">
<#include "../includes/header.ftl" />
${body}
<#include "../includes/footer.ftl" />
${js}
</#macro>
然后在需要的页面这样用:
<#import "./layout/page.ftl" as base>
<#assign body>
<p>我是页面内容</p>
<p>当前时间:<span id="current-time">${.now?string("yyyy-MM-dd HH:mm:ss")}</span></p>
</#assign>
<#assign js>
<script>
// 每隔一秒刷新当前时间
setInterval(function() {
document.getElementById("current-time").innerHTML = new Date().toLocaleString();
}, 1000);
</script>
</#assign>
<@base.layout body=body js=js />
页面效果如下:

减少手工输入:code snippets
虽然布局复用问题解决了,但每次新建页面还得手写一遍结构,还是不够优雅。程序员的信条是:能自动化的绝不手动!
这时就轮到编辑器/IDE 的 code snippets 功能登场了。把上面的结构定义成代码片段,新建页面时只需输入一个触发词,基本结构就自动生成。
以 VSCode 为例,可以在项目的 .vscode 目录下新建 layout.code-snippets 文件,内容如下:
{
"page_layout": {
"scope": "ftl",
"prefix": "layout:page",
"body": [
"<#import \"./layout/page.ftl\" as base>",
"",
"<#assign body>",
"",
"",
"",
"</#assign>",
"",
"<#assign js>",
"",
"<script>",
"",
"</script>",
"",
"</#assign>",
"",
"<@base.layout body=body js=js />"
],
"description": "Page layout template for FTL files"
}
}
这样新建 .ftl 文件后,输入 layout:page,页面布局结构就自动生成了。
如图所示:

IntelliJ IDEA 也可以用 Live Templates 实现同样的效果。
本文相关代码和示例已上传至 GitHub,见 https://github.com/mzlogin/learn-spring 的 freemarker-test 目录。
XXL-JOB的部署、搭建与使用
XXL-JOB是一个分布式任务调度平台,它可以很方便的实现分布式的任务调度。
部署
首先我们下载XXL-JOB的源码
git clone git@github.com:xuxueli/xxl-job.git之后我们根据doc/db/tables_xxl_job.sql中的sql创建对应的数据库和表。
在启动XXL-JOB之前,有些配置需要修改:
xxl-job-admin/src/main/resources/logback.xml中property的value需要设置为xxl-job/xxl-job-admin/data/applogs/xxl-job/xxl-job-admin.log,否则可能会因为文件夹不存在而启动不了xxl-job-admin/src/main/resources/application.properties中数据库的地址、用户名和密码,以及accessToken等等需要修改为自定义的配置值
修改完配置,就可以启动服务了
mvn clean packagecd xxl-job-admin/targetjava -jar xxl-job-admin-3.1.2-SNAPSHOT.jar这样服务就启动成功了,之后我们可以访问http://127.0.0.1:8080/xxl-job-admin/进入控制台
服务搭建
我们基于SpringBoot来构建业务服务,首先我们添加依赖
<dependency> <groupId>com.xuxueli</groupId> <artifactId>xxl-job-core</artifactId> <version>3.1.1</version></dependency>之后创建一个XXL-JOB的配置bean
1 | @Configuration |
配置好了之后我们就可以新建一个任务了
1 | @Component |
添加了配置和任务之后,我们就可以启动服务了。
使用
在控制台中,我们选择执行器管理,之后新增一个执行器。其中,AppName就是上面配置的应用名,如上就是test,名称就是这个管理器的自定义名称,注册方式选择自动注册即可。
创建好执行器,我们就可以新增任务了。在任务管理中,我们新增一个执行器为刚刚新增那个执行器的任务。运行模式选择BEAN,JobHandler设置为@XxlJob注解中的值demoJobHandler。之后当任务执行的条件达到时,demoJobHandler方法就会执行了。