前言
Go语言的垃圾回收(Garbage Collection,以下简称GC)机制是并发三色标记,是一种不移动的标记-清扫 GC算法。在2018年的一次演讲 中,官方公布了尝试使用分代GC的想法。截止目前(2019年4月份),分代GC仍处于试验的状态,没有具体的发布时间。
go官方代码库,目前共有10个commit,本文主要讲解Go分代GC的实现思路以及现有的一些问题,后面可能详细讲解一下代码。
标记-清扫 GC算法
标记-清扫GC算法首先会从一些固定的root节点开始,对于Go语言来说就是全局指针和 goroutine 栈上的指针,根据这些root节点进行递归标记。当标记完成后,所有被标记的对象就都是存活的,其余的对象即是可以清扫的。
分代GC算法
分代GC是基于这样一个假设:大部分新分配的对象存活周期较短,在分配后的第一轮GC中就会被回收掉。
如果这个假设成立,那么GC期间只去扫描和清扫新分配的对象就可以清扫掉大部分需要回收的对象,这样就可以节省GC的时间。
补充几个概念:
新生代对象:上一轮GC后新分配的对象。
老年代对象:上一轮GC中存活的对象。
Minor GC:小型GC,只对新生代对象进行清扫。
Major GC:大型GC,清扫所有对象,因为一直进行Minor GC的话,会造成程序占用的内存越来越多,老年代对象越来越多,所以在分代GC中要穿插使用Minor GC和Major GC,Major GC可以是标记-清扫GC算法或复制GC算法,一般是和复制GC算法配合使用(容易区分新生代对象和老年代对象)。
这里额外说一下,在GC中有两种问题:一种是本不该被清扫的对象被清扫了,这种会造成程序错误。 另一种是本该被清扫的对象没有被清扫,这种会增加内存的占用量,但不会造成程序错误。 分代GC属于第二种问题。
Go分代GC的实现思路
Go将分代GC算法与标记清扫算法相结合,实现了一个不移动的分代GC。那么如何判别新生代和老年代呢?
首先来看下Go并发标记清扫算法是如何工作的
allocBits
内存分配标志位,表示这一小块内存已经分配了对象。 gcmarkBits
GC清扫标志位,表示这一小块内存在本轮GC中是存活的对象,不能被清扫。
- 程序第一次运行期间,在1、2、4、5、6位置分配了新对象(
allocBits
有所体现) - 开始GC时,1、4、5位置的对象是存活的(
gcmarkBits
有所体现) - 完成GC时,将
gcmarkBits
的值赋给allocBits
,然后清空gcmarkBits
。表示1、4、5位置已经分配了对象,不能重复分配。 - 下一轮程序运行期间,又在2、3、6位置分配了新对象…………
如何实现的不移动的分代GC?
在上图中可以看出,在每次GC完成期间,都对gcmarkBits
进行了清空操作,如果不进行清空操作,会发生什么?
如果在GC期间,发现了一个对象的allocBit=1
,且还没有进行标记的情况下gcmarkBit=1
,那么就说明这个对象时上一轮GC中存活的老年代对象(对象的gcmarkBit
是在上一轮GC期间被标记的) ;如果一个对象的allocBit=1
但 gcmarkBit=0
,这个对象是在这一轮程序运行期间分配的新生代对象。
所以allocBit=1 && gcmarkBit=1
表示老年代对象,allocBit=1 && gcmarkBit=0
表示新生代对象,这样就实现在可不增加额外工作量的情况下,判别新生代和老年代对象。 如果每轮GC都对所有对象进行扫描去判断是否是新生代对象,是否存活,那么分代GC的优势将不再存在,如何做到减小分代GC的工作量呢?
一种方案是在程序运行期间也开启一个类似writrbarrier(写屏障)的机制,使用一个快速写屏障,这种方案会对GC关闭期间程序的运行速度造成一些影响,另外一种方案就是Go分代GC的另外一个创新点,使用card hash
消除在GC关闭期间的写屏障。
为什么要判别老年代对象和新生代对象?
一个是因为在Minor GC中只需要对新生代对象进行清扫,老年代对象无论是否标记可达,都认为是存活的,不会进行清扫。另外一个是因为要找出老年代对象对新生代对象的指向,对新生代对象进行提升的操作。
分代GC中Minor GC不会清空gcmarkBits
,Go语言的标记-清扫GC是根据根节点递归扫描,如果对象已经被标记了,就会返回,不再递归扫描下去。那么在扫描过程中,遇到老年代对象就会立即返回,如果这个老年代对象是一个指针,并且在程序运行期间指向了一个新生代对象,并且这个老年代对象仍是存活的(标记可达的),那么就会遗漏对这个老年代对象所指向的新生代对象的标记,造成这个新生代被回收掉,造成程序的错误。所以在分代GC中也会把老年代对象作为一个根节点,找出老年代指向的新生代对象,对新生代对象进行提升标记操作。
使用Hash进行加速
将内存每1KB空间分成一个card
,cardhash
表示对card
中所有老年代指针对象进行hash
计算的结果,Cardhash Table
存储所有card
的cardhash
。 在GC期间会重新计算每个card
中老年代指针对象的hash
值,如果这个老年代在程序运行期间指向了新对象,那么重新计算hash
时,就会发现其所在的card
与上一次GC时计算的hash
值不相符,再去仔细扫描这个card
中老年代所指向的新生代对象,将新生代对象进行提升为老年代的操作。
仔细扫描提升的时间比hash更耗时,且大部分的card的hash值与之前的值是相同的,所以加入hash计算可以达到加速的目的。
End
这篇文章就讲到这里吧,Go的分代GC目前还有些问题,Hash没有想象中的快,所以在某些情况下,没有很大的性能提升,甚至在某些特定情况下还可能造成性能下降。下次可能会讲下代码级别的具体实现。希望Go早日能够将分代GC合入分支,带来更好的性能。
欢迎交流,如有错误,还请指出。
转载请注明出处,谢谢。
Go是如何穿插使用Minor GC和Major GC已经在下一篇文章更新,欢迎阅读。