DotNet基础-GC与内存管理

内存管理

GC的内存管理的目标主要都是引用类型对象。

对象创建及生命周期

一个对象的生命周期简单概括就是:创建 > 使用 > 释放,在.NET中一个对象的生命周期:

  • new创建对象并分配内存
  • 对象初始化
  • 对象操作、使用
  • 资源清理(非托管资源)
  • GC垃圾回收

大部分的对象创建都是开始于关键字 new,有个别引用类型是由专门 IL 指令的,比如 string 有 ldstr 指令。

引用对象都是分配在托管堆上的, 先来看看托管堆的基本结构,如下图,托管堆中的对象是顺序存放的,托管堆维护着一个指针 NextObjPtr,它指向下一个对象在堆中的分配位置。

82a2b4be6318aebc0dc9b7b29c2a7599.png

创建一个新对象的主要流程:

151257-20160309235623241-1001221060.png

模拟一个对象的创建过程:

1
2
3
4
5
6
7
8
public class User
{
public int Age { get; set; }
public string Name { get; set; }

public string _Name = "123" + "abc";
public List<string> _Names;
}
  • 1,对象大小估算,共计44个字节:
    (1) 属性 Age 值类型 Int,4字节;
    (2) 属性 Name,引用类型,初始为 NULL,4个字节,指向空地址;
    (3) 字段 _Name 初始赋值了,代码会被编译器优化为 _Name=”123abc” 。一个字符两个字节,字符串占用 2×6+8(附加成员:4字节 TypeHandle 地址,4字节 同步索引块 )= 20 字节,总共 内存大小 = 字符串对象20字节 + _Name 指向字符串的内存地址4字节=24字节;
    (4) 引用类型字段 List _Names 初始默认为 NULL,4个字节;
    (5) User对象的初始附加成员(4字节 TypeHandle 地址,4字节同步索引块)8个字节;
  • 2,内存申请: 申请 44 个字节的内存块,从指针 NextObjPtr 开始验证,空间是否足够,若不够则触发垃圾回收。
  • 3,内存分配: 从指针 NextObjPtr 处开始划分 44 个字节内存块。
  • 4,对象初始化: 首先初始化对象附加成员,再调用User对象的构造函数,对成员初始化,值类型默认初始为0,引用类型默认初始化为 NULL;
  • 5,托管堆指针后移: 指针 NextObjPtr 后移44个字节。
  • 6,返回内存地址: 返回对象的内存地址给引用变量。

GC垃圾回收

GC是垃圾回收( Garbage Collect)的缩写,是 .NET 核心机制的重要部分。她的基本工作原理就是遍历托管堆中的对象,标记哪些被使用对象(那些没人使用的就是所谓的垃圾),然后把可达对象转移到一个连续的地址空间(也叫压缩),其余的所有没用的对象内存被回收掉。

151257-20160309235624382-1396676769.png

垃圾回收基本流程

标记阶段

先假设所有对象都是垃圾,根据应用程序根指针 Root 遍历堆上的每一个引用对象,生成可达对象图,对于还在使用的对象(可达对象)进行标记(其实就是在对象同步索引块中开启一个标示位)。

其中 Root 根指针保存了当前所有需要使用的对象引用,他其实只是一个统称,意思就是这些对象当前还在使用,主要包含:静态对象/静态字段的引用;线程栈引用(局部变量、方法参数、栈帧);任何引用对象的 CPU 寄存器;根引用对象中引用的对象;GC Handle table;Freachable 队列等。

对性能的影响

标记阶段是一个 “几乎仅有只读操作” 的阶段。这个阶段中没有任何对象被移动,也没有任何内存被回收。

  • 1,当进行一次完整的标记时,垃圾回收器几乎遍历了每一个被引用的对象。若这部分数据并不存在于程序工作区(working set)中就会造成页面错误,从而导致重新加载对象时缓存丢失(cache miss)与缓存抖动(cache thrashing)。
  • 2,在一个多处理器系统中,当垃圾回收器在对象的头部进行位标记操作时,若相应的对象已被加载至其他处理器的缓存中,则会造成缓存失效。
  • 3,标记阶段的性能取决于引用图中对象的数目,但是和对象占用的内存大小并无关系。

