C#-垃圾回收机制(GC)

  • Post category:C#

C#-垃圾回收机制(GC)

什么是GC

官网中有这么一句话:

The garbage collector is a common language runtime component that controls the allocation and release of managed memory。

垃圾回收机制(Garbage Collection)简称GC,是CLR的一个组件,它控制内存的分配与释放。

概括:就是GC会帮你自动管理内存,分配内存,回收内存,采用的就是对应的GC的算法。


GC产生的背景

每个程序都要使用这样或那样的资源,比如文件、内存缓冲区、屏幕空间、网络连接、数据库资源等。在面向对象的环境中,每个类型都代表可供程序使用的一种资源。要使用这些资源,必须为代表资源的类型分配内存。

访问资源所需要的步骤有:


上述步骤如果最后一步是由程序员负责,可能会产生一些无法预测的问题,如忘记释放不再使用的内存、试图使用已被释放的内存(即野指针),这种bug会造成资源泄露(浪费内存)和对象损坏(影响稳定性)。而正确的进行资源管理通常很难而且很枯燥,它会极大的分散程序员的注意力。而GC能简化程序员的内存管理工作。

GC工作原理

垃圾收集器的本质,就是跟踪所有被引用到的对象,整理不再被引用的对象,回收相应的内存。

以应用程序的root为基础,遍历应用程序在Heap上动态分配的所有对象,通过识别它们是否被引用来确定哪些对象是已经死亡的、哪些仍需要被使用。已经不再被应用程序的root或者别的对象所引用的对象就是已经死亡的对象,即所谓的垃圾,需要被回收。

垃圾回收的算法有有多种,在.Net中采用了一种叫做标记与清除(Mark-Sweep)算法,该算法分两个本领:

Compact算法除了会提高再次分配内存的速度,如果新分配的对象在堆中位置很紧凑的话,高速缓存的性能将会得到提高,因为一起分配的对象经常被一起使用(程序的局部性原理),所以为程序提供一段连续空白的内存空间是很重要的。

简单地把.NET的GC算法看作Mark-Compact算法。阶段1: Mark-Sweep 标记清除阶段,先假设heap中所有对象都可以回收,然后找出不能回收的对象,给这些对象打上标记,最后heap中没有打标记的对象都是可以被回收的;阶段2: Compact 压缩阶段,对象回收之后heap内存空间变得不连续,在heap中移动这些对象,使他们重新从heap基地址开始连续排列,类似于磁盘空间的碎片整理。Heap内存经过回收、压缩之后,可以继续采用前面的heap内存分配方法,即仅用一个指针记录heap分配的起始地址就可以。


主要处理步骤:将线程挂起确定roots创建reachable objects graph对象回收heap压缩指针修复。可以这样理解roots:heap中对象的引用关系错综复杂(交叉引用、循环引用),形成复杂的graph,roots是CLR在heap之外可以找到的各种入口点。

GC搜索roots的地方包括全局对象静态变量局部对象函数调用参数当前CPU寄存器中的对象指针(还有finalization queue)等。主要可以归为2种类型:已经初始化了的静态变量线程仍在使用的对象(stack+CPU register)

Reachable objects:指根据对象引用关系,从roots出发可以到达的对象。例如当前执行函数的局部变量对象A是一个root object,他的成员变量引用了对象B,则B是一个reachable object。从roots出发可以创建reachable objects graph,剩余对象即为unreachable,可以被回收 。


指针修复是因为compact过程移动了heap对象,对象地址发生变化,需要修复所有引用指针,包括stack、CPU register中的指针以及heap中其他对象的引用指针。

Debug和release执行模式之间稍有区别,release模式下后续代码没有引用的对象是unreachable的,而debug模式下需要等到当前函数执行完毕,这些对象才会成为unreachable,目的是为了调试时跟踪局部对象的内容。传给了COM+的托管对象也会成为root,并且具有一个引用计数器以兼容COM+的内存管理机制,引用计数器为0时,这些对象才可能成为被回收对象。Pinned objects指分配之后不能移动位置的对象,例如传递给非托管代码的对象(或者使用了fixed关键字),GC在指针修复时无法修改非托管代码中的引用指针,因此将这些对象移动将发生异常。pinned objects会导致heap出现碎片,但大部分情况来说传给非托管代码的对象应当在GC时能够被回收掉。

Generational 分代算法

GC算法的设计考虑到了4个因素:

  1. 对于较大内存的对象,频繁的进行GC将耗费大量的资源,成本很高且效果较差
  2. 大量新创建的对象生命周期都较短,老对象的生命周期都较长
  3. 小部分的进行GC比大块的进行GC效率更高,消耗更少
  4. 新创建的对象在内存分配上多为连续,且关联程度较强,关联度较强有利于CPU Cache命中。

基于此,按照寿命长短,托管堆被分为了三个年龄层,分别是Generation 0,Generation 1, Generation 2。垃圾收集器在第 0 代存储新对象。在应用程序生命周期早期创建的在收集过程中幸存下来的对象被提升并存储在第 1 代和第 2 代中。因为压缩托管堆的一部分比压缩整个堆要快,因此该方案允许垃圾收集器在特定代中释放内存,而不是在每次执行收集时释放整个托管堆的内存。


第 0 代:这是最年轻的一代,包含生命周期很短的对象。短期对象的一个例子是临时变量。垃圾收集在这一代发生得最频繁。新分配的对象形成了第0代的对象,并且是隐式的第 0 代集合。但是,对象很大,它们将进入大对象堆 (LOH),有时也称为第3 代。第3 代可以理解为物理代,作为第二代的衍生。 大多数对象在第 0 代被回收用于垃圾收集,并且不会存活到下一代。

如果应用程序在第 0 代已满时尝试创建新对象,垃圾收集器将执行收集以尝试释放对象的地址空间。垃圾收集器首先检查第 0代中的对象,而不是托管堆中的所有对象。单独的第 0 代集合通常会回收足够的内存,使应用程序能够继续创建新对象。

第 1 代:这一代包含短期对象,并作为短期对象和长期对象之间的缓冲区。在垃圾收集器执行第 0代的收集后,它会压缩可访问对象的内存并将它们提升到第 1代。因为在收集中幸存下来的对象往往具有更长的生命周期,所以将它们提升到更高的代是有意义的。垃圾收集器不必在每次执行第 0代收集时重新检查第 1 代和第 2 代中的对象。 如果第 0 代的集合没有为应用程序回收足够的内存来创建新对象,则垃圾收集器可以执行第1 代的收集,然后是第 2 代。第 1 代中在集合中幸存下来的对象将被提升到第 2 代。

第 2 代:这一代包含长期存在的对象。长寿命对象的一个示例是服务器应用程序中的对象,其中包含在进程持续期间有效的静态数据。在集合中存活的第 2 代对象将保留在第 2 代中,直到它们被确定在未来的集合中不可访问。 大对象堆(有时称为第3 代)上的对象也在第 2代中收集。

当条件允许时,垃圾收集发生在特定的世代。收集一代意味着收集该一代及其所有年轻一代的对象。第 2 代垃圾回收也称为完整垃圾回收,因为它回收所有代中的对象(即托管堆中的所有对象)。

当垃圾收集器检测到某一代存活率较高时,会增加该代的分配阈值。 下一个集合获得大量回收内存。 CLR 不断平衡两个优先级:不让应用程序的工作集因延迟垃圾收集而变得太大,以及不让垃圾收集运行得太频繁。

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方法,这样就形成了一个在堆上永远不会死去的对象,像凤凰涅槃一样每次死的时候都可以复生。

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

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

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

示例:

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

public class BaseResource : IDisposable
{
// 指向外部非托管资源
private IntPtr handle;
// 此类使用的其它托管资源.
private Component Components;
// 跟踪是否调用.Dispose方法,标识位,控制垃圾收集器的行为
private bool disposed = false;

// 构造函数
public BaseResource()
{
// Insert appropriate constructor code here.
}

// 实现接口IDisposable.
// 不能声明为虚方法virtual.
// 子类不能重写这个方法.
public void Dispose()
{
Dispose(true);
// 离开终结队列Finalization queue
// 设置对象的阻止终结器代码
//
GC.SuppressFinalize(this);
}

// Dispose(bool disposing) 执行分两种不同的情况.
// 如果disposing 等于 true, 方法已经被调用
// 或者间接被用户代码调用. 托管和非托管的代码都能被释放
// 如果disposing 等于false, 方法已经被终结器 finalizer 从内部调用过,
//你就不能在引用其他对象,只有非托管资源可以被释放。
protected virtual void Dispose(bool disposing)
{
// 检查Dispose 是否被调用过.
if (!this.disposed)
{
// 如果等于true, 释放所有托管和非托管资源
if (disposing)
{
// 释放托管资源.
Components.Dispose();
}
// 释放非托管资源,如果disposing为 false,
// 只会执行下面的代码.
CloseHandle(handle);
handle = IntPtr.Zero;
// 注意这里是非线程安全的.
// 在托管资源释放以后可以启动其它线程销毁对象,
// 但是在disposed标记设置为true前
// 如果线程安全是必须的,客户端必须实现。

}
disposed = true;
}
// 使用interop 调用方法
// 清除非托管资源.
[System.Runtime.InteropServices.DllImport(“Kernel32”)]
private extern static Boolean CloseHandle(IntPtr handle);

// 使用C# 析构函数来实现终结器代码
// 这个只在Dispose方法没被调用的前提下,才能调用执行。
// 如果你给基类终结的机会.
// 不要给子类提供析构函数.
~BaseResource()
{
// 不要重复创建清理的代码.
// 基于可靠性和可维护性考虑,调用Dispose(false) 是最佳的方式
Dispose(false);
}

// 允许你多次调用Dispose方法,
// 但是会抛出异常如果对象已经释放。
// 不论你什么时间处理对象都会核查对象的是否释放,
// check to see if it has been disposed.
public void DoSomething()
{
if (this.disposed)
{
throw new ObjectDisposedException();
}
}

// 不要设置方法为virtual.
// 继承类不允许重写这个方法
public void Close()
{
// 无参数调用Dispose参数.
Dispose();
}

public static void Main()
{
// Insert code here to create
// and use a BaseResource object.
}
}

GC.Collect() 方法

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

GC的方法:

名称

说明

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代对象,从而在内存中停留更长时间。

总结

垃圾回收背后有这样一个基本的观念:编程语言(大多数的)似乎总能访问无限的内存。而开发者可以一直分配、分配再分配——像魔法一样,取之不尽用之不竭。

GC的基本工作原理是:通过最基本的标记清除原理,清除不可达对象;再像磁盘碎片整理一样压缩、整理可用内存;最后通过分代算法实现性能最优化。

问题记录

  1. 第0代时幸存的对象变成第1代,那么有没有可能这个时候第0代幸存对象的空间算到第1代,导致第1代满了呢?

答:不会,因为第0代和第1代的预算容量相差悬殊,而且不是在第1代空间完全满的时候才清理内存的,而是差不多快满的时候就会清理内存,这个快满的空间是大于第0代的预算容量的;


  1. 同步块索引,你刚刚说又可以锁住对象标记同步,又可以用来标记可达?

答:同步块索引的功能很多,即可以标记同步位,又可以标记可达,还可以存储哈希码

https://blog.csdn.net/acmilanvanbasten/article/details/14521051 具体可以看看这篇文章,写的很详细。