垃圾回收算法

Go语言中的垃圾回收(GC)算法是其自动内存管理的核心机制,采用并发标记-清扫(Concurrent Mark-Sweep)技术,结合三色标记法增量回收策略,旨在减少程序停顿时间并提高性能。

三色标记法将程序中的对象分成白色 黑色 和灰色三类:

  • 白色:未被访问的对象, 潜在的垃圾,可能会被回收
  • 灰色:已被标记但子对象未完全扫描的对象
  • 黑色:活跃的对象,不会被回收

GC工作流程

  1. 标记准备(Mark Setup): STW暂停,完成准备工作如开启写屏障。记录根对象(全局变量、栈变量、寄存器等)。
  2. 并发标记(Concurrent Marking): 后台线程扫描根对象及其可达子对象,标记为灰色,逐步推进至黑色。 用户程序继续运行,写屏障拦截指针修改,确保标记正确性。
  3. 标记终止(Mark Termination): 短暂STW暂停:完成剩余标记,确保所有存活对象标记为黑色。统计存活对象,准备清除阶段。
  4. 并发清除(Concurrent Sweeping): 回收未被标记的白色对象内存,交还给堆管理器。用户程序无感知,持续执行。

写屏障

垃圾标记和正常程序是同时进行,所以有可能出现标记错的情况,比如扫描了a 以及a所有的子节点后,这时候用户建立了a指向b的引用,这时b是白色会被回收,所以引入了屏障。它可以在执行内存相关操作时遵循特定的约束,在内存屏障执行前的操作一定会先于内存屏障后执行的操作。屏障有两种,写屏障和读屏障,因为读屏障需要在读操作中加入代码,对性能影响大,所以一般都是写屏障。

业界有两种写屏障 : 插入写屏障和删除写屏障 1.7用的插入写屏障 1.8用的混合写屏障

  • 插入写屏障: 当A对象从A指向B改成从A指向C时,把BC都改成灰色。
  • 删除写屏障:在老对象的引用被删除时,将白色的老对象改成灰色
  • 混合写屏障 :将被覆盖的对象标记成灰色 & 没有被扫描的新对象也被标记成灰色 & 将创建的新对象都标记成黑色

屏障必须遵守三色不变性 : 强三色不变性:黑色对象不会指向白对象,只会指向灰色对象或者黑色对象 弱三色不变性:黑色对象指向的白色对象必须包含一条从灰色对象经由的多个白色对象的可达路径

垃圾回收触发条件

  1. 堆内存阈值:默认当堆内存增长到上一次GC后的2倍(由GOGC环境变量控制,默认值100%)。
  2. 定时触发:防止长时间未触发GC导致内存激增(2分钟强制触发一次)。
  3. 手动触发:通过runtime.GC()函数主动触发(通常不推荐)。

参考资料

GO与Java垃圾回收的区别

GO语言与Java语言的垃圾回收机制均旨在实现自动内存管理,但在设计哲学、实现细节及适用场景上存在显著差异。以下从算法、并发性、内存模型、性能表现等方面详细对比二者的异同点:

  1. 算法与分代设计
特性 GO语言 Java语言
基础算法 并发三色标记-清除(无分代) 分代收集(新生代+老年代)+多种算法选择
分代假设 不依赖分代假设 强分代假设(对象大多朝生夕死)
回收策略 全局混合回收 分代回收(Minor GC + Full GC)
典型GC算法 Go 1.5+的并发标记-清除 G1、ZGC、Shenandoah、CMS、Parallel GC

关键点
Go无分代:假设对象生命周期无明显规律,采用统一内存空间,简化实现但可能增加回收成本。
Java分代:通过Young/Old区划分,优先回收新生代(复制算法),提升短期对象回收效率。

  1. 并发与停顿时间
特性 GO语言 Java语言
STW阶段 仅初始标记和终止阶段短暂STW(微秒级) 某些算法(如G1)在标记开始/结束需STW
并发能力 全并发标记与清扫 ZGC/Shenandoah支持全并发,CMS部分并发
最大停顿目标 通常<1ms(Go 1.14+) ZGC/Shenandoah可控制在10ms以内

关键点
• Go的GC设计更强调低延迟,适合实时性要求高的场景(如游戏服务器)。
• Java通过ZGC等算法在超大堆(TB级)下仍保持低停顿,适合企业级应用。

  1. 内存模型与分配策略
特性 GO语言 Java语言
对象分配 基于协程栈的逃逸分析,优先栈分配 对象几乎全堆分配
内存布局 连续内存块(mspan管理) 分代内存区(Eden/Survivor/Old)
内存压缩 无内存碎片整理 G1/ZGC支持部分整理,避免碎片

关键点
• Go通过逃逸分析将未逃逸对象分配在栈上,减少GC压力。
• Java因分代需处理跨代引用(Card Table、Remembered Set),增加开销。

Mid-stack inlining

与C系列的语言一样, Go语言的编译器对于比较短小的函数会默认进行内联操作. 是否内联的主要依据是代码的代价(复杂度), 越是简单的代码越容易被内联.

内联是递归的, 例如对于下面的函数, 由于g是简单函数, 因此f中的调用被内联, 之后f也是简单函数, 因此f被内联到main方法中.

1
2
3
4
5
6
7
8
9
10
11
func main() {
f()
}

func f() int {
g() + g()
}

func g() int {
// 简单函数
}

由于是否内联取决于函数的代价, 因此部分无法衡量代价的操作将导致整个函数无法被内联. 此外, 如果一个函数内调用了不可内联的函数, 则此函数由于无法计算代价也必然不可内联.


针对函数内调用了不可内联的函数导致不可内联的问题, Go语言引入了Mid-stack inlining技术. 只要一个函数f本身足够简单, 即使调用了不可内联的函数g, f自己还是可以被内联.

无法对f进行内联的主要原因是Go的编译器对于此类内联操作无法准确的追踪堆栈和调用信息, 导致报错时无法准确输出信息. 对编译器进行改造以后即可修复此问题. 引入此优化后, Go语言有大约9%的性能提升和大约15%的体积增加


标准库的sync包中的Lock方法采用了Mid-stack inlining特性, 代码如下:

1
2
3
4
5
6
7
8
9
10
11
func (m *Mutex) Lock() {
// Fast path: grab unlocked mutex.
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
// Slow path (outlined so that the fast path can be inlined)
m.lockSlow()
}

上述代码对于加锁操作分为两个部分, 首先尝试CAS加锁, 如果失败则执行后续逻辑. 此处CAS操作的代价是确定的, 而lockSlow()逻辑非常复杂, 不可被内联.

按照上述写法, 可以确保Lock方法本身可以被内联, 从而使得锁压力较小时性能较高.

GC优化

最后更新: 2025年03月12日 19:15

版权声明:本文为原创文章,转载请注明出处

原始链接: https://lizec.top/2024/03/24/Go%E8%AF%AD%E8%A8%80%E7%AC%94%E8%AE%B0%E4%B9%8B%E6%80%A7%E8%83%BD%E5%88%86%E6%9E%90/