观察Go新垃圾回收器在堆中的运作
Go 1.26版本将Green Tea设置为默认的垃圾回收器(GC)。去年发布的Go 1.25版本引入了这款新的垃圾回收器,而在几个月前发布的Go 1.26中,Green Tea成为了默认的回收器。我们通过 `perf` 工具来观察它的缓存友好性,可视化堆内存以了解Go语言的内存分配方式,还会探讨像Go这种非移动回收器在处理稀疏页面时面临的难题。
内存分配情况对比
Go语言通过在一个或多个8KiB页面组成的连续块(在Go术语中称为 _span_)内分配相同大小类别的对象来管理内存。我们随机分配三种不同大小(小、中、大)的对象,然后检查它们在堆中的地址。运行程序后发现,尽管是随机分配不同大小的对象,但Go运行时会将相同大小的对象相邻放置,而且即使运行了垃圾回收器,对象的位置也不会发生移动。而C#中相同大小的对象并没有被分组放置在一起,之后在不同的工作负载下,C#会在内存中移动对象。
标记 - 清除算法
垃圾回收器从特定的根对象开始,先进行 _标记_ 阶段,沿着每个指针进行遍历,直到访问完所有可访问的对象;然后进行 _清除_ 阶段,释放所有未被访问过的已分配对象。在Go语言中,不同大小的对象会被分配到内存的不同区域,垃圾回收器沿着指针进行遍历时会引入随机内存访问,降低缓存友好性。在Green Tea中,Go现在会扫描内存块以查找对象和指针,并根据找到的指针将后续要扫描的内存块加入队列。使用 `perf` 工具观察到缓存未命中率(每千条指令)减少,并且程序的整体运行速度加快。通过不同的代码和脚本进行测试,发现新的垃圾回收器在每种工作负载下都有明显的性能提升,但新的垃圾回收器的缓存未命中率似乎有所增加。进一步计算每千条指令的缓存未命中数(MPKI)后,仍然没有看到预期的结果。最后在裸机x86/amd64 Linux机器上测试,终于看到预期结果:虽然L1缓存未命中率百分比保持不变或有所下降,但 _L1 MPKI_ 明显下降。
最坏情况下的清理
由于Go语言不会移动内存来进行内存压缩或碎片整理,释放了大部分对象,但Go语言无法完全回收未使用的内存。通过程序测试,在分配完对象后释放90%的对象,原本希望内存使用量能减少90%,但实际上并不会,因为Go语言不会对对象进行压缩或移动。