目录
CA1063
内存
托管堆
内存分配
内存回收步骤
GC算法
代数
后台垃圾回收
using模式
IDisposable的正确实现
析构函数
GC.SuppressFinalize(this)
最近写代码遇到了一个code smell CA1063: Implement IDisposable correctly 提示我应该正确实现Dispose模式。什么是IDisposable接口的最佳实现呢?GC过程到底有哪些需要我们注意细节?让我们一起来认识一下C# CLR的内存管理机制与GC原理。
我们程序中使用的都是虚拟内存。虚拟内存是为了增加进程的可使用空间,并且无需关心物理内存分配调度问题。操作系统会通过页表管理虚拟内存和物理内存之间的关系,分页可以减少内存资源的浪费。
虚拟内存被分为三种状态:
通过 C# 值类型与引用类型 实现原理与差异 我们知道引用类型被分配到了"堆"上,什么是"堆"呢?
GC负责内存管理。当GC被CLR初始化之后,它会为进程分配一段连续的内存空间,这段内存就是我们常说的"托管堆"。每个进程都有一个托管堆, 进程中的所有线程都在同一堆上为对象分配内存,托管堆的大小是可变的。
托管堆又被继续分为大对象堆和小对象堆,默认大于85000 字节的对象会被放入大对象堆,可以通过runtime config options来配置大对象堆阈值。
托管堆是一块连续的虚拟内存地址,它自身维护了一个分配指针,指向下一块可用地址。
分配指针刚开始位于堆顶,每分配一个对象便往后移动一次,对象分配是连续的。
通常内存分配连续的对象在程序使用中也是连续的,因此这种分配方式可以提高对象访问效率。
C# GC通过构建对象引用图的方式来决定哪些对象需要回收。它会从一系列"根"出发,查找正在被引用的对象。未被引用的对象会被标记为无法访问,并回收为它们分配的内存。
大家可以想象,分配指针不断的在托管堆的末尾分配空间给新的对象,而之前旧的对象又零零散散的被回收,那么原本一块连续的托管堆最后会变得千疮百孔。
因此GC还会负责托管堆的"压缩"工作,即当GC发现大量对象被回收时,便会将后边的对象复制到前边的地址空间来,挤压掉托管堆中间的空隙。这样可以提高空间利用率,也有利于较大对象的分配。
到这里,你大概就能明白为什么要设置大对象堆和小对象堆了,大对象堆为了避免对象移动,通常不会压缩内存。
哪些对象可以作为根?静态字段、局部变量、CPU 寄存器、GC 句柄和终结队列。
GC算法遵守以下原则:
结合以上三个原则,托管堆将对象分为了0,1,2 三代。所以每次在GC时只需要维护部分堆空间,而不是压缩整个托管堆的内存。
第0代是GC频率最高的,刚创建的对象都会被分配到0代堆或者大对象堆。当0代堆空间不足的时候便会触发0代GC,大多数对象都会被回收,而那些幸存下来的对象就会"升级"成为1代。
第1代对象堆满了也会触发GC,那些幸存下来的对象成为第2代。
大对象因为是被单独维护的,它们属于第2代对象,有时也被称为第3代,但它们会和第2代对象一起被回收。CLR会尽量维持GC频率和不同代堆空间大小之间的平衡,以求获取最大的时空效率。
在.NET Framework 4之后使用后台垃圾回收机制机制。0代和1代对象被称为暂时代,对暂时代的垃圾回收始终是阻塞式的,即所有线程都将被挂起,在GC完成之后才能继续工作。因此,暂时代如果分配的对象过多将会严重影响程序性能。
了解IDisposable接口前,先看两个简单的问题:
1. Stream,SqlConnection是不是非托管对象?不是。它们是托管对象,也会被分配到托管堆上。
2. 为什么我们要用using来声明它们呢?因为它们会占用数据库链接,文件等非托管资源,我们知道GC的触发时机是不确定的,因此使用using是为了及时释放这些资源。而using的原理就是会调用对象的Dispose()方法,主动释放对象占用的资源。
了解C# CLR的内存管理机制后,我们再来看这个问题:如何正确实现IDisposable接口。
- 首先要明确一点:GC在回收资源时,只会调用对象的析构函数,不会调用Dispose方法。
- Dispose方法应该是幂等的,其不应引发任何异常, 非托管资源只能被释放一次。道理很简单,你申请了一块非托管内存,并在之后释放了它,那么它就有可能被重新分配给其他程序使用,如果再次去释放它就错了。
来看一个正确的例子,我们注意到对象除了实现无参Dispose方法之外,还声明了一个有参的Dispose虚方法,而这个虚方法才是资源释放的真正实现。
public class ManagedAndUnmanagedObject : IDisposable
{
private SqlConnection sqlConnection = new SqlConnection();
private UnmanagedHandle unmanagedHandle = Win32.SomeUnmanagedResource();
private bool disposed;
// Dispose方法由用户手动调用或者using模式使用
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposeManaged)
{
// disposed字段标记该对象是否已经被释放,重复释放会引发异常
if (!disposed)
{
if (disposeManaged)
{
if (sqlConnection != null)
{
//级联释放调用
sqlConnection.Dispose();
}
}
unmanagedHandle.Release();
disposed = true;
}
}
// 析构函数供GC调用,如果没有非托管资源,则可以省略
~ManagedAndUnmanagedObject()
{
Dispose(false);
}
}
那么为什么Dispose方法调用它时要传true,而析构函数在调用时要传入false呢?
这是因为析构函数只应该负责非托管资源的释放。GC在回收对象前会调用对象的析构函数,假如用户没有调用Dispose方法来主动释放非托管资源,那么实现析构函数可以保证非托管资源在最后能被正确释放。
GC.SuppressFinalize(this) 则是为了告诉GC,不要在回收该对象的时候调用析构函数。因为根据上面的实现,当我们手动调用Dispose的之后,非托管资源已经被释放掉了。
有些时候,我们可能没有用到非托管资源,但仍需要实现 IDisposable 接口来做一些事情,这种情况下可以不要析构函数,GC.SuppressFinalize(this) 也就不必要了。
在C# 8.0之后出现了 IAsyncDisposable接口,原理是一样的。