垃圾回收器遍历对象图中所有被应用程序引用的对象。为了正确的遍历图中的对象,需要选定一系列起点保证引用对象的遍历,这些起点称为

1.局部根

局部变量是一种最显而易见的根。

2.静态根

另一种类型的根是 静态变量。静态成员类型在类型加载时被创建,并且可以在整个应用程序域( application domain )的生命周期内作为潜藏根。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Program
{
static event EventHandler ButtonClick;
static void Main(string[] args)
{
while (true)
{
Button button = new Button();
ButtonClick += button.OnClick;
}
}
}

class Button
{
public void OnClick(object sender, EventArgs e) { }
}

上面代码会造成内存泄漏。因为静态事件包含一个委托列表,而这个委托列表引用了我们创建的对象。
事实上,.NET 内存泄漏的最普遍原因是静态变量引用了对象。

可以使用 SOS.DLL 查看根,!gcroot 命令提供跟得类型和引用链的简明信息。

清理阶段与压缩阶段

清理

针对所有不可达对象进行清除操作,针对普通对象直接回收内存,而对于实现了终结器的对象(实现了析构函数的对象)需要单独回收处理。清除之后,内存就会变得不连续了,这时就需要压缩阶段了。

建议不要随意手动调用垃圾回收 GC.Collect(),GC 会选择合适的时机、合适的方式进行内存回收的。

压缩

把剩下的对象转移到一个连续的内存,因为这些对象地址变了,还需要把那些 Root 根指针的地址修改为移动后的新地址。

net-mem-02-mark-compact.png

性能影响

  • 1,移动对象意味着内存复制,这对于内存占用多的对象来说是昂贵的开销。
  • 2,对象被移动之后,所有的引用的值必须更新其地址。对于频繁引用的对象来说,这种分散的内存操作势必造成性能影响。

固定

该场景涉及到托管对象传递给非托管代码。

垃圾回收器的特性

垃圾回收器是如何和其他应用程序线程(通常称为赋值线程,mutator thread)进行交互的。

垃圾回收时暂停线程

垃圾回收器和其他应用程序线程并发执行可以产生的问题:

  • 假阴性(false negative):一个对象满足垃圾回收的条件,但被标记为活动的。
  • 假阳性(false positive):一个对象被认为是可回收对象,但它依然被应用程序所引用。尽量避免该情况发生。

在垃圾回收时挂起线程

垃圾回收时会在安全点(safe point)出挂起线程,JIT 编译器通过生成额外的信息确保只有安全的时候才挂起线程进行垃圾回收。而 CLR 也会尝试安全的挂起线程。

非托管线程并不会由于托管线程的挂起而受到影响,除非它已经切换回了托管线程,这一过程有平台调用转换器负责的。

在标记阶段挂起线程

出现假阳性
如果垃圾回收器在对象创建之前,已经完成了相关部分引用图更新,那么一个刚刚被创建的对象,即使已经被应用程序引用,也可能判断为未引用。

出现假阴性
对于一个已经被标记的对象,如果它的最后一个引用在标记阶段被移除,那么这个本该被回收的对象就会继续存活。
如果对象真的不可达,那就不可能重新变成可达状态,它将在下一轮垃圾回收周期中被回收。

在清理阶段挂起线程

CLR 禁止应用程序和垃圾回收过程并发执行。

工作站垃圾回收

工作站垃圾回收(workstation GC),分为并发工作站垃圾回收非并发工作站垃圾回收

并发工作站垃圾回收

并发工作站垃圾回收 是默认的特征。在并发工作站垃圾回收下,有一个独立的专门的垃圾回收线程。该线程在执行垃圾回收的过程时,始终具有 THRAD_PRIORITY_HIGHEST 优先级。CRL 可以决定是否允许某些垃圾回收阶段与应用程序线程并发执行。
图形界面应用程序应该尽量避免从 UI 线程触发垃圾回收,即在后台线程进行资源分配,且不要显示的从 UI 线程调用 GC.Collect 方法。因为UI线程被垃圾回收阻塞的同时,其它应用程序线程却在和垃圾回收过程争抢资源。

