C#.NET托管堆和垃圾回收

# dotnet托管堆和垃圾回收

# 托管堆基础

简述:每个程序都要使用这样或那样的资源,包括文件、内存缓冲区、屏幕空间、网络连接…..事实上,在面向对象的环境中,每个类型都代表可供程序使用的一种资源。要使用这些资源,必须为代表资源的类型分配内存。 以下是访问一个资源所需步骤:

  1. 调用IL指令newobj,为代表资源的类型分配内存。(C# new操作符)
  2. 初始化内存,设置资源的初始状态。(一般指构造函数)
  3. 访问类型的成员来使用资源。(使用成员变量、方法、属性等)
  4. 摧毁资源的状态以进行清除。(Dispose???)
  5. 释放内存。(GC)

# 从托管堆分配资源

CLR要求所有的对象都从托管堆分配。 进程初始化,CLR划出一个地址空间区域作为托管堆。CLR还要维护一个指针,姑且叫NextObjPtr,该指针指向下一个对象在堆中的分配位置。刚开始的时候, NextObjPtr 设为地址空间区域的基地址。 一个区域被非垃圾对象填满后,CLR会分配更多的区域。

这一个过程一直重复,直至整个进程地址空间被填满。所以,应用程序内存收进程的虚拟地址空间的限制。

32位进程最多能分配1.5GB,64位进程最多能分配8T。 注:进程内存大小的相关资料

Memory Support and Windows Operating Systems

进程地址空间

32位模式下C/C++程序可用最大内存

# C# 的new操作符导致CLR执行以下操作:

  1. 计算类型的字段(以及从基类型继承的字段)所需要的字节数。

  2. 加上对象的开销所需的字节数。每个对象都有两个开销字段:类型对象指针和同步块索引。对于32位应用程序,这两个字段各需要32位,所以每个对象需要增加8字节。对于64位应用程序,这两个字段各需要64位,所以每个对象要增加16字节。

  3. CLR检查区域中是否有分配对象所需的字节数。如果托管堆有足够的可用空间,就在NetxObjPtr指针指向的地址处放入对象,为对象分配的字节会被清零。接着调用类型的构造器(为this参数传递NextObjPtr),new操作符返回对象引用。就在返回这个对象引用之前,NextObjPtr指针的值会加上这个对象占用的字节数来得到一个新值,即下个对象放入托管堆时的地址。如下图:

tup

# 垃圾回收算法

####CLR使用引用跟踪算法。

引用跟踪算法只关心引用类型的变量,因为只有这种变量才能引用堆上面的对象; 值类型变量直接包含值类型实例。引用类型变量可在许多场合使用,包括类的静态和实例字段,或者方法的参数和局部变量。这里我们将所有引用类型的变量都称为根。 CLR开始GC时,首先暂停所有的线程。(这样可以防止线程在CLR检查期间访问对象并更改其状态。) 然后CLR进入GC标记阶段。在这个阶段,CLR遍历堆中的所有对象,将同步块索引字段中的一位设为0。这表明所有的对象都应删除。然后,CLR检查所有的活动根,查看他们引用了哪些对象。这正是CLR的GC被称作引用跟踪GC的原因。如果一个根包含null,CLR忽略这个根并继续检查下一个根。 下图展示一个堆,其中包含几个对象。 图片1

应用程序的根直接引用对象A 、C、D 、F。所有的对象都已经被标记。标记对象D时,GC发现这个对象含有一个引用对象H的字段,造成对象H也被标记。标记过程会持续,直至应用程序的所有根所有检查完毕。 检查完毕后,堆中的对象要么已标记,要么未标记。已标记的对象不能被垃圾回收,因为至少有一个根在引用它。我们说这种对象是可达的,因为应用程序可以通过引用它的变量抵达它。 未标记的对象是不可达的,因为应用程序中不存在使对象能被再次访问的根。

CLR知道哪些对象可以幸存,哪些可以被删除后,进入GC的压缩(类似于碎片整理)阶段。在压缩阶段,CLR对堆中已标记的对象进行“乾坤大挪移”,整理所有幸存下来的对象,使他们占用连续的内存。

这样做的好处在于:

  1. 所有幸存对象在内存中紧挨在一起,恢复了引用的“局部性”,减少了应用程序的工作集,从而提升了将来访问这些对象时的性能;

  2. 经过整理后,可用空间也是连续的,整个地址空间区段得到了解放,允许其他东西进驻。

在内存中移动了对象之后有一个问题亟待解决。引用幸存对象的根现在引用的还是对象最初在内存中的位置,而非移动后的位置。被暂停的线程恢复执行时,将访问旧的内存位置,会造成内存损坏。 这显然是不能容忍的,所以作为压缩阶段的一部分,CLR还要从每个根减去所引用对象在内存中偏移的字节数。这样就能保证每个根还是引用和之前一样的对象,只是对象在内存中变换了位置。 如图: 123

# 代:提升性能 (待续)

CLR的GC是基于代的垃圾回收器,它对你的代码做出了以下几点假设:

  1. 对象越新,生存周期越短。

  2. 对象越老,生存周期越长。

  3. 回收堆的一部分 ,速度快于回收整个堆。

大量研究表明,这些假设对于现今大多数的应用程序都是成立的,它们影响了垃圾回收器的实现方式。这里将解释代的工作原理。

托管堆在初始化时不包括对象。添加到堆的对象成为第0代对象。简单来说,第0代对象就是那些新构造的对象,垃圾回收器从未检查过它们。如下图,新启动的应用程序,分配了5个对象(从A到E)。过了一会,C和E变得不可达了。

23

CLR初始化第0代对象选择一个预算容量。如果分配一个新对象造成第0代超预算,就必须启动一次GC。假设对象A到E刚好用完了第0代的空间,那么分配对象F就必须启动GC。GC之后存活的对象现场成为第1代对象。如下图:

123 一次GC之后,第0代就不包含任何对象。和前面一样,新对象会分配到第0代。新分配对象F到对象K都到了第0代。 234

之后,程序继续运行,B、H、J变得不可达,它们的内存将在某一个时刻回收。

假设现在新分配对象L会造成第0代超出预算,造成必须启动垃圾回收。

开始垃圾回收时,垃圾回收器必须决定检查哪些代。前面说过,CLR初始化时会为第0代对象选择预算.事实上,它还必须为第1代选择预算.

开始一次垃圾回收时,垃圾回收器还会检查第一代占用了多少内存。在本例中,由于第1代占用内存远少于预算,所以垃圾回收器只检查第0代对象。回顾之前基于代的垃圾回收器做出的第一个假设:对象越新,生存期越短。 因此,第0代包含更多的垃圾的可能性更大,能回收更多的内存。由于忽略第1代中的对象,所以加快了垃圾回收速度。

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

基于代的垃圾回收器还假设越老的对象活得越长。也就是说,第1代对象在应用程序中有可能是继续可达的。如果垃圾回收器检查第1代的对象,很有可能找不到多少垃圾,结果是也回收不了多少内存。因此,对第1代进行垃圾回收很可能是浪费时间的。如果第一代真有垃圾,垃圾将留在那里。如下图: 2345

程序继续运行,继续往第0代分配对象,同时程序停止对第1代某对象的使用。

如下图: edf 分配对象P导致第0代超预算,开始GC。第1代的所有对象占据内存仍小于预算,垃圾回收器再次决定只回收第0代。忽略第1代中的垃圾对象。如下图: 2345

程序继续运行,假设第一代的增长导致它的全部对象占用了全部预算。这时候应用程序分配对象P到对象S,使第0代对象达到它的预算总和。如下图: 43

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

托管堆只支持三代:第0代、第1代和第2代。

CLR初始化时,会为每一代选择预算。

然而,CLR的垃圾回收是自调节的。

这意味着垃圾回收器会在执行垃圾回收的过程了解程序的行为。

例如:假设应用程序构造了许多对象,但每个对象的时间都很短。 在这种情况下,对第0代的垃圾回收会回收到大量的内存。事实上,第0代的所有对象都可能被回收。

如果垃圾回收器发现在回收第0代后存活下来的对象很少,就可能减少第0代的预算。已分配空间的减少意味着垃圾回收将更频繁地发生,但垃圾回收器每次做的事情也减少,这减少了进程的工作集。

另一方面,如果垃圾回收器回收了第0代,发现还有很多对象存活,没多少内存可以被回收,就会增大第0代的预算。

同样的启发性算法调整预算适用于了第1代和第2代的预算。

引自:《CLR VIA C# -21章》

自动内存管理

垃圾回收的基础

代数

使用 Hugo 构建
主题 StackJimmy 设计