CLR 垃圾回收实现原理

垃圾回收器帮我们处理了内存中不在使用的对象,提高了机器的性能,让开发人员轻松了很多。

但是,你真的了解垃圾回收吗?

或许你知道垃圾回收,听说过是通过标记回收,可是怎么标记回收呢就不是很清楚了,好吧,如果不清楚就继续往下看。如果你是大神对这块了如执掌,请直接跳过,欢迎来提不同的意见。

1、我们先来聊一下内存分配:

代码中声明变量是需要向内存申请地址的,内存呢又分托管堆和栈,我们今天主要聊的就是托管堆内存

啥事托管堆内存呢?想必各位也心中知道,不知道的自行百度谷歌去。

写代码中凡是需要使用new声明的变量都是引用类型变量,使用的都是托管堆内存地址,那声明了一个对象,需要分配多大的控件呢?

1.1、这个时候就需要计算类型的字段需要的字节数了

1.2、引用类型对象开销的字节数还需要(类型对象指针和同步索引块)

  在32位应用中,这多出来的两个字段各需32位字节地址空间,所以每个对象需要多占用8个字节的地址控件

  在64位应用中,这多出来的两个字段各需64位字节地址空间,所以每个对象需要多占用16个字节的地址控件

1.3、内存申请后,CLR会检查保留区是否能够提供分配对象所需的字节数,使用new 声明的对象会向托管堆请求地址分配,并返回对象地址,NextObjPtr指针会加上对象占据的字节数,得到一个新值

2、垃圾回收-Go Go Go

垃圾回收的基本逻辑:垃圾回收器会检查托管堆中是否又应用程序不再使用的任何对象,如果有,它们使用的内存就可以回收了。

回收之前的托管堆如下:

下面我们来聊一下标记回收的整个过程:

2.1、首先,应用有一组根(root)每个根都是一个存储位置,其中包含指向引用类型对象的一个指针,指针要么引用托管堆中的一个对象,要么为null

  例如:类型中定义的任何静态字段被认为是一个根

       任何方法参数或局部变量也被认为是一个根,只有引用类型的变量才被认为是一个根,值类型不能被认为是根。

2.2、垃圾回收的第一阶段,标记阶段:

  这时,垃圾回收器会沿着线程栈上行以检查所有根,如果发现一个根引用了一个对象,就在对象 “同步索引块”上开启一位---标记,

  以递归的方式遍历所有可达的对象。如果垃圾回收器试图标记一个先前标记过的对象,就会停止沿这个路径走下去。

    这个行为有两个目的:

      1、垃圾回收器不会多次遍历一个对象,所以性能得到显著增强

      2、如果对象存在循环链表,可以避免无线循环。

   检查完所有的根之后,堆中将包含一组已标记和未标记的对象,已标记的对象是代码可达的对象,而未标记的对象是不可达的,不可达的对象被认为是垃圾,它们占用的内存是可以被回收的

垃圾回收之后的托管堆如下:

2.3、垃圾回收的第二阶段,压缩阶段:

  这个时候该回收内存空间已经都回收了,空出来的内存可能是前头一块,中间一块,后边又一块。

  垃圾回收器线性遍历堆,以寻找未标记对象的连续内存块,如果发现内存块比较小,则忽略,如果发现大的,可用的连续内存块,垃圾回收器会把非垃圾的对象移动到这里以压缩堆。

 

3、代

说到这可能有人不明白了,聊垃圾回收呢怎么说“代”呢,“代”是个什么玩意呢?

其实代(generation)是CLR垃圾回收器采取的一种机制,它唯一的目的就是提升应用程序的性能,一个基于代的垃圾回收做出了一下几个点的假设

  • 对象越新,生存期越短
  • 对象越老,生存期越长
  • 回收堆的一部分,速度比回收整个堆要快

无数的研究证明,对于现今的大多数应用程序,这些假设都是可以成立的。

3.1、托管堆再初始化时不包含任何对象。添加到堆的对象称为第0代对象。简单的说,第0代对象就是那些新构造的对象,垃圾回收器从未检查过它们。

例如,一个新启动的应用程序,它分配了5个对象(从A到E),过了一会,对象B和E变的不可达。

一个新初始化的堆,其中包含了一些对象,所有的对象都是第0代,垃圾回收尚未发生。

CLR初始化时,它会为第0代对象选择一个预算容量,假定为256KB(实际容量可能有所不同),所以,如果分配一个新对象造成第0代超过预算,就必须启动一次垃圾回收。

假设对象A到E刚好占用 了256KB。分配对象F时,垃圾回收器必须启动。垃圾回收器判定对象B和E为垃圾,因此会压缩对象CD,使其与对象A相邻。顺便说一句,之所以第0代的预算容量时256KB,是因为所有这项对象都能装入CUP的L2缓存,使内存的压缩能以非常快的速度完成。垃圾回收中存活的对象(A,C,D)被认为是第1代对象。第1代对象已经经理过一次垃圾回收器的检查,此时的堆情况如下图:

经历过一次垃圾回收之后,第0代的幸存者被提升到第1代,第0代暂时是空的。

此时,应用程序还在继续运行,并新分配了对象F到K。另外,随着应用程序的运行,对象C,H,J变得不可达,它们的内存将再某一时刻回收。

第0代分配了新对象,第1代又垃圾产生。

现在,假定分配新对象L会造成第0代超过其256KB的预算。所以必须启动垃圾回收,开始一次垃圾回收时,垃圾回收器必须决定检查哪些代。前面提到过,当CLR初始化时,它为第0代对象选择了一个预算。同样的,它还必须为第1代选择一个预算,假定第1代选择的预算时2M。

开始一次垃圾回收时,垃圾回收器还会检查第1代占用了多少内存,咱们的例子中,由于第1代占据的内存远少于2MB,所以垃圾回收器只检查第0代中的对象。我们可以回顾一下垃圾回收做出的假设。第一个假设时新创建的对象具有一个短的生存期。因此,第0代可能包含大量的垃圾,所以对第0代进行回收,可以回收比较多的内存空间。垃圾回收器会忽略第1代中的对象,从而加快垃圾回收的速度。

显然,忽略第1代中的对象可能增强垃圾回收器的性能。但是,对性能有更大提振作用的时现在不必遍历托管堆中的每个对象。如果一个根或者一个对象引用了老一代的某个对象,垃圾回收器就可以忽略老对象的所有内部引用,从而能以更快的速度构造好可达对象的图。当然,老对象的字段也有可能引用新对象。为了确对保老对象中的这些已更新的字段进行检查,垃圾回收器利用了JIT编译器内部的一个机制。这个机制再对象的引用字段发生变化时,会设置一个对象的位标志。这样一来,垃圾回收器就知道自从上一次垃圾回收以来,哪些老对象已被写入。只有字段发生变化的老对象才需检查是否引用了第0代中的任何新对象。

基于代的垃圾回收器还假设活的比较久的对象能继续活下去。也就是说,第1代对象在应用程序中很可能是继续可达的。如果垃圾回收器检查第1代中的对象,很可能找不到多少垃圾,结果是回收不了多少内存。因此,对第1代进行垃圾回收和可能是浪费时间。

如果真的有垃圾在第1代中,它将呆在那里。此时的堆如下图:

经历过两次垃圾回收之后,第0代的幸存者被提升到第1代(第1代的大小增加),第0代又空出来了。

如图所见,所有幸存下来的第0代对象变成了第1代的一部分。由于垃圾回收器没有检查第1代,所以对象C的内存并没有被回收,即使它在上一次垃圾回收时已经不可达。同样,在一次垃圾回收后,第0代不包含任何对象,等着分配新对象。假定应用程序继续运行,并分配对象L到对象O。另外,在运行过程中,应用程序停止使用对象G,L,M。使它们变得不可达。此时的托管堆如图:

新对象分配到第0代中,第1代产生了更多的垃圾