非并发工作站垃圾回收

非并发工作站垃圾回收特征在标记和清理阶段均会挂起应用程序线程。适用于从 UI 线程触发垃圾回收的情形。
由于在 UI 线程等待垃圾回收时,其它的后台线程不会和垃圾回收器争夺资源,一次 UI 线程可以即可恢复响应。

服务器垃圾回收

服务器垃圾回收专门针对服务器应用程序进行了优化。

使用服务器垃圾回收特征的唯一限制是物理处理器的数目。如果仅有一个处理器,则只能够选择工作站垃圾回收特征。

由于并发工作站垃圾回收是默认的垃圾回收特征,一般运行于默认的 CLR 宿主下的命令行,windonws 应用程序,windows 服务都使用默认的垃圾回收特征;不在默认的 CLR 宿主运行的应用程序则可以选择其他的垃圾回收特征。由于 IIS 大多安装在服务器上,在 IIS ASP.NET 宿主下运行的应用程序使用的就是服务器垃圾回收特征(可以通过 web.config 修改这个定义)

切换垃圾回收特征

在默认的宿主下,可以通过应用程序配置文件( app.config )修改垃圾回收特征及其子特征。

1
2
3
4
5
6
7
8
<?xml version="1.0" encoding="utf-8"?>

<configuration>
<runtime>
<gcServer enabled="true"/>
<gcConcurrent enabled="false"/>
</runtime>
</configuration>

安全的使用低延迟垃圾回收

安全的使用低延迟垃圾回收的唯一途径是将其放在受限执行区域(constrained execution region,CER)内。

需要引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using System.Runtime;
using System.Runtime.CompilerServices;

GCLatencyMode oldModel = GCSettings.LatencyMode;
RuntimeHelpers.PrepareConstrainedRegions();

try
{
GCSettings.LatencyMode = GCLatencyMode.LowLatency;
// Perform time-sensitive work here (执行时间敏感操作)
}finally
{
GCSettings.LatencyMode = oldModel;
}

代龄(Generation)

.NET 垃圾回收器的“代”模型(generational model)使用局部垃圾回收进行性能优化。

和真实世界的人和动物不同,.NET 认为年轻的对象更容易死亡,而年老的对象更倾向于存货。
年轻和年老的定义取决于应用程序垃圾回收的频率。在大多数应用程序中,临时对象(在一个方法张中分配的对象)大多是年轻的,而随着应用程序初始化的对象大多是年老的。

分代(Generation)算法 是 CLR 垃圾回收器采用的一种机制,它唯一的目的就是提升应用程序的性能。分代回收,速度显然快于回收整个堆。分代( Generation )算法的假设前提条件:

  • 1、大量新创建的对象生命周期都比较短,而较老的对象生命周期会更长
  • 2、对部分内存进行回收比基于全部内存的回收操作要快
  • 3、新创建的对象之间关联程度通常较强。heap 分配的对象是连续的,关联度较强有利于提高 CPU cache 的命中率

在 “代”模型 中,托管堆被划分为三个区域:第0代,第1代和第2代。

  • 第0代,最新分配在堆上的对象,从来没有被垃圾收集过。任何一个新对象,当它第一次被分配在托管堆上时,就是第0代(大于85000的大对象除外)。
  • 第1代,0代满了会触发0代的垃圾回收,0代垃圾回收后,剩下的对象会搬到1代。
  • 第2代,当0代、1代满了,会触发0代、1代的垃圾回收,第0代升为第1代,第1代升为第2代。

net-mem-06-generation.png

大部分情况,GC 只需要回收0代即可,这样可以显著提高 GC 的效率,而且 GC 使用启发式内存优化算法,自动优化内存负载,自动调整各代的内存大小。

