CodeGym /课程 /C# SELF /C# 内存:栈 (Stack)、堆 (Heap) 和 垃圾回收 (

C# 内存:栈 (Stack)、堆 (Heap) 和 垃圾回收 ( GC)

C# SELF
第 65 级 , 课程 0
可用

1. 内存基础:栈和堆 (Stack & Heap)

当你的程序启动时,它会得到内存。对于 .NET(CLR — Common Language Runtime)来说,这块内存是自动管理的,主要分为两个区域:。搞清数据放哪儿、怎么放,对于写出高效稳定的代码特别重要。

栈 (Stack)

想象成一摞盘子:你总是把新的盘子放在最上面,取也从最上面取。这就是 LIFO(Last In, First Out)原理,也就是“后进先出”。栈是非常快的,但空间有限。

栈上存什么:

  • 值类型:直接存放变量的值。
  • 对象的引用:对于引用类型,栈上只存对象在堆上的地址(引用)。
  • 方法参数:传入函数的值。
  • 局部变量:方法内部声明的变量。
  • 返回地址:方法执行完要返回到哪儿。

栈上的内存分配和回收是自动且非常快的:方法结束或变量超出作用域时内存就回收了。

示例:栈上的值类型

void MyMethod()
{
    int a = 10;       // 'a' 和 值 10 在栈上
    bool flag = true; // 'flag' 和 值 true 在栈上
    char initial = 'Z'; // 'initial' 和 值 'Z' 在栈上

    // ...
} // 当 MyMethod 结束时,'a', 'flag', 'initial' 会从栈上移除。

示例:栈上的引用

class MyObject { }

void AnotherMethod()
{
    MyObject objRef; // 'objRef'(引用)在栈上。对象本身还没创建。
    // ...
} // 当 AnotherMethod 结束时,'objRef'(引用)会从栈上移除。

堆 (Heap)

是更大、更灵活的内存区域。这里没有严格的 LIFO 次序;数据可以放在任意空闲位置。堆用来存放较大和/或寿命较长的对象。

堆上存什么:

  • 引用类型的对象:类对象、数组、字符串等 — 通过 new 创建。
  • 嵌套的值类型:如果一个值类型(struct)是引用类型(class)的字段,它会存放在堆上对象内部。

堆上的内存由 GC(Garbage Collector)自动管理。

示例:类对象在堆上

class Person { public string Name; public int Age; }

void CreatePerson()
{
    Person p = new Person(); // Person 对象在堆上。'p'(引用)在栈上。
    p.Name = "Alice";        // 字符串 "Alice"(也是对象)在堆上。
    p.Age = 30;              // 30(int,值类型)存放在 Person 对象内部(堆上)。
    // ...
} // 当 CreatePerson 结束时,'p'(引用)从栈上移除。
  // Person 对象在堆上变得不可达,将成为 GC 的回收对象。

示例:堆上的数组

void ProcessArray()
{
    int[] numbers = new int[5]; // 长度为 5 的 int 数组在堆上。'numbers'(引用)在栈上。
    numbers[0] = 10;            // 元素 10 是数组的一部分,存在堆上。
    // ...
} // 当没有对该数组的引用时,数组会被 GC 清理。

2. 垃圾回收 (Garbage Collection)

堆内存的管理通过 GC 自动完成。这是 .NET 的核心特性之一,免去了手动释放内存(在没有 GC 的语言里,这是内存泄漏的常见来源)。

目标和工作原理

GC 的主要目标是当对象不再被程序使用时自动释放它们占用的堆内存。

  1. 创建对象:使用 new 时,CLR 在堆上分配空间。
  2. 跟踪引用: GC 跟踪活动引用;如果有从栈、静态字段或其他可达对象的引用,则该对象被认为是“可达”的。
  3. 确定“垃圾”:如果没有引用——对象不可达,就被视为垃圾。
  4. 回收:在需要时,GC 会标记可达对象,释放不可达对象的内存。
  5. 压缩:为了减少碎片,剩下的对象可能会被移动,形成连续的内存区域。

GC 的世代机制 (Generational GC)

GC 使用世代的原因是大多数对象“很快就死”:

  • 第 0 代 (Gen 0): 新创建的对象;回收频繁且快速。
  • 第 1 代 (Gen 1): 从 Gen 0 幸存下来的对象;回收较少且耗时更长。
  • 第 2 代 (Gen 2): 长期存在的对象;回收最少但最昂贵。

GC 什么时候运行?

  • 为新的分配没有足够的可用内存时。
  • 空闲时的定期检查。
  • 显式调用(通常不推荐)GC.Collect()

GC 的性能

GC 的运行会引起短暂的停顿,因为用户代码会被暂停。过度创建短寿命的堆对象会导致更频繁的回收,从而影响性能。

示例:对象变得不可达

class DataBlock { public byte[] Data; public DataBlock() => Data = new byte[1024 * 1024]; } // 1 MB 数据

void AllocateAndLose()
{
    DataBlock block1 = new DataBlock(); // block1 对象在堆上,'block1' 引用在栈上

    // ... 一些代码块 ...

    block1 = null; // 现在没有对 DataBlock 对象的活动引用。它变得不可达。
    // 在这一步 GC 可能还没回收它,但它已经成为回收候选。

    // 在这里调用 GC.Collect()(仅为演示,不要在生产中这样做!)
    Console.WriteLine("Calling GC.Collect()");
    GC.Collect(); // GC 可能(但不保证)现在就回收内存
}

AllocateAndLose();
// 当 AllocateAndLose 结束时,'block1' 引用会从栈上移除,
// DataBlock 对象在堆上变得不可达,等待 GC 回收。

3. 非托管资源的管理

GC 管理的是 .NET 的“托管”内存。但有些资源是非托管的,处于 GC 的控制之外,需要显式释放:

  • 文件句柄(打开的文件)。
  • 网络 socket。
  • 窗口/图形对象的句柄(比如 GDI+)。
  • 从操作系统分配的内存(例如通过 P/Invoke)。

析构函数 (Finalizers)

析构函数是一个在对象被垃圾回收前用于做最终清理(非托管资源)的特殊方法。语法类似构造函数,但带波浪号:

class MyClassWithFinalizer
{
    ~MyClassWithFinalizer() { /* 释放非托管资源 */ }
}

GC 的触发时机不可预测,且在专门的 finalizer 线程上调用。缺点是不可预测且有开销(带 finalizer 的对象通常需要两次回收周期)。所以 finalizer 只是“保险”,不能当成主要的资源释放机制。

示例:把析构函数当成“保险”

class UnmanagedResourceHolder
{
    private bool _resourceReleased = false;

    // 模拟非托管资源
    public UnmanagedResourceHolder() => Console.WriteLine("资源已创建。");
    
    public void ReleaseResource()
    {
        if (!_resourceReleased)
        {
            Console.WriteLine("资源通过显式方法被释放。");
            _resourceReleased = true;
        }
    }

    // 析构函数 (Finalizer) - 当 GC 作为最后手段调用
    ~UnmanagedResourceHolder()
    {
        Console.WriteLine("析构函数 被 调用(资源 未 被 显式 释放)。");
        ReleaseResource(); // 尝试释放资源
    }
}

接口 IDisposable

IDisposable 是显式释放资源的推荐方式。它只包含一个方法:void Dispose()。常见做法是实现可重复调用的 Dispose(),并通过 GC.SuppressFinalize(this) 抑制 finalizer。

示例:实现 IDisposable

using System.IO;

class MyFileWriter : IDisposable
{
    private StreamWriter _writer;
    private bool _disposed = false;

    public MyFileWriter(string path)
    {
        _writer = new StreamWriter(path, true);
        Console.WriteLine($"文件 '{path}' 已打开。");
    }

    public void WriteLog(string message) => _writer.WriteLine(message);

    // IDisposable 的实现
    public void Dispose()
    {
        Dispose(true); // 调用主要的 Dispose 逻辑
        GC.SuppressFinalize(this); // 告诉 GC 不需要调用析构函数
        _disposed = true;
    }

    // 受保护的虚方法,常用于 Dispose 模式
    protected virtual void Dispose(bool disposing)
    {
        if (!_disposed)
        {
            if (disposing)
            {
                // 释放托管资源
                _writer?.Dispose();
            }
            // 释放非托管资源(如果有的话)
            Console.WriteLine("文件 已通过 Dispose 关闭。");
        }
    }

    // 可选的析构函数作为“保险”
    ~MyFileWriter()
    {
        Console.WriteLine("文件 通过 析构函数 关闭(Dispose() 未被调用)。");
        Dispose(false); // 指示这是非显式调用的 Dispose
    }
}

操作符 using

操作符 using 是语法糖,它保证当对象超出作用域时会调用 Dispose(),即便发生异常也会调用。这是处理实现了 IDisposable 的对象时最推荐的方式。

示例:用 using 自动清理

// 使用上面示例的 MyFileWriter
void ProcessFile(string fileName)
{
    // 在 using 块结束后,MyFileWriter 对象会被自动 Dispose
    using (var writer = new MyFileWriter(fileName))
    {
        writer.WriteLog("第一行。");
        writer.WriteLog("第二行。");
        // 即使这里抛异常,Dispose() 也会被调用
    } // 这里会自动调用 writer.Dispose()

    Console.WriteLine("using 块结束,文件已关闭。");
}

ProcessFile("testlog.txt");
// 在这个例子里,由于显式调用了 Dispose(),MyFileWriter 的析构函数不会被调用。

理解这些概念是写出高性能、可靠、可扩展的 C# 应用的关键。

2
任务
C# SELF, 第 65 级, 课程 0
已锁定
栈和堆的区别
栈和堆的区别
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION