.NET-垃圾回收原理
一、关键词
托管堆,内存泄漏,垃圾回收,托管对象,引用类型,引用跟踪算法,根,代
二、导言
本文主要介绍垃圾回收的原理和相关概念,帮助.NET开发人员了解垃圾回收的回收机制。通常,垃圾回收的机制对于开发人员是透明的。但是,在一些内存泄漏或高性能开发的场景下,了解垃圾回收的原理可以帮助开发人员更好的排查问题和设计高性能的应用程序。
垃圾回收(GC)是.NET运行时的核心功能。GC帮助开发人员管理对象实例的生命周期,使得应用程序的开发更加便捷。
在本篇文章中,我们将从垃圾回收的发展历史讲起,介绍几个关键概念,然后重点讲述垃圾回收的概念、算法、过程,以及核心问题。
三、正文
1.垃圾回收的历史
在应用程序开发早期,开发人员需手动管理内存(比如C++开发人员),在这一过程中,开发人员很容易忘记释放不需要的内存而导致内存泄漏;同时,还可能试图访问已释放的内存,造成程序错误或安全漏洞。在这样的开发模式中,开发人员几乎很难预测问题发生的时间和后果。而且,这样的问题通常要比其他bug要严重得多。可能是应用程序内存溢出崩溃,也可能访问被破坏的内存导致非预期的严重后果。
因此引入一种机制来管理不再使用的对象,让开发人员可以不再花费精力管理内存的分配和释放,专注于核心功能的编码,变得尤为重要。
作为现代化的软件开发技术,.NET引入了垃圾回收器(GC),统一由CLR管理托管对象的内存。从此,开发人员可以编写可验证的,类型安全的代码。
2.托管对象
.NET中托管对象是指存储在托管堆中的引用类型实例。通常,这些对象通过new操作符从托管堆分配内存,初始化,在不再使用时,由GC自动回收内存,从而减小内存工作集。对于值类型实例进行装箱,也是通过托管堆创建了引用类型的对象进行使用,这时,有两个独立的实例,值类型实例存储在线程栈,装箱得到的引用类型实例存储在堆中。
CLR中相对于托管对象,即引用类型,只有值类型,但在CLR之外,还有非托管对象。非托管对象是本机资源,由CLR通过平台调用,调用对应的系统函数创建(一般通过句柄管理)。非托管对象的内存仍需要手动管理,一般,我们通过Dispose模式进行释放。
3.托管堆
托管堆是进程启动时,CLR向操作系统虚拟内存空间申请(例如Windows中,通过本机VirtualAlloc函数申请内存)的一片内存区域。用于管理托管对象的内存分配和回收。是垃圾回收所管理的内存区域。
我们常把线程栈对应托管堆。对于构造的实例,线程栈存储了作为局部变量或参数的值类型,托管堆存储了作为静态变量,实例,局部变量或参数的引用类型。
线程栈也是内存区域,在每个线程创建时,操作系统申请了内存,通常为1MB左右。线程栈通常包含局部变量和函数调用和返回信息。
4.托管内存分配
CLR对象从托管堆中分配。首先CLR维护一个指针NextObjPtr,用于保存下一个对象的可用分配位置。
为new操作符创建对象时,首先,计算类型的字段开销,加上额外开销(类型对象指针和同步块索引)。
然后,在GC第0代堆中,在NextObjPtr指向的位置分配相应的空间,调用类型构造器,再将对象引用返回。
最后将NextObjPtr加上为对象分配的字节数,使其指向下个可用空间。
这一过程中,连续分配的对象在内存空间中连续保存,这些对象通常有很强联系,经常在同一时间访问。访问这些对象时,命中CPU缓存的概率大大提高,不必访问内存,从而提高访问性能。
5.垃圾回收算法
早期,人们通过引用计数算法(也称为标记清除算法)来实现垃圾回收机制。引用计数算法保存对新建对象的计数,在对象不再使用时,对计数递减,当发现计数为0时,说明该对象已经不再使用,从而回收内存。引用计数有缺点,例如,对于窗体应用,父窗体保存了对子窗体的引用,子窗体也保存了父窗体的引用。这样的循环引用,将导致父窗体和子窗体不再使用时,其引用计数永远不为0。
GC引入了引用跟踪算法解决了上述循环引用的问题。
引用跟踪算法将所用引用类型变量称为根。在GC触发一次垃圾回收时,首先暂停全部线程(GC保留了优先级为Time-Critical的线程,该线程优先级大于CLR中其他所有线程,可确保线程优先执行);然后进入标记阶段在对象的同步块索引中,将一个特定位置标记为0(0表明对象应该删除),然后CLR遍历所有的活动根,如果根非null,就将标记位置为1,表明该对象还在使用。标记为1的对象,继续检查其成员字段,若某对象已经标记了,不再重新检查。这一过程可以避免循环引用。
在标记完成后,这时知道了哪些对象可用,哪些对象删除,这时进入压缩阶段。
首先,压缩幸存下来的对象,这将减小内存的活动工作集,提升内存使用率,即解决空间碎片化问题。
然后,压缩完成后,现有的活动根减去引用的对象压缩后偏移的字节数,保证引用到正确位置。
最后,将NextObjPtr指向下一个可用位置。

