GC 是什么?
GC 是垃圾回收(Garbage Collection)的缩写,是一种自动内存管理机制。在编程语言中,程序员通常需要手动分配和释放内存,但是这种方式容易出现内存泄漏和野指针等问题,因此一些编程语言引入了 GC 机制,自动管理内存的分配和释放。
GC 机制会在程序运行时自动检测不再使用的内存,并将其回收,以便程序可以继续运行。GC 机制可以减少程序员的工作量,同时也可以提高程序的性能和稳定性。
Go GC 的原理介绍
在 Go 中,GC 采用了标记-清除算法,其中标记采用的是三色标记法。
简单介绍一下,标记-清除算法是一种常见的 GC 算法,它分为两个阶段:标记阶段和清除阶段。在标记阶段,GC 会遍历程序中的所有对象,并标记所有仍在使用的对象。在清除阶段,GC 会清除所有未被标记的对象,以便回收内存。标记-清除算法的缺点是会产生内存碎片,影响程序的性能。
为了解决标记-清除算法的缺点,Go 引入了三色标记法。三色标记法将对象分为三种颜色:白色、灰色和黑色。在 GC 开始时,程序创建的对象都标记为白色。在标记阶段,GC 会从根对象开始遍历程序中的所有对象,并将其标记为灰色。然后,GC 会遍历所有灰色对象的引用,并将其标记为黑色。这个过程会一直进行,直到所有灰色对象的引用都被标记为黑色,此时只会存在黑色和白色对象。在清除阶段,GC 会清除所有白色对象,并将所有黑色对象重新标记为白色。三色标记法可以避免内存碎片的产生,提高程序的性能和稳定性。
在标记前和标记后有一个很重要的阶段,被称为 STW(Stop-The-World),顾名思义此阶段会暂停所有程序,此时程序会出现卡顿。因为进行标记前,Go 需要初始化一些三色标记的环境,进行一些轻量级的准备工作;标记完成后,Go 需要针对此次 GC 的结果进行一些统计,并计算出下一次 GC 的内存额度。所以 Go 每个版本的迭代对 GC 的优化,很重要的一部分工作就是减少 STW 的时间,目前官方声称 Go 的 STW 已经是亚毫秒级了。
那么很多同学会有疑问,既然标记前后都需要 STW,那么标记中不需要进行 STW 吗?垃圾回收流程与代码逻辑并发执行。代码逻辑的执行会产生或改变对象引用,如果标记时阶段不加以区分,对象引用的变化不是会让标记阶段无止尽的执行下去?
Golang GC 采用了写屏障技术来保证标记过程的原子性,以避免出现上述因程序运行导致对象被重复标记或漏标的情况。大致原理是写屏障技术会在对象被修改时,将其标记为灰色,以便 GC 可以重新遍历其引用。这样可以保证标记过程的正确性和完整性。Golang 采用的写屏障为混合写屏障(即插入写屏障 + 删除写屏障),关于写屏障的详细原理这里就不展开说明了,静待后续开坑。
Go GC 计算公式
从上一个 GC 周期结束为时间起点,仍存活的对象定义为活动堆(live heap),活动堆加上应用程序时间起点以来分配的内存,定义为新堆(New heap)。新堆加上活动堆则是总堆(Total heap)。
Go Runtime会在每次 GC 周期结束之后,计算出一个目标堆内存(Target heap memory ),同时保证总堆大小超过目标堆大小之前(即:Total heap memory < Target heap memory)完成垃圾收集。而 GOGC 则是计算目标堆内存的重要参数,也是权衡 CPU 开销和内存开销的关键变量。
Go 1.18 前,目标堆的计算公式如下:
默认情况下 GOGC 为 100,这就意味着每次 Go runtime 预留分配的内存空间与之前的活动推大小一致。通过公式可知,GC 触发与 Live heap 有着强关联性,但 Live heap 并不包含 goroutine,因为 goroutine 堆栈中的内存量非常小,相比较 Live heap 可以忽略,所以之前都是通过 Live heap 大小支配所有其他 GC 工作来源。但在程序有数十万个 goroutine 的情况下,GC 会做出错误的判断。所以:
Go 1.18 前,目标堆的计算公式如下:
例如,考虑一个 Go 程序,其活动堆大小为 8 MiB,goroutine 堆栈为 1 MiB,全局变量中的指针为 1 MiB。然后,如果 GOGC 值为 100,则在下一次 GC 运行之前分配的新内存量将为 10 MiB,或者 10 MiB 工作量的 100%,总堆占用空间为 18 MiB。如果 GOGC 值为 50,则它将是 50%,即 5 MiB。如果 GOGC 值为 200,则为 200%,即 20 MiB。
无论是 1.18 前还是 1.18 后,Target heap 越大,GC 等待开始另一个标记阶段的时间就越长,从而降低 GC 频率,反之亦然。GC 频率高 CPU 成本高,GC 频率低堆内存开销大。
而 GOGC 是 一个在 GC CPU 和内存权衡中选择一个点的参数。所以只要记住一句话,将 GOGC 加倍将使堆内存开销加倍,并使 GC CPU 成本大致减半,反之亦然。有兴趣的同学可以参考 官方原文:A Guide to the Go Garbage Collector
GC 调优
下面我们就可以利用我们丰富小学的数学知识,从公式推导中我们可以梳理出 GC 调优思路。
1. 调整 GOGC
从公式中最直观的就是通过设置 GOGC 环境变量来调整 GC 的触发时间和阈值。设置 GOGC 分为两种方式:
1.1 通过程序设置
需要注意,此时 GOGC 参数的设置只对当前进程有效
import (
"os"
"strconv"
)
func main() {
// 设置 GOGC 参数为 50
os.Setenv("GOGC", "50")
// 获取当前 GOGC 参数的值
gogc := os.Getenv("GOGC")
gogcValue, _ := strconv.Atoi(gogc)
fmt.Printf("GOGC is set to %d\n", gogcValue)
}
或者通过 debug.SetGCPercent() 来设置。
1.2 设置 GOGC 系统环境变量
但这种直接设置 GOGC 方式有两个显而易见的缺点:
- 设置固定不变的 GOGC 并不灵活,同时也不精确,想要达到最佳实践得反复调试观察,成本很高。
- 设置过大的 GOGC 可能会导致 OOM 的问题(这个在 1.19 版本中有了比较好的解决方案)
2. Auto-tuning
由上方法,可想到其实理想的 GC 应该是,内存充足,CPU 较高时,GC 频率低一些;内存不足,CPU 较低时,GC 频率高一些,GC 在每次 GC 周期结束时,根据当前状态来进行一个最优参数的动态设置。
Uber 根据这个理想模型,提出了 Auto-tuning 的优化方案。具体实现效果和上述差不多,在每次 GC 结束时,根据容器的内存上限动态通过 debug.SetGCPercent() 调整 GOGC 的值,并在内存使用率达到 70% 时,强制 GC,实现内存硬限制来防止 OOM 发生。
阿里也曾做过类似的尝试:Go 调用 Java 方案和性能优化分享
这种方式已经接近于 GC 的理想模型了,但很显然,这种方式的实现成本比较高,可迁移性也比较差,业界目前还没有一个具有此功能的插件能够低成本的给开发者使用,大部分都是根据自身业务 case by case 的定制开发。
3. Memory Ballast
我们除了从 GOGC 入手,还由公式可知目标堆的影响因素除了 GOGC 还有 Live heap。
而 Memory Ballast 原理就是想活动推中写入一个保活的大对象,影响 Live heap 大小,从而扩大目标堆,推迟 GC,减少 CPU 开销。我们可以在程序开始时分配一个大数组来实现该效果。实现代码如下:
func main() {
// Create a large heap allocation of 5 GiB
_ := make([]byte, 5<<30)
// Application execution continues
// ...
}
这个超大的数组就称为 Ballast。当使用默认 GOGC 时,下一次的目标堆最小都为 10GiB。也就是说下一次 GC 触发至少需要 New heap 分配的内存接近于 5 GiB。
这里使用字节数组作为 Ballast 的理由是,首先确保我们只向标记阶段添加一个额外的对象,其次字节数组没有任何指针(对象本身除外),GC 可以在 O(1) 时间内标记整个对象。
那么使用 Ballast 时,这个大对象会不会占用宝贵的实际物理内存呢?
答案是理论上不会,因为系统中的内存实际上是由操作系统通过页表寻址和映射的,ballast 的 slice 会被分配到程序的虚拟地址空间中。只有当我们尝试读取或写入 slice 时,会发生缺页,才会实际分配物理 RAM。更详细的内容可参考:Go memory ballast: How I learnt to stop worrying and love the heap
但对于此结论确实也存在争议,具体可见论坛上的讨论:Create a 1GB ballast but it takes up RSS and pages are Dirty?
4. 从程序上优化内存使用
上面提到了触发 GC 的条件是:总堆大小超过目标堆大小之前(即:Total heap memory < Target heap memory),而以上的方案都是通过目标堆的公式推导得出的,我们一直在尝试调大目标堆来减少 GC 频率,那么反之,在目标堆(Target heap memory)不变的情况下,减少总堆(Total heap memory)大小的分配速度,同样也可以减少 GC 的频率。
根据总堆公式:
可知总堆由 Live heap 和 New heap 组成,而 Live heap 是上一个 GC 周期结束后仍存活的对象,这块优化空间很小,所以我们需要将重点放在 New heap 这部分,而 New heap 是由 Live heap 加上应用程序时间起点以来分配的内存。
由此优化方向已经很清晰了,重点需要减少新变量的内存分配上,一般操作是:
- 通过使用更少的变量、更小的数据结构和更少的缓存来减少内存使用。
- 通过使用 sync.Pool 来重用对象,或者使用内存池来减少内存分配等。
这种优化方式需要结合业务场景,在编写代码时 case by case 的进行调优。
尾言
我们通过 GC 计算公式出发,控制其公式参数的变化,总结出了几个常用的调优方式。按照这套理论,今后无论是在面试还是项目中,只要你记住了 GC 计算公式,都可以现场推出调优方式,所以学习过程中,深入理解原理还是非常重要的。
当然光靠理论是不行的,调优是一个非常注重实践的操作。在项目中,首先我们的有完善性能测试和监控系统,才能发现问题,从而将我们的调优理论落地,而且在落地的过程中,需要综合考虑应用程序的特点、负载情况和性能需求,在日益复杂的软件架构中,是没有银弹的,这也是软件开发工程师的价值所在。