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 的主要目标是当对象不再被程序使用时自动释放它们占用的堆内存。
- 创建对象:使用 new 时,CLR 在堆上分配空间。
- 跟踪引用: GC 跟踪活动引用;如果有从栈、静态字段或其他可达对象的引用,则该对象被认为是“可达”的。
- 确定“垃圾”:如果没有引用——对象不可达,就被视为垃圾。
- 回收:在需要时,GC 会标记可达对象,释放不可达对象的内存。
- 压缩:为了减少碎片,剩下的对象可能会被移动,形成连续的内存区域。
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# 应用的关键。
GO TO FULL VERSION