如果 Gen 0 heap 内存达到阀值,则触发0代GC,0代GC后Gen 0中幸存的对象进入Gen1。如果Gen 1的内存达到阀值,则进行1代GC,1代GC将Gen 0 heap和Gen 1 heap一起进行回收,幸存的对象进入Gen2。

  2代 GC 将Gen 0 heap、Gen 1 heap和Gen 2 heap一起回收,Gen 0和Gen 1比较小,这两个代龄加起来总是保持在16M左右;Gen2的大小由应用程序确定,可能达到几G,因此0代和1代GC的成本非常低,2代GC称为full GC,通常成本很高。粗略的计算0代和1代GC应当能在几毫秒到几十毫秒之间完成,Gen 2 heap比较大时,full GC可能需要花费几秒时间。大致上来讲 .NET 应用运行期间,2代、1代和0代GC的频率应当大致为1:10:100。

第0代

第0代通常以 256KB-4MB 起步,根据使用情况缓慢增长。当第0代已满而不能完成一个新的内存分配请求时,第0代就会发起一次垃圾回收。
由于第0代的大小受高速缓存大小的影响,因此很可能在高速缓存中就可以找到第0代的所有对象。第0代的所有对象几乎都在一次垃圾回收结束之后清理殆尽。
但是,仍然有一些对象会出于各种原因得以存活。

  • 应用程序具有糟糕的行为,分配的临时对象在一次垃圾回收之后仍然存活。
  • 应用程序处于初始化阶段,这个阶段分配的对象多是长生命周期对象。
  • 应用程序创建的是短期临时对象,但是在垃圾回收触发时这些对象恰巧正在被使用。

这些撑过一次垃圾回收的对象不会被排列在第0代的起始位置。它们会被提升到第1代。

跨代移动固定对象

垃圾回收器无法移动固定的对象。但是 CLR 用一种非常巧妙的方法完成了固定对象的代升级:如果第0代由于固定对象的原因严重碎片化了,则 CLR 可以将整个第0代声明为更高代,并将一块新的内存声明为第0代,在这块内存中处理新的分配请求。

第1代

第1代是第0代和第2代之间的缓冲区域。第1代典型的初始长度为 512KB-4MB。当第1代被填满时,会在第1代触发垃圾回收。只有第1代中的对象会被垃圾回收器标记和清除。自然触发第1代垃圾回收的唯一时机是在第0代垃圾回收后,存活的对象被提升至第1级时(另一种是手动触发垃圾回收)。

第2代

第2代是在至少两次垃圾回收过程后存活对象的终极区域(还有大对象),在“代”模型中,这些对象属于老年对象,这些对象不太可能在短期内回收。第2代区域不会人为的进行大小限制,它可以扩展到整个系统进程的专用内存空间,在32位系统中为2GB,在64位系统中最多为8TB。第2代中设定了动态阈值(水印)以触发垃圾回收操作。

若垃圾回收发生在第二代,则这是一个完全的垃圾回收。这是昂贵的,需要消耗最多的时间。

大对象堆

大对象堆(large object heap,LOH)是一个专门容纳大对象的特殊区域。指内存占用大于85KB(85000字节)的对象。这个区域的主要特点就是:不会轻易被回收;就是回收了也不会被压缩(因为对象太大,移动复制的成本太高了)。

这个大小指对象本身的大小而非以该对象为根的整个对象树的大小。因此,包含1000个字符串(每个字符串含有100个字符)的数组不是一个大对象,但是一个长度为50000的整数数组是一个大对象。

大对象从 LOH 中直接分配而不会放在第0代,第1代或者第2代中。

垃圾回收在 LOH 上进行回收时,并不会清理大对象并进行数据复制。LOH 维护了一个未使用内存的链表,新的内存分配请求可以从链表中进行。

当第2代对象占用的内存达到阈值时,则 LOH 就会进行垃圾回收。类似的,若 LOH 占用的内存达到阈值,也会触发第2代垃圾回收。因此,创建太多大型的临时对象也会造成类似 “中年危机” –必须进行完全回收以释放这些对象。LOH 中的碎片是另一个潜在问题,因为 LOH 中对象之间的空洞无法在清理阶段被移除并达到对堆进行碎片整理的效果。一个有效的策略是缓存并重用大对象,或者是(如果数组中对象的类型是一致的)分配一个大对象,然后在需要时手动将其分成小块。