假设分配对象P导致第0代超过预算,垃圾回收发生。由于第1代中的所有对象占据的内存仍小于2MB,所以垃圾回收器再次决定之回收第0代,忽略第1代中的不可达对象(对象C和G)。回收后,堆的情况如图:

经历过三次垃圾回收,第0代的幸存者被提升到第1代(第一代的大小再次增加),第0代空出来

从上图我们可以看到,第1代正在缓慢的增长。假定第1代的增长导致它的所有对象占用了2MB内存。这时,应用程序继续运行(因为垃圾回收刚刚完成)。并分配对象P到对象S,使第0代对象达到它的预算容量。这时的堆情况如下图:

新对象分配到第0代,第1代中有了更多的垃圾

当应用程序试图分配对象T时,由于第0代已满,所以必须开始垃圾回收。但是,这一次垃圾回收器发现第1代占据了太多的内存,以至于达到了第1代的2MB预算。由于前几次对第0代进行回收时,第1代中可能已经又许多对象变得不可达,所以这一次垃圾回收器决定检查第1代和第0代中的所有对象。两代都被垃圾回收后,堆的情况如下图所示:

经历过4次垃圾回收之后,第1代的幸存者提升到第2代,第0代的幸存者提升到第1代,第0代空出来了。

像之前一样,垃圾回收后,第0代的幸存者被提升到第1代,第1代的幸存者被提升到第2代。第0代再次空出来。准备好迎接新对象的到来。第2代中的对象经历过2次或更多次检查。虽然到目前为止已经发生过多次垃圾回收,但只有在第1代达到它的预算时,才会检查第1代中的对象。而在此之前,一般都已经对第0代进行好几次垃圾回收了。

CLR的托管堆只支持三代:第0代,第1代,第2代。没有第3代。CLR初始化的时候,会为每一代选择预算。如前所述,第0代的预算约为256KB,第1代约2MB,第2代约为10MB。同样的,预算的大小以提升性能为宜。预算越大,垃圾回收的频率越低。再次提醒各位注意,性能的提升源于最开始的假设:新对象的生存期较短,老对象的生存期更长

CLR的垃圾回收器时自调节的。这意味着垃圾回收器会在执行垃圾回收的过程中了解应用程序的行为。

例如,假定应用程序构造了许多对象,但每个对象用的时间都很短。在这种情况下,对第0代的垃圾回收会回收大量的内存。事实上,第0代的所有对象都可能被回收。如果垃圾回收器发现在回收0代后存活下来的对象很少,就可能决定将第0代的预算从256KB减少到128KB。已分配空间的减少意味着垃圾回收将会更频繁的发生,但垃圾回收器需要做的工作会减少,从而减少进程的工作集。事实上,如果第0代中的所有对象都是垃圾,垃圾回收时就不必压缩任何内存,只需要让NextObjPrt指针指回到第0代的起始处即可。这样回收就太快了。

另一方面,如果垃圾回收器回收第0代,发现还有很多对象存活,没有多少内存被回收,就会增大第0代的预算,比如增大到512MB.现在,垃圾回收的次数将减少,但每次进行垃圾回收时,回收的内存要多的多。顺便说一句,如果没有回收到足够的内存,垃圾回收器回执行一次完整的回收。如果还是不够,就会抛出OutOfMemoryException异常。

以上部分,我们只讨论了垃圾回收后如何动态调整第0代的预算,同样的实现原理,第1代,第2代也是类似的实现算法,基于垃圾回收时,垃圾回收器回检查又多少内存被回收,以及又多少对象幸存。基于这些结果,垃圾回收器可能增大或减少这些代的预算,从而提升应用程序的总体性能。

最终的结果就是,垃圾回收器回根据应用程序要求的内存负载来自动优化-----是不是很聪明,很Cool 。

 

 

 

 

参考:CLR Via C#(第三版)

  

posted @ 2020-04-15 15:35  闪存第一莽撞人  阅读(628)  评论(0编辑  收藏  举报