图5.1 垃圾回收过程
6.内存泄漏
即使GC采用了引用跟踪算法。CLR仍有一些内存泄漏的问题,常见的包括静态字段的使用;向集合中添加对象,对象不使用时,却不删除。
内存泄漏可以通过工具来跟踪,官方建议的工具是dotnet-counters和dotnet-dump。首先,通过dotnet-counters实时监控GC内存,判断是否发生了内存泄漏;然后,通过dotnet-dump获取CLR快照,再通过分析不同时刻的托管堆,判断发生了内存泄漏的区域。
7.代和大对象
GC是分代回收器,将管理的内存区域分为0代、1代,2代。为了性能,GC将大对象(大于85000Byte的CLR对象)单独存储在大对象堆(LOH)中,有时将其视为第3代。
存储在LOH中的大对象通常是不可压缩的(可通过配置强制一次压缩,建议不了解内部机制前不轻易使用)。对于确实不想要压缩的对象,可以将其存储在POH(固定对象堆)中,在C#中,可以通过fixed关键字固定对象。
GC分配空间时,都是由第0代分配,这时会出现3种情况:
第一,第0代有足够空间,这时能够正常分配空间给相应的对象;
第二,第0代没有足够空间,这时向进程申请更多活动工作集来使用,然后再分配空间给对象;
第三,第0代没有足够空间,进程内存已耗尽,这时将抛出OutOfMemoryException。
在GC对第0代执行完垃圾回收后,还存活的内存变为第1代;对第1代执行完垃圾回收后,还存活的内存变为第2代。这3代的内存空间是连续的,首先是第2代,再是第1代,最后是第0代(参考图1),这样保证内存空间的连续性和压缩后,代数的转换。
在每一代可用内存达到阈值时,进行垃圾回收,回收时,不仅对该代进行回收,还要对小于它的代进行回收。例如,对第2代的回收,也会包含第1代和第0代的回收。所以,本质上对第2代的回收也是对整个托管堆的回收。
GC在回收过程中,会根据空间的使用情况,动态调整托管堆各个代的大小,申请或释放内存(Windows中通过VirtualAlloc和VirtualFree函数),提高内存的使用率。
8.终结器和非托管对象回收
CLR中使用的对象,仍有部分不能通过垃圾回收机制自动回收,仍需要手动管理(即前面提到的本机非托管对象)。对于含有非托管对象的类,微软官方通过Dispose模式,来统一管理托管对象和非托管对象的。可以从FileStream,NetworkStream等使用了非托管对象的类源码中,查看Dispose方法和终结器,参考Dispose模式的实现方式。
参考:
[1]Jeffrey Richer:CLR vir C#(第四版)
[2]Windows 系统上的大型对象堆:https://learn.microsoft.com/zh-cn/dotnet/standard/garbage-collection/large-object-heap

浙公网安备 33010602011771号