GC.Collect() 方法

作用:强制进行垃圾回收。

Collect():强制对所有代进行即时垃圾回收。

Collect(Int32):强制对零代到指定代进行即时垃圾回收。

Collect(Int32, GCCollectionMode):强制在 GCCollectionMode 值所指定的时间对零代到指定代进行垃圾回收

GC注意事项:

  • 1、只管理内存,非托管资源,如文件句柄,GDI 资源,数据库连接等还需要用户去管理。
  • 2、循环引用,网状结构等的实现会变得简单。GC 的标志-压缩算法能有效的检测这些关系,并将不再被引用的网状结构整体删除。
  • 3、GC 通过从程序的根对象开始遍历来检测一个对象是否可被其他对象访问,而不是用类似于 COM 中的引用计数方法。
  • 4、GC 在一个独立的线程中运行来删除不再被引用的内存。
  • 5、GC 每次运行时会压缩托管堆。
  • 6、你必须对非托管资源的释放负责。可以通过在类型中定义Finalizer来保证资源得到释放。
  • 7、对象的 Finalizer被执行的时间是在对象不再被引用后的某个不确定的时间。注意并非和C++中一样在对象超出声明周期时立即执行析构函数
  • 8、Finalizer的使用有性能上的代价。需要Finalization的对象不会立即被清除,而需要先执行Finalizer.Finalizer,不是在GC执行的线程被调用。GC把每一个需要执行Finalizer的对象放到一个队列中去,然后启动另一个线程来执行所有这些Finalizer,而GC线程继续去删除其他待回收的对象。在下一个GC周期,这些执行完Finalizer的对象的内存才会被回收。
  • 9、.NET GC使用”代”(generations)的概念来优化性能。代帮助GC更迅速的识别那些最可能成为垃圾的对象。在上次执行完垃圾回收后新创建的对象为第0代对象。经历了一次GC周期的对象为第1代对象。经历了两次或更多的GC周期的对象为第2代对象。代的作用是为了区分局部变量和需要在应用程序生存周期中一直存活的对象。大部分第0代对象是局部变量。成员变量和全局变量很快变成第1代对象并最终成为第2代对象。
  • 10、GC 对不同代的对象执行不同的检查策略以优化性能。每个 GC 周期都会检查第0代对象。大约1/10的 GC 周期检查第0代和第1代对象。大约1/100的GC周期检查所有的对象。重新思考Finalization的代价:需要Finalization的对象可能比不需要 Finalization 在内存中停留额外9个GC周期。如果此时它还没有被Finalize,就变成第2代对象,从而在内存中停留更长时间。

跨代引用

一个高代对象引用一个低代对象的情形只会在一种类型的语句中出现:将一个非空引用类型对象赋值给一个引用类型的实例的成员变量(或者复制给一个数组的元素)。

非托管资源回收

  非托管资源不受 CLR 或者垃圾回收器的管理(如内核对象句柄,数据库连接和非托管内存等)。释放非托管资源需要使用 终结化特性,即一个对象与一段特定的代码关联起来。这段代码必须在该对象(代表一个非托管资源)不再需要时执行。

常见的有:ApplicationContext, Brush, Component, ComponentDesigner, Container, Context, Cursor, FileStream, Font, Icon, Image, Matrix, Object, OdbcDataReader, OleDBDataReader, Pen, Regex, Socket, StreamWriter, Timer, Tooltip, 文件句柄, GDI资源, 数据库连接等等资源。

.NET 中提供释放非托管资源的方式主要是:Finalize() 和 Dispose()。

.NET的GC机制有这样两个问题:

  • GC并不是能释放所有的资源。它不能自动释放非托管资源。
  • GC并不是实时性的,这将会造成系统性能上的瓶颈和不确定性。

  GC 并不是实时性的,这会造成系统性能上的瓶颈和不确定性。所以有了 IDisposable 接口,IDisposable 接口定义了 Dispose 方法,这个方法用来供程序员显式调用以释放非托管资源。使用 using 语句可以简化资源管理。

