slaveOftime

“我爸是在微软收垃圾的”,.NET GC开创人的儿子说

2021-02-23

最近住院了,有时间开始查一些关于.NET GC的资料,反正死不了,出院了还得干活所以学习不能落下,万一面试又被问了呢!

为什么需要

买车的时候我肯定首选自动挡啊,市区开多省力啊,超车也就是脚稍微挪一下位置就好了。想想手动挡,什么离合啊,低挡切换啊什么的,油门还不能给太凶,至少考驾照的时候教练会骂你。

从这个角度来看,C/C++里的内存管理就相当于手动档,麻烦但是精确(要几挡来几挡,小心溜车或熄火哦)。适用于偏底层一点的开发,比如系统开发、驱动开发等。但是对于业务代码来说就显得太过啰嗦麻烦,而且最关键的就是容易犯错,比如内存泄漏、内存碎片化等。

别人RUST既兼顾性能,也兼顾内存的安全,当然语法就比较复杂,学习曲线也很陡峭了。

.NET GC具体优点有:

  • 让开发者不用手动释放内存
  • 高效地在托管堆分配对象
  • 回收不使用的对象,清理它们的内存,以备将来使用;托管对象会自动获取干净的内容,因此,它们的构造函数不必对每个数据字段进行初始化。
  • 通过确保一个对象不能使用另一个对象的内容来保证内存安全

什么时候需要

  • 系统内存过低,或者宿主程序指示内存过低。比如容器环境下,对内存和CPU进行限制。
  • 在托管堆上分配的内存超过一个阈值(这个阈值随着程序的运行也在不断调整)。
  • GC.Collect 被调用,这个是开发者自主介入的。当然我们基本不需要调用这个函数,反正我是重来没有用过。

怎么回收

对于内存回收,核心方法有引用计数和追踪两种。使用引用计数的有某些运行时下的javascript,C++里有smart pointer等;而使用追踪的就有ruby, java, 以及.net等。

那么.NET GC具体怎么追踪呢?

我们都知道.NET的堆是和栈分布在虚拟内存的两端的,而堆是用来存放引用类型的数据的,也叫托管堆。当我们如下分配了几个对象在堆上:

A B C D E F G ... (从左到右依次分配,每个对象最少使用24B,包含Header, Metadata pointer, data三部分)

假如这些对象的引用关系图如下:

那么从roots开始往下递归,我们就能发现ABDEG是可以在递归的过程中访问到的,而C和F则不行。这里其实就是所谓的mark phase,这个过程会生成一个mark array/mark list以供将来的plan phse使用。 而这个mark array是需要额外的空间的,对于64位的机器来说要标记1GB的数据需要8Mb。

所谓的roots来源有如下:

  • 栈,本地变量等
  • CPU register
  • 静态数据
  • Finalize queue
  • Inter-generation reference
  • ...

前三种还是比较容易理解的,其他的就得继续深入查资料了。

当然mark phase并不是听起来这么简单的,尤其是再要考虑pinned(比如,fixed语法下的数据),concurrent mark那就非常复杂了。

接下来就是所谓的plan phase,这个阶段会发生两种可能,即sweep和compact。sweep就是把未标记的对象释放掉,而compact则是不但要把对象释放掉还得整理内存,进行压实,免得碎片化。

之所以会有这两种可能,主要是为了性能考虑,因为如果只发现几个对象需要释放就要重整管理堆的话,消耗可能太大,所以运行时会根据compact的效率以及释放对象数量的阈值来决定是sweep还是compact。

当然还可以继续深入compact到底是怎么做的,因为整理内存有两个重要的事情要做:拷贝数据;更新引用地址。这就要涉及到plug, gap, bricktable等概念了。这里就不继续学习了,先看看generation,即我们常说的“代”。

为什么要有“代”这个概念呢,当然主要还是为了性能。因为前面我们也看到了,mark phase和plan phase的消耗都是很大的,尤其是我们有很多对象时。而“代”这个概念其实和我们现在的垃圾分类很像,也是为了处理垃圾时的效率。

当我们最开始分配对象到堆上时,.NET CLR会向操作系统申请两块内存(即segment,具体大小根据GC类型和运行环境决定),一个是用于LOH(大对象堆),一个用于SOH(小对象堆)。大小对象的阈值是85000 bytes。对象都是依次从segment的开始依次分配。

对于SOH,当GC 第0代和第1代的时候通常比较快,而且都是非并发的,需要暂停整个程序,如果一个对象没有被回收掉,那它的寿命就加1,最大到2。其实并没有真的加了一个数值,只是说它最后所在的位置在相应的代的区域里了。

比如第一次分配了

ABCDEFG

后来发生了GC,把CD释放了,那么剩下的对象就变成了第一代了,并且压实在segment上面。

ABEFG

后来,我们可能又分配了对象HIJK

ABEFGHIJK

然后又发生了GC,释放了IJ

ABEFGHK

那么也就剩下了G2和G1。

由此也可以看出为什么G0,G1的GC是一起做的,而G2是另外的时间。因为新分配的对象很自然的就是在G0。在比较新的版本的.NET里,G2是在后台运行的,大部分时间不会中断整个程序,而G0/G1一直都是前台运行,并始终会中断整个程序的运行。

具体可以参看如下时序图:

gc-time-series

Workstation GC,最常用的就是WPF,WinForm之类的,它们需要更好的响应速度来提升交互体验;而Server GC,最常见的就是ASP.NET了,它们需要更好的吞吐能力。

而对于LOH,因为数据量太大,所以通常都不会进行compact,在.NET CORE里还可以进行配置来决定是否要做compact。LOH的垃圾回收是和G2一起的,通常也被叫做G3。

对于大对象,如果有频繁使用的场景,最好使用大对象池,因为大对象的分配首先是比较耗时的,因为.NET为了确保分配的内存是干净的,会对相应的内存做清理,而消耗是和要清理的内存大小成正比的。而且频繁的分配和释放大对象,很可能触发G2 GC,也叫Full GC,这样就会导致不必要的大消耗,但是GC的效果却并不明显。

总结

经过一番学习,发现GC其实还是挺有趣的,不过我想多年前我第一次学习GC的时候也是这样认为,但是后来几乎忘得一干二净,只知道GC这两个字母了。

现在我对GC有了新的认识,个人感觉上对自己平时的工作可能帮助不大。印象比较深并可能会对以后有帮助的就是大对象的分配,非托管对象的维护。其他的,唉,就然时间和面试官来检验了吧😁

Do you like this post?