介绍

所有的计算机程序都会消耗内存,无论是内存中的变量、打开的文件,还是与数据库的连接。问题是,运行时环境如何回收不再使用的内存呢?这个问题有三个答案:

  1. 如果你使用的是托管资源,它会被垃圾回收器自动释放。
  2. 如果你使用的是非托管资源,你必须使用 IDisposable 接口来帮助清理。
  3. 如果你直接调用垃圾回收器,可以使用 System.GC.Collect() 方法,强制立即清理资源。

在讨论托管资源和非托管资源之前,了解垃圾回收器的实际工作原理会很有趣。

垃圾回收器

垃圾回收器是一个在程序中后台运行的进程。它始终存在于所有 .NET 应用程序中。它的工作是查找那些不再被程序使用的对象(即引用类型)。如果对象被赋值为 null,或对象超出了作用域,垃圾回收器会将该对象标记为待清理,可能并不立即释放其资源!

为什么这么做?因为垃圾回收器难以跟上程序运行的速度,特别是在每次释放内存时。因此,它只有在资源变得紧张时才会运行。垃圾回收器有三个“代”:

  • 第0代:最近创建的对象。
  • 第1代:生命周期中的中期对象。
  • 第2代:长期存在的对象。

所有引用类型的对象都将存在于这三代之一。它们首先被分配到第0代,然后根据其生命周期被移动到第1代和第2代。垃圾回收器的工作方式是只移除需要的对象,因此它通常仅扫描第0代进行快速修复。这是因为大多数局部变量都会被放置在这一代中。

如需更深入的了解,可以访问MSDN文章以获取更详细的解释。

现在你已经了解了垃圾回收器,我们来讨论它所管理的资源。

托管资源

托管资源是完全在 .NET 框架内运行的对象。所有的内存都会被自动回收,所有资源都会被关闭,通常情况下,当应用程序关闭或垃圾回收器运行时,你可以保证所有内存会被释放。

你无需手动关闭连接或做其他操作,它是一个自动清理的对象。

非托管资源

有些情况下,.NET 框架无法释放资源。这可能是因为对象引用了 .NET 框架之外的资源,例如操作系统,或引用了另一个非托管组件,或者资源访问了使用 COM、COM+ 或 DCOM 的组件。

无论什么原因,如果你使用的对象实现了 IDisposable 接口,那么你也需要实现 IDisposable 接口。

public interface IDisposable
{
    void Dispose();
}

此接口暴露了一个名为 Dispose() 的方法。单独使用它并不能帮助清理资源,因为它只是一个接口,因此开发者必须正确使用它以确保资源被释放。需要遵循两个步骤:

  1. 在使用完任何实现了 IDisposable 的对象后,始终调用 Dispose() 方法。(这可以通过 using 关键字使其更加简便)
  2. 使用终结器方法调用 Dispose(),这样即使有人没有关闭你的资源,你的代码也会为他们处理。

Dispose 模式

通常情况下,根据对象是否正在被终结,清理的内容会有所不同。例如,你不想在终结器中清理托管资源,因为这些托管资源可能已经被垃圾回收器回收。Dispose 模式可以帮助你在这种情况下正确实施资源管理:

public class MyResource : IDisposable
{
    private IntPtr _someUnmanagedResource;
    private List<long> _someManagedResource = new List<long>();
    
    public MyResource()
    {
        _someUnmanagedResource = AllocateSomeMemory();
        
        for (long i = 0; i < 10000000; i++)
            _someManagedResource.Add(i);
    }
    
    // 终结器会调用内部的 Dispose 方法,并指示它不释放托管资源。
    ~MyResource()
    {
        this.Dispose(false);
    }
    
    // 内部的 Dispose 方法。
    private void Dispose(bool disposing)
    {
        if (disposing)
        {
            // 清理托管资源
            _someManagedResource.Clear();
        }
        
        // 清理非托管资源
        FreeSomeMemory(_someUnmanagedResource);
    }
    
    // 公共的 Dispose 方法会调用内部的 Dispose 方法,指示它释放托管资源。
    public void Dispose()
    {
        this.Dispose(true);
        // 告诉垃圾回收器不要调用终结器,因为我们已经释放了资源。
        GC.SuppressFinalize(this);
    }
}

应用

如果你是从经典的 Visual Basic 迁移到 C#,你可能会见到类似这样的代码:

Public Function Read(ByRef FileName) As String
    Dim oFSO As FileSystemObject
    Set oFSO = New FileSystemObject

    Dim oFile As TextStream
    Set oFile = oFSO.OpenTextFile(FileName, ForReading, False)
    Read = oFile.ReadLine
End Function

注意,在 Visual Basic Classic 中,oFSOoFile 并没有显式地调用 Dispose。这是因为在经典的 Visual Basic 中,这些对象是局部声明的。这意味着当函数结束时,引用计数会降为零,从而触发这两个对象的 Terminate 事件处理程序,这些事件处理程序关闭文件并释放相关资源。

而在 C# 中并不会发生这种情况,因为这些对象没有引用计数。终结器直到垃圾回收器决定销毁这些对象时才会被调用。如果程序使用的内存很少,这可能需要很长时间。

这会造成一个问题,因为文件会保持打开状态,这可能阻止其他进程访问它。

在许多语言中,解决方法是显式地关闭文件并释放对象资源,许多 C# 程序员就是这样做的。然而,更好的方法是使用 using 语句:

public read(string fileName)
{
    using (TextReader textReader = new StreamReader(filename))
    {
        return textReader.ReadLine();
    }
}

在幕后,编译器会将 using 语句转换成 try...finally,并生成以下中间语言(IL)代码:

.method public hidebysig static string  Read(string FileName) cil managed
{
    .maxstack  5
    .locals init (class [mscorlib]System.IO.TextReader V_0, string V_1)
    IL_0000:  ldarg.0
    IL_0001:  newobj     instance void [mscorlib]System.IO.StreamReader::.ctor(string)
    IL_0006:  stloc.0
    .try
    {
        IL_0007:  ldloc.0
        IL_0008:  callvirt   instance string [mscorlib]System.IO.TextReader::ReadLine()
        IL_000d:  stloc.1
        IL_000e:  leave      IL_0025
    }  // end .try
    finally
    {
        IL_0018:  ldloc.0
        IL_0019:  brfalse    IL_0024
        IL_001e:  ldloc.0
        IL_001f:  callvirt   instance void [mscorlib]System.IDisposable::Dispose()
        IL_0024:  endfinally
    }  // end handler
    IL_0025:  ldloc.1
    IL_0026:  ret
}

注意,Read 函数的主体已经分为三个部分:初始化、tryfinallyfinally 块包括了原始 C# 代码中未显式指定的代码,特别是调用 StreamReader 实例的析构函数。

资源获取即初始化 (RAII)

在引言中使用 using 语句的示例就是所谓的 资源获取即初始化(RAII) 的一种惯用法。

RAII 是像经典的 Visual Basic 和 C++ 这样的语言中的一种自然技术,这些语言具有确定性的终结,但通常需要额外的工作才能在像 C# 和 VB.NET 这样的垃圾回收语言中应用。using 语句使得这一点变得简单。当然,你也可以显式地编写 try..finally 代码,在某些情况下这仍然是必要的。有关 RAII 技术的详细讨论,请参考《HackCraft: The RAII Programming Idiom》。Wikipedia 上也有简短的说明:Resource Acquisition Is Initialization

Last modified: Sunday, 12 January 2025, 12:14 AM