当你用 Dispose 方法释放未托管对象的时候,应该调用 GC.SuppressFinalize。如果对象正在终结队列( finalization queue ), GC.SuppressFinalize 会阻止 GC 调用 Finalize 方法。因为 Finalize 方法的调用会牺牲部分性能。如果你的 Dispose 方法已经对委托管资源作了清理,就没必要让 GC 再调用对象的 Finalize 方法。

终结器

所有实现了终结器(析构函数)的对象,会被 GC 特殊照顾,GC 的终止化队列跟踪所有实现了 Finalize 方法(析构函数)的对象。

  • 当 CLR 在托管堆上分配对象时,GC 检查该对象是否实现了自定义的 Finalize 方法(析构函数)。如果是,对象会被标记为可终结的,同时这个对象的指针被保存在名为终结队列的内部队列中。终结队列是一个由垃圾回收器维护的表,它指向每一个在从堆上删除之前必须被终结的对象。
  • 当 GC 执行并且检测到一个不被使用的对象时,需要进一步检查“终结队列”来查询该对象类型是否含有 Finalize 方法,如果没有则将该对象视为垃圾,如果存在则将该对象的引用移动到另外一张 Freachable 列表,此时对象会被复活一次。
  • CLR 将有一个单独的高优先级线程负责处理 Freachable 列表,就是依次调用其中每个对象的 Finalize 方法,然后删除引用,这时对象实例就被视为不再被使用,对象再次变成垃圾。
  • 下一个 GC 执行时,将释放已经被调用 Finalize 方法的那些对象实例。

简单总结:Finalize() 可以确保非托管资源会被释放,但需要很多额外的工作(比如终结对象特殊管理),而且 GC 需要执行两次才会真正释放资源。唯一的优点就是不需要显示调用。

Finalization Queue 和 Freachable Queue

  这两个队列和 .NET 对象所提供的 Finalize 方法有关。这两个队列并不用于存储真正的对象,而是存储一组指向对象的指针。当程序中使用了 new 操作符在 Managed Heap 上分配空间时,GC 会对其进行分析,如果该对象含有 Finalize 方法则在 Finalization Queue 中添加一个指向该对象的指针。

  在 GC 被启动以后,经过 Mark 阶段分辨出哪些是垃圾。再在垃圾中搜索,如果发现垃圾中有被 Finalization Queue 中的指针所指向的对象,则将这个对象从垃圾中分离出来,并将指向它的指针移动到 Freachable Queue 中。这个过程被称为是对象的复生(Resurrection),本来死去的对象就这样被救活了。为什么要救活它呢?因为这个对象的 Finalize 方法还没有被执行,所以不能让它死去。Freachable Queue 平时不做什么事,但是一旦里面被添加了指针之后,它就会去触发所指对象的 Finalize 方法执行,之后将这个指针从队列中剔除,这是对象就可以安静的死去了。

  .NET Framework 的 System.GC 类提供了控制Finalize的两个方法,ReRegisterForFinalize 和 SuppressFinalize。前者是请求系统完成对象的 Finalize 方法,后者是请求系统不要完成对象的 Finalize 方法。ReRegisterForFinalize 方法其实就是将指向对象的指针重新添加到 Finalization Queue 中。这就出现了一个很有趣的现象,因为在 Finalization Queue 中的对象可以复生,如果在对象的 Finalize 方法中调用 ReRegisterForFinalize 方法,这样就形成了一个在堆上永远不会死去的对象,像凤凰涅槃一样每次死的时候都可以复生。

手动确定性终结化

手动确定性终结化,需要客户端释放资源。

自动非确定性终结化

