首先,为了深入了解垃圾回收(GC),我们要了解一些基础知识:
CLR:Common Language Runtime,即公共语言运行时,是一个可由多种面向CLR的编程语言使用的“运行时”,包括内存管理、程序集加载、安全性、异常处理和线程同步等核心功能。
托管进程中的两种内存堆:
托管堆:CLR维护的用于管理引用类型对象的堆,在进程初始化时,由CLR划出一个地址空间区域作为托管堆。当区域被非垃圾对象填满后,CLR会分配更多的区域,直到整个进程地址空间(受进程的虚拟地址空间限制,32位进程最多分配1.5GB,而64位最多可分配8TB)被填满。
本机堆:由名为VirtualAlloc的Windows API分配的,用于非托管代码所需的内存。
NextObjPtr:CLR维护的一个指针,指向下一个对象在堆中的分配位置。初始为地址空间区域的基地址。
CLR将对象分为大对象和小对象,两者分配的地址空间区域不同。我们下方的讲解更关注小对象。
大对象:大于等于85000字节的对象。“85000”并非常数,未来可能会更改。
小对象:小于85000字节 的对象。
然后明确几个前提:
CLR要求所有引用类型对象都从托管堆分配。
C#是运行于CLR之上的。
C#new一个新对象时,CLR会执行以下操作:
计算类型的字段(包括从基类继承的字段)所需的字节数。
加上对象开销所需的字节数。每个对象都有两个开销字段:类型对象指针和同步块索引,32位程序为8字节,64位程序为16字节。
CLR检查托管堆是否有足够的可用空间,如果有,则将对象放入NextObjPtr指向的地址,并将对象分配的字节清零。接着调用构造器,对象引用返回之前,NextObjPtr加上对象真正占用的字节数得到下一个对象的分配位置。
弄清楚以上知识点后,我们继续来了解CLR是如何进行“垃圾回收”的。
二、垃圾回收的流程我们先来看垃圾回收的算法与主要流程:
算法:引用跟踪算法。因为只有引用类型的变量才能引用堆上的对象,所以该算法只关心引用类型的变量,我们将所有引用类型的变量称为根。
主要流程:
1.首先,CLR暂停进程中的所有线程。防止线程在CLR检查期间访问对象并更改其状态。
2.然后,CLR进入GC的标记阶段。
a. CLR遍历堆中的对象(实际上是某些代的对象,这里可以先认为是所有对象),将同步块索引字段中的一位设为0,表示对象是不可达的,要被删除。
b. CLR遍历所有根,将所引用对象的同步块索引位设为1,表示对象是可达的,要保留。
3.接着,CLR进入GC的碎片整理阶段。
a. 将可达对象压缩到连续的内存空间(大对象堆的对象不会被压缩)
b. 重新计算根所引用对象的地址。
4.最后,NextObjPtr指针指向最后一个可达对象之后的位置,恢复应用程序的所有线程。
CLR的GC是基于代的垃圾回收器,它假设:
对象越新,生存期越短
对象越老,生存期越长
回收堆的一部分,速度快于回收整个堆
托管堆最多支持三代对象:
第0代对象:新构造的未被GC检查过的对象
第1代对象:被GC检查过1次且保留下来的对象
第2代对象:被GC检查大于等于2次且保留下来的对象
第0代回收只会回收第0代对象,第1代回收则会回收第0代和第1代对象,而第2代回收表示完全回收,会回收所有对象。
CLR初始化时,会为第0代和第1代对象选择一个预算容量(单位:KB)。如下图,CLR为ABCD四个第0代对象分配了空间,如果创建一个新的对象导致第0代容量超过预算时,CLR会进行GC。
A0 B0 C0(不可达) D0GC后的堆如下图,ABD三个对象提升为第1代对象,此时无第0代对象
A1 B1 D1假设程序继续执行到某个时刻时,托管堆如下,其中FGHIJ为第0代对象
A1 B1 D1(不可达) F0 G0(不可达) H0 I0 J0根据GC假设的前两条可知,它会优先检查第0代对象,那么GC第0代回收后的托管堆如下,FHIJ提升为第1代对象
A1 B1 D1(不可达) F1 H1 I1 J1随着第1代的增加,GC会发现其占用了太多内存,所以会同时检查第0代和第1代对象,如某个时刻的托管堆如下,其中K为第0代对象
A1 B1 D1(不可达) F1 H1(不可达) I1 J1 K0GC第1代回收后的托管堆如下,其中ABFIJ都为第2代对象,K为第1代对象。
A2 B2 F2 I2 J2 K1还有一些额外的规则需要注意:
在进行第1代回收之前,一般都已经对第0代对象回收了好几次了。
如果对象提升到了第2代,它会长期保持存活,基本上只有当GC进行完全垃圾回收(包括0、1、2代的对象)时才会进行回收。