CLR内存管理机制与IDisposable对象的GC原理

目录

CA1063

内存

托管堆

内存分配

内存回收步骤

GC算法

代数

后台垃圾回收

using模式

IDisposable的正确实现

析构函数

GC.SuppressFinalize(this)


CA1063

最近写代码遇到了一个code smell CA1063: Implement IDisposable correctly 提示我应该正确实现Dispose模式。什么是IDisposable接口的最佳实现呢?GC过程到底有哪些需要我们注意细节?让我们一起来认识一下C# CLR的内存管理机制与GC原理。

内存

我们程序中使用的都是虚拟内存。虚拟内存是为了增加进程的可使用空间,并且无需关心物理内存分配调度问题。操作系统会通过页表管理虚拟内存和物理内存之间的关系,分页可以减少内存资源的浪费。

虚拟内存被分为三种状态:

  • Free(自由可分配)
  • Reserved(进程保留), 已经分配给进程,但还没分配物理内存,因此无法存储数据
  • Committed(已提交),已经为其分配了物理内存

托管堆

通过 C# 值类型与引用类型 实现原理与差异 我们知道引用类型被分配到了"堆"上,什么是"堆"呢?

GC负责内存管理。当GC被CLR初始化之后,它会为进程分配一段连续的内存空间,这段内存就是我们常说的"托管堆"。每个进程都有一个托管堆, 进程中的所有线程都在同一堆上为对象分配内存,托管堆的大小是可变的。

托管堆又被继续分为大对象堆和小对象堆,默认大于85000 字节的对象会被放入大对象堆,可以通过runtime config options来配置大对象堆阈值。

内存分配

托管堆是一块连续的虚拟内存地址,它自身维护了一个分配指针,指向下一块可用地址。

分配指针刚开始位于堆顶,每分配一个对象便往后移动一次,对象分配是连续的。

通常内存分配连续的对象在程序使用中也是连续的,因此这种分配方式可以提高对象访问效率。

内存回收步骤

C# GC通过构建对象引用图的方式来决定哪些对象需要回收。它会从一系列""出发,查找正在被引用的对象。未被引用的对象会被标记为无法访问,并回收为它们分配的内存。

大家可以想象,分配指针不断的在托管堆的末尾分配空间给新的对象,而之前旧的对象又零零散散的被回收,那么原本一块连续的托管堆最后会变得千疮百孔。

因此GC还会负责托管堆的"压缩"工作,即当GC发现大量对象被回收时,便会将后边的对象复制到前边的地址空间来,挤压掉托管堆中间的空隙。这样可以提高空间利用率,也有利于较大对象的分配。

到这里,你大概就能明白为什么要设置大对象堆和小对象堆了,大对象堆为了避免对象移动,通常不会压缩内存。

哪些对象可以作为根?静态字段、局部变量、CPU 寄存器、GC 句柄和终结队列。

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完成之后才能继续工作。因此,暂时代如果分配的对象过多将会严重影响程序性能。

using模式

了解IDisposable接口前,先看两个简单的问题:

1. Stream,SqlConnection是不是非托管对象?不是。它们是托管对象,也会被分配到托管堆上。

2. 为什么我们要用using来声明它们呢?因为它们会占用数据库链接,文件等非托管资源,我们知道GC的触发时机是不确定的,因此使用using是为了及时释放这些资源。而using的原理就是会调用对象的Dispose()方法,主动释放对象占用的资源。

IDisposable的正确实现

了解C# CLR的内存管理机制后,我们再来看这个问题:如何正确实现IDisposable接口。

  1. 首先要明确一点:GC在回收资源时,只会调用对象的析构函数,不会调用Dispose方法。
  2. 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.SuppressFinalize(this) 则是为了告诉GC,不要在回收该对象的时候调用析构函数。因为根据上面的实现,当我们手动调用Dispose的之后,非托管资源已经被释放掉了。

有些时候,我们可能没有用到非托管资源,但仍需要实现 IDisposable 接口来做一些事情,这种情况下可以不要析构函数,GC.SuppressFinalize(this) 也就不必要了。

在C# 8.0之后出现了 IAsyncDisposable接口,原理是一样的。

你可能感兴趣的:(.NET,CLR,c#,jvm,java)