Finalize 来自 System.Object 中受保护的虚方法 Finalize,无法被子类显示重写,也无法显示调用。她的作用就是用来释放非托管资源,由 GC 来执行回收,因此可以保证非托管资源可以被释放。

  • 无法被子类显示重写:.NET 提供类似 C++ 析构函数的形式来实现重写,因此也有称之为析构函数,但其实她只是外表和C++ 里的析构函数像而已。
  • 无法显示调用:由 GC 来管理和执行释放,不需要手动执行了,不用担心忘记调用Dispose。

  任何一种类型都可以通过重写 System.Object 定义的(protected)Finalize 方法表明该类型需要进行自动化终结。例如在 File 类中自动终结化方法 ~File(终结器,析构函数)。在对象将被销毁时,该方法必须得到执行。值类型只有在装箱的情况下才有垃圾回收的必要。

  当一个拥有终结器的对象被创建时,他的一个引用将被添加到一个特殊的运行时队列上,成为终结队列(finalization queue)。这个队列被垃圾回收器界定为根。这意味着即使应用程序没有针对这个引用对象的引用,它仍然会子啊终结队列上保持活动状态。

  当这个对象不再被应用程序引用,并开始垃圾回收时,若垃圾回收发现唯一一个针对该对象的引用来自于终结队列,则它会将这个对象的引用移动到另一个运行时管理的队列上,成为终结可达队列(freacheable queue)。该队列仍然被界定为根,因而,该对象仍然在被引用并保持存活。

  对象的终结器不会在垃圾回收的过程中执行。在 CLR 初始化的过程会创建一个特殊的线程,称为 终结器线程(finalizaer thread)(每一个进程只会有一个终结器线程,这和垃圾回收器特征无关,该线程运行在 THREAD_PRIORITY_HIGHEST 优先级上)。这个线程会反复等待终结化事件(finalization event)的触发。在垃圾回收器完成垃圾回收并触发该事件后,如果终结可达队列中有对象放入,则终结器线程就会被激活。终结器线程将对象的引用从终结可达队列中移除,同时同步执行对象上的终结器方法。而当下一次垃圾回收开始时,由于该对象再无引用,因此垃圾回收可以将其内存回收。

非确定性终结的缺点

  • 有终结器的对象将至少位于第1代,更容易经历“中年危机”,从而更容易进行多次完整回收。
  • 终结器线程的压力(有很多对象需要进行终结化)可能导致内存泄漏。如果应用程序线程分配对象的频率比终结器线程终结化对象的频率更高,那么应用程序将从等待终结化的对象中持续的泄漏内存。
  • 自动非确定性终结化还是很多难以发现或调试错误的来源。因为终结化是异步的,因此多个对象的终结顺序是难以保证的。

终结器也许永远不会被调用

  终结器无法在进程被野蛮的关闭时执行。如用户通过任务管理器或者 TerminateProcess API 终止线程的执行,则终结器将无法进行资源回收。

对象的复活

终结化为对象提供了一个在其不被应用程序引用的情况下执行任意代码的机会。这种机会可以用于创建一个应用程序到该对象的引用,在对象即将失效时,另其起死回生,这种能力称为复活(resurrection)。主要的风险是,该对象引用的其它对象有可能已经被终结化而处于无效状态,此时,只能重新初始化该对象所有引用的对象;另一个问题是该对象的终结器不会被执行,因此你需要将该对象的引用作为参数传递个 GC.ReRegisterForFinnalize 方法。

适用于复活机制的场景之一就是对象池(object pooling)。

Dispose模式

.Net 规定所有需要进行确定终结化的对象都必须实现 IDisposable 接口。而该接口仅有一个方法,即 Dispose 方法。这个方法会释放非托管资源,进行确定终结化。

Dispose 需要手动调用:

1
2
3
4
5
6
7
8
9
10
//方式1:显示接口调用
SomeType st1=new SomeType();
//do sth
st1.Dispose();

//方式2:using()语法调用,自动执行Dispose接口
using (var st2 = new SomeType())
{
//do sth
}
  • 第一种方式,显示调用,缺点显而易见,如果程序猿忘了调用接口,则会造成资源得不到释放。或者调用前出现异常,当然这一点可以使用 try…finally 避免。

  • 建议使用第二种实现方式,他可以保证无论如何 Dispose 接口都可以得到调用,原理其实很简单,using() 的 IL 代码如下图,因为 using 只是一种语法形式,本质上还是 try…finally 的结构。

