1. 值类型与引用类型的本质差异
一句话总结: 值类型(struct)按值传递、内联存储,引用类型(class)走堆内存、由 GC 统一回收,二者差异决定了一门代码的内存画像。
C# 的类型系统分两大阵营:值类型(Value Type)与引用类型(Reference Type)。这个划分不是概念游戏,它直接决定变量在内存里怎么放、怎么传、怎么释放。
// 值类型:struct / enum / 元组,存储在栈上或内联在父对象中
public struct Point
{
public int X;
public int Y;
}
// 引用类型:class / interface / delegate / 数组 / string,存储在托管堆上
public class Player
{
public string Name;
public int Level;
}
值类型赋值是整块拷贝,引用类型赋值只拷贝对象引用(8 字节指针)。这个差异在参数传递、集合存储、返回结果三处尤其明显。
| 对比维度 | 值类型 struct | 引用类型 class |
|---|---|---|
| 存储位置 | 栈或内联于堆对象中 | 托管堆 |
| 赋值语义 | 逐字段拷贝 | 复制引用,指向同一对象 |
| 回收方式 | 作用域结束即释放 | GC 分代回收 |
| 默认值 | 零值(如 0、false) | null |
| 判等 | 默认逐字段比较(Equals 需重写) | 默认引用比较 |
避坑: 把一个大型 struct 反复传给方法、放进 List 再排序,会产生大量拷贝开销;而把大对象反复 new 出来,则会压榨 GC。选择哪种类型,本质是在「拷贝成本」和「分配成本」之间做权衡。
2. 分代垃圾回收机制
一句话总结: .NET 用分代(Gen0/Gen1/Gen2)加 LOH 区分对象年龄,绝大多数对象活不过 Gen0,GC 用「标记-清除-压缩」三步把堆整理得井井有条。
.NET 的 GC 不是「回收所有对象」,而是按代际分层管理。新对象先进 Gen0,熬过一次回收进入 Gen1,再熬一次进入 Gen2;大对象(> 85KB)直接进 LOH(Large Object Heap),不参与压缩。
// 观察 GC 状态
GC.GetTotalMemory(false); // 当前托管堆已用字节
GC.CollectionCount(0); // Gen0 回收次数
GC.CollectionCount(1); // Gen1 回收次数
GC.CollectionCount(2); // Gen2 回收次数
// 默认不要把 GC.Collect() 写进业务代码
// GC.Collect(); // 仅诊断场景使用
| 代际 | 对象年龄 | 回收频率 | 特点 |
|---|---|---|---|
| Gen0 | 刚分配 | 最高 | 存活率极低,回收最快 |
| Gen1 | 熬过一次 | 中 | 短期对象缓冲区 |
| Gen2 | 长命对象 | 低 | 回收一次成本高 |
| LOH | 大对象 | 低 | 不压缩,易产生碎片 |
关键认知: GC 回收时不是「扫一遍堆」,而是从 GC Root(静态字段、栈局部变量、寄存器、finalizer 队列) 出发做可达性标记,不可达对象即垃圾。理解这一点就明白——持有不需要的引用,等于把对象钉在堆上不让回收,这正是内存泄漏在托管世界里的主要形态。
2.3 终结器与 IDisposable
托管对象靠 GC 回收,但非托管资源(文件句柄、数据库连接、Socket)必须显式释放。C# 提供两套机制:终结器(finalizer)在 GC 回收前兜底调用,IDisposable 让资源释放可控、及时。
// IDisposable 的标准实现模式
public class ManagedConnection : IDisposable
{
private readonly SqlConnection _conn = new("...");
private bool _disposed;
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this); // 已手动释放,跳过终结器
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing) _conn.Dispose();
_disposed = true;
}
// 终结器:兜底,仅在忘记 Dispose 时由 GC 触发
~ManagedConnection() => Dispose(false);
}
// 使用:using 保证异常时也释放
using (var c = new ManagedConnection())
{
// 业务代码
}
| 机制 | 触发时机 | 说明 |
|---|---|---|
| IDisposable.Dispose | 手动/using | 确定性释放,首选 |
| 终结器 | GC 回收前 | 兜底,耗时且不定时 |
| GC.SuppressFinalize | Dispose 内调用 | 避免双重回收 |
避坑: 终结器里的代码运行在专用终结线程上,不能保证时序,还可能导致对象被“复活”(resurrection)进 FReachable 队列。有非托管资源就实现 IDisposable 并用 using 包裹,别把回收交给不知道何时发生的终结器。
3. 箱拆箱与集合选型
一句话总结: 值类型塞进 object / 老式 ArrayList 会触发装箱(boxing),每次分配新对象;泛型集合通过类型实参消除装箱,是性能的第一道防线。
装箱(boxing)是把值类型包进 object 的行为,发生在 object o = 42; 这类赋值时。装箱必然产生一次堆分配;拆箱(unboxing)则把 object 里的值拷回值类型变量。高频装箱会让 GC 压力骤增。
// 装箱:值类型 → object,产生堆分配
object boxed = 42;
// 拆箱:object → 值类型,需要类型检查
int unboxed = (int)boxed;
// 泛型集合:全程无装箱,值类型内联存储
List<int> ints = new List<int> { 1, 2, 3 };
// 老式非泛型集合:每个元素都装箱,尽量避免
// ArrayList legacy = new ArrayList { 1, 2, 3 }; // 三个装箱对象
| 集合 | 值类型行为 | 适用场景 |
|---|---|---|
List<T> / Dictionary<TKey,TValue> | 内联存储,无装箱 | 默认首选 |
ArrayList / Hashtable | 全部装箱 | 遗留代码,勿用于新项目 |
Span<T> | 栈/内联,无 GC 压力 | 高性能切片 |
数组 T[] | 内联存储 | 定长数据 |
实践: 凡是「值类型集合」一律用泛型。若方法签名只接受
IEnumerable(非泛型),尽量改成IEnumerable<T>;无法改时,再用Cast<T>()或显式拆箱兜底。装箱在 JIT 眼里是透明的语法糖,在 GC 眼里是扎扎实实的分配。
4. 泛型的约束与运行时
一句话总结: 泛型让算法与数据类型解耦,约束(constraint)在编译期锁死能力边界,JIT 为每种封闭类型生成专用代码,值类型还能获得内联优化。
泛型是「类型参数化」:写一遍 List<T>,能装任意类型。C# 的泛型是运行时保留的(reified),不像 Java 那样类型擦除——所以 List<int> 和 List<string> 在运行时是不同类型,JIT 会分别生成机器码。
// 约束语法
public static T Max<T>(T a, T b) where T : struct, IComparable<T>
{
return a.CompareTo(b) > 0 ? a : b;
}
// 值类型约束让 JIT 能生成无装箱的专用代码
public static TIdentity<T>(T value) where T : struct => value;
// 用法:约束与调用端类型一致
int m = Max(3, 5);
| 约束 | 含义 | 典型场景 |
|---|---|---|
where T : struct | 值类型 | 数值运算 |
where T : class | 引用类型 | 对象仓储 |
where T : new() | 有无参构造 | 工厂方法 |
where T : IComparable<T> | 可比较 | 排序算法 |
where T : notnull | 非空引用 | 可空上下文 |
避坑: 泛型与
static的组合容易产生认知偏差——泛型类型的静态字段每个封闭类型各有一份。Counter<int>和Counter<string>的静态计数互不相干。这在缓存、单例设计里常是隐患来源。
5. record 与模式匹配
一句话总结: record 用一条语法拿到值相等、不可变和 with 复制,模式匹配把类型判断从 if-else 升级为可组合的声明式表达式。
record 是 C# 9 引入的引用类型,默认提供值相等性(逐成员比较)、不可变属性和 with 表达式。它非常适合 DTO、事件、领域值对象这类「以值论身份」的数据。
// 位置式 record:构造函数、属性、解构、值相等全自动生成
public record Order(int Id, string Customer, decimal Total);
var order1 = new Order(1, "Alice", 99.5m);
var order2 = order1 with { Total = 120.0m }; // 非破坏性复制
Console.WriteLine(order1 == order2); // False,Total 不同
// 模式匹配:switch 表达式 + 属性模式
static string Describe(Order o) => o switch
{
{ Total: >= 100m } => "大额订单",
{ Total: >= 50m } => "普通订单",
_ => "小额订单"
};
| 特性 | record | class | struct |
|---|---|---|---|
| 值相等 | 默认支持 | 需重写 Equals | 需重写 Equals |
| 不可变 | 默认 init | 可读写 | 可读写 |
| with 复制 | 支持 | 需手写 | 需手写 |
| 引用语义 | 是 | 是 | 否 |
一句话: record 解决的痛点是「相等性语义」。判断两个对象是否相同,业务上通常是值相等而非引用相等——record 让这种直觉直接变成语言行为。
6. 模式匹配进阶与类型空间的组合
一句话总结: 模式匹配不止 switch,还支持位置模式、列表模式、关系模式,能对任意嵌套数据结构做声明式解构与校验。
C# 的模式匹配家族覆盖了大多数「按形状分派」的需求,从简单 is 检查到递归模式都能表达。
// 类型模式
object x = "hello";
if (x is string s) { Console.WriteLine(s.Length); }
// 位置模式 + 列表模式
int[] numbers = { 1, 2, 3 };
if (numbers is [1, var second, _]) { Console.WriteLine(second); }
// 关系模式 + 逻辑模式
static string Classify(int score) => score switch
{
< 0 => "非法",
>= 0 and < 60 => "不及格",
>= 60 and <= 100 => "及格",
> 100 => "非法"
};
// 递归位置模式解构 record
if (order1 is Order(_, "Alice", var total) && total > 50)
{
Console.WriteLine("Alice 的大额订单");
}
| 模式 | 语法示例 | 用途 |
|---|---|---|
| 类型模式 | is string s | 类型判断并绑定 |
| 属性模式 | { Total: >= 100m } | 按属性过滤 |
| 位置模式 | Order(_, "Alice", var t) | 解构 record/元组 |
| 列表模式 | [1, var second, _] | 集合形状匹配 |
| 关系模式 | < 0、>= 60 and <= 100 | 范围判断 |
实践: 模式匹配的威力在「穷尽性」——配合
_兜底和编译器对 sealed 类型的穷尽检查,能让「忘了某种分支」从运行时 bug 变成编译期错误。
7. 内存访问的高级手段
一句话总结: Span
、ref struct、stackalloc 把「指向连续内存的指针」安全地引进 C#,让高性能解析、零拷贝切片成为可能。
前面提到的都是「怎么少分配」,本节讲「怎么直接访问内存」。Span
// Span 可以指向数组的一段,零拷贝
int[] data = { 0, 1, 2, 3, 4, 5 };
Span<int> slice = data.AsSpan(2, 3); // 2,3,4
slice[0] = 99; // 原地修改原数组
// stackalloc 在栈上分配,零 GC 分配
Span<int> buffer = stackalloc int[128];
// ref struct:只能存在于栈上,无法装箱、无法入堆
public ref struct Token
{
public ReadOnlySpan<char> Text;
public int Position;
}
| 手段 | 分配位置 | GC 参与 | 限制 |
|---|---|---|---|
数组 T[] | 堆 | 是 | 无 |
Span<T> | 栈/堆/对象内 | 可为零 | ref struct 不能入堆 |
stackalloc | 栈 | 无 | 容量有限 |
Memory<T> | 堆 | 是 | 可异步持有,弥补 Span 的不足 |
7.2 高性能字符串与解析场景
字符串是不可变引用类型,高频拼接与解析是 GC 压力的重灾区。ReadOnlySpan<char> 让「切片字符串」不再产生子串分配,是解析类热路径的标准姿势。
// 传统切片:每次产生新字符串
string payload = "id:42,name:plume";
int colon = payload.IndexOf(':');
string idStr = payload.Substring(colon + 1, 2); // 分配新串
// Span 视图:零分配切片
ReadOnlySpan<char> span = payload;
int idx = span.IndexOf(':');
ReadOnlySpan<char> idSpan = span[(idx + 1)..(idx + 3)]; // 零分配
// 解析数字也零分配
int id = int.Parse(idSpan);
| 操作 | 分配 | 替代 |
|---|---|---|
string.Substring | 每次分配 | ReadOnlySpan 切片 |
string + 循环拼接 | 每次分配 | StringBuilder / string.Create |
Split() | 数组 + 子串 | Span 逐段扫描 |
int.Parse(string) | 无新分配但需 string | int.Parse(ReadOnlySpan<char>) |
避坑: Span 是 ref struct,不能放进 class 字段、不能装箱、不能跨 await——这是编译期强制的安全性约束。需要在异步场景持有切片时改用
Memory<T>。解析二进制协议、字符串分词这类热路径,Span + stackalloc 的收益极其可观,子串与拼接是这类代码里最先要消灭的分配。
8. 总结
| 环节 | 要点 |
|---|---|
| 类型 | 值类型拷贝、引用类型共享,按需选型 |
| GC | 分代 + LOH,持引用即钉住对象 |
| 集合 | 泛型集合消灭装箱,优先 List |
| 泛型 | 运行时保留类型,值类型内联优化 |
| record | 值相等 + with 复制,DTO 首选 |
| 模式匹配 | 声明式分派,穷尽性由编译器保障 |
| 内存 | Span / ref struct / stackalloc 零拷贝 |
语言特性与内存管理是 C# 一切的根基:写业务靠类型与语法糖,写性能靠对内存分配的直觉。 把值/引用类型、GC、装箱这三件事内化之后,后面无论是异步、Web 还是数据访问,遇到性能问题的归因路径都会清晰得多。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。