151257-20160309235625475-414934067.png

微软是推荐同时实现 IDisposable 接口和 Finalize (析构函数),其实 FCL 中很多类库都是这样实现的,这样可以兼具两者的优点:

  • 如果调用了 Dispose,则可以忽略对象的终结器,对象一次就回收了;
  • 如果忘了调用 Dispose,则还有一层保障,终结器会负责对象资源的释放;

性能优化建议

尽量不要手动执行垃圾回收的方法:GC.Collect()

垃圾回收的运行成本较高(涉及到了对象块的移动、遍历找到不再被使用的对象、很多状态变量的设置以及 Finalize 方法的调用等等),对性能影响也较大,因此我们在编写程序时,应该避免不必要的内存分配,也尽量减少或避免使用 GC.Collect() 来执行垃圾回收,一般 GC 会在最适合的时间进行垃圾回收。

需要注意,在执行垃圾回收的时候,所有线程都是要被挂起的(如果回收的时候,代码还在执行,那对象状态就不稳定了,也没办法回收了)。

推荐 Dispose 代替 Finalize

如果你了解 GC 内存管理以及 Finalize 的原理,可以同时使用 Dispose 和 Finalize 双保险,否则尽量使用 Dispose。

选择合适的垃圾回收机制:工作站模式、服务器模式。

弱引用

弱引用(weak reference)是用于管理托管对象引用的附加机制。典型的对象引用(通常为强引用(strong reference))是明确的:只要还拥有一个对象的引用,那么这个对象就会存活。这种行为的正确性是有垃圾回收器保证的。

但在某些情况下,我们还希望有一种隐形的 “绳索”,即能够绑在对象上,又不影响垃圾回收器回收它占用的内存。如果垃圾回收器回收了这个对象,那么我们可以探测到绳索的一端和对象断开了。如果垃圾回收器还没有处理这个对象,我们可以牵动“绳索”来获得这个对象的一个强引用并在此使用这个对象。

常见的场景:

  • 在不保持对象的存活的情况下提供外部服务。例如,定时器和事件服务可以为对象所用,但并不需要维持对象的引用。这可以避免很多典型的内存泄漏问题。
  • 可以自动管理缓存或池策略。一个缓存可以保有最近使用对象的弱引用而不妨碍他们被回收。一个池可以划分为一个非常小的部分用以维持一小部分对象的强引用,以及另外一个可选部分对象的弱引用。
  • 用以保存一个大对象的引用,并寄希望于它不会被回收。应用程序可以持有一个需要长时间才能初始化的大对象的弱引用。若这个对象被回收,则重新初始化该对象,否则可以在需要时直接复用该对象。

引用程序代码可以通过 System.WeakReference 类来使用弱引用。可以基于 System.WeakReference 实现事件,这样可以规避.NET内存泄漏的元凶–忘记反注册事件。

弱引用默认不会跟踪对象的复活。若想允许复活追踪功能,就使用重载的构造函数并将第二个参数传参为true。

允许追踪对象复活状态的弱引用称为长弱引用(long weak reference);不追踪对象复活状态的弱引用称为短弱引用(short weak reference)。

垃圾回收句柄

弱引用是一类特殊的垃圾回收句柄( GC handle )。一个垃圾回收句柄是一个特殊的底层值类型,它为对象引用提供了如下的功能。

  • 维持一个对象的标准的(强)引用,防止对象被回收。以 GCHandleType.Normal 表示。
  • 维持一个对象的短弱引用。以 GCHandleType.Weak 表示。
  • 维持一个对象的长弱引用。以 GCHandleType.WeakTrackResurrection 表示。
  • 维持一个对象的引用,并进行固定,以防止它在内存中被移动。如果需要还可以获得该对象的地址。以 GCHandleType.Pinned 表示。

实际开发中,我们几乎没有直接使用垃圾回收句柄的需要。但是它们,作为另一种可以保持对象的根,经常出现在诊断结果中。

参考:

.NET面试题解析(06)-GC与内存管理

C#技术漫谈之垃圾回收机制(GC)

[.Net性能优化]