第 1 章:值类型与引用类型的底层真相
jerry2026年4月9日C# 12 次阅读 约 14 分钟

第 1 章:值类型与引用类型的底层真相
深入理解栈与堆的分配策略、装箱拆箱的性能代价、struct 的内存布局,以及 Span<T> 如何避免堆分配。
1.1 值类型 vs 引用类型
这是 .NET 类型系统最根本的划分:
| 特性 | 值类型(Value Type) | 引用类型(Reference Type) |
|---|---|---|
| 继承自 | System.ValueType |
System.Object |
| 存储位置 | 通常在栈上(或内联在父对象中) | 堆上 |
| 赋值行为 | 复制整个值 | 复制引用(指针) |
| 默认值 | 各字段的零值 | null |
| 相等性 | 按值比较 | 按引用比较(除非重写) |
| 代表类型 | int, double, bool, struct, enum |
class, string, array, delegate, interface |
// 值类型:赋值是复制
int a = 42;
int b = a; // b 是 a 的副本
b = 100;
Console.WriteLine(a); // 42(a 不受影响)
// 引用类型:赋值是复制引用
var list1 = new List<int> { 1, 2, 3 };
var list2 = list1; // list2 和 list1 指向同一个对象
list2.Add(4);
Console.WriteLine(list1.Count); // 4(list1 也被影响了)
1.2 栈与堆
栈(Stack)
- 每个线程有自己的栈(默认 1MB)
- LIFO(后进先出),分配和释放极快(只需移动栈指针)
- 方法调用时分配栈帧,方法返回时自动释放
- 存储:局部值类型变量、方法参数、返回地址
堆(Managed Heap)
- 所有线程共享
- 由 GC 管理分配和释放
- 分配速度也很快(指针递增),但释放需要 GC 介入
- 存储:引用类型的实例、装箱后的值类型
真正的分配规则
"值类型在栈上,引用类型在堆上"这句话不完全准确。更准确的说法是:
class MyClass
{
int x; // x 是值类型,但它在堆上(因为是 class 的字段)
string name; // name 的引用在堆上,string 对象也在堆上
}
void Method()
{
int local = 42; // local 在栈上(局部变量)
var obj = new MyClass(); // obj 引用在栈上,MyClass 实例在堆上
// obj.x 在堆上(内联在 MyClass 实例中)
}
规则总结:
- 局部值类型变量 → 栈
- 引用类型实例 → 堆
- 值类型作为引用类型的字段 → 堆(内联在对象中)
- 被捕获的局部变量(闭包、async)→ 堆(编译器生成的类的字段)
1.3 装箱与拆箱
装箱(Boxing)
将值类型转换为引用类型(object 或接口):
int x = 42;
object obj = x; // 装箱:在堆上分配内存,复制值
装箱的过程:
1. 在堆上分配内存(对象头 + 方法表指针 + 值的副本)
2. 将值复制到堆上的新对象中
3. 返回堆上对象的引用
栈: 堆:
┌─────┐ ┌──────────────────┐
│ obj ─┼──────────→│ 对象头 (8 bytes) │
└─────┘ │ 方法表指针 │
│ 值: 42 │
└──────────────────┘
拆箱(Unboxing)
将装箱的对象转换回值类型:
object obj = 42; // 装箱
int x = (int)obj; // 拆箱:检查类型 + 复制值
装箱的性能代价
// 隐式装箱的常见场景
// 1. 值类型赋值给 object
object obj = 42; // 装箱
// 2. 值类型赋值给接口
IComparable comp = 42; // 装箱
// 3. 字符串插值中的值类型(.NET 6 前)
int x = 42;
string s = $"值是 {x}"; // .NET 6 前会装箱,.NET 6+ 优化了
// 4. 非泛型集合
ArrayList list = new ArrayList();
list.Add(42); // 装箱
// 5. 值类型调用 object 的虚方法(如果没有重写)
struct MyStruct { }
var s = new MyStruct();
s.GetHashCode(); // 如果没有重写,会装箱
s.ToString(); // 如果没有重写,会装箱
避免装箱的方法:
- 使用泛型集合(
List<int>而不是ArrayList) - 值类型重写
ToString()、GetHashCode()、Equals() - 使用泛型约束而不是
object参数 - 实现
IEquatable<T>接口
1.4 struct 的内存布局
默认布局(SequentialLayout)
struct Point
{
public int X; // 4 bytes
public int Y; // 4 bytes
}
// 总大小:8 bytes,字段按声明顺序排列
内存对齐
struct BadLayout
{
public byte A; // 1 byte + 3 padding
public int B; // 4 bytes
public byte C; // 1 byte + 3 padding
}
// 总大小:12 bytes(有 6 bytes 浪费)
struct GoodLayout
{
public int B; // 4 bytes
public byte A; // 1 byte
public byte C; // 1 byte + 2 padding
}
// 总大小:8 bytes
控制布局
using System.Runtime.InteropServices;
// 显式指定每个字段的偏移量
[StructLayout(LayoutKind.Explicit)]
struct Union
{
[FieldOffset(0)] public int IntValue;
[FieldOffset(0)] public float FloatValue; // 与 IntValue 共享内存
}
// 按 1 字节对齐(无填充)
[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct Packed
{
public byte A; // 1 byte
public int B; // 4 bytes
public byte C; // 1 byte
}
// 总大小:6 bytes(无填充,但可能影响性能)
1.5 readonly struct 与 ref struct
readonly struct
所有字段都是只读的,编译器保证不可变性:
readonly struct Vector3
{
public readonly float X;
public readonly float Y;
public readonly float Z;
public Vector3(float x, float y, float z) => (X, Y, Z) = (x, y, z);
// readonly struct 的方法不会产生防御性复制
public float Length() => MathF.Sqrt(X * X + Y * Y + Z * Z);
}
为什么要用 readonly struct?
// 普通 struct 作为 readonly 字段时,调用方法会产生防御性复制
struct MutableStruct
{
public int Value;
public void Increment() => Value++;
}
class Container
{
readonly MutableStruct _field;
void Test()
{
_field.Increment(); // 编译器会复制 _field,在副本上调用
// _field.Value 不会改变!这是一个隐蔽的 bug
}
}
// readonly struct 不会有这个问题
ref struct
只能存在于栈上,不能装箱、不能作为类的字段、不能在 async 方法中使用:
ref struct StackOnlyType
{
public Span<int> Data; // Span<T> 本身就是 ref struct
}
// 不能做的事:
// object obj = new StackOnlyType(); // 编译错误:不能装箱
// class MyClass { StackOnlyType field; } // 编译错误:不能作为类字段
// async Task Foo() { StackOnlyType x; } // 编译错误:不能在 async 中使用
Span<T> 和 ReadOnlySpan<T> 就是 ref struct,这保证了它们永远不会被分配到堆上。
1.6 record struct(C# 10+)
// record struct:值类型 + record 语义
record struct Point(int X, int Y);
var p1 = new Point(1, 2);
var p2 = new Point(1, 2);
Console.WriteLine(p1 == p2); // True(按值比较)
Console.WriteLine(p1); // Point { X = 1, Y = 2 }
// 解构
var (x, y) = p1;
// with 表达式(创建副本并修改)
var p3 = p1 with { X = 10 }; // Point { X = 10, Y = 2 }
1.7 面试要点
-
值类型和引用类型的区别?
- 存储位置、赋值行为、默认值、相等性比较
-
"值类型在栈上"这句话对吗?
- 不完全对。值类型作为类的字段时在堆上,被闭包捕获时也在堆上
-
装箱的性能代价?
- 堆分配 + 值复制 + GC 压力。应该尽量避免
-
readonly struct 的作用?
- 保证不可变性,避免防御性复制
-
ref struct 的限制和用途?
- 只能在栈上,不能装箱。Span<T> 就是 ref struct
练习
- 使用
Unsafe.SizeOf<T>()查看不同 struct 的大小,验证内存对齐 - 编写代码触发装箱,使用 BenchmarkDotNet 测量性能差异
- 对比
struct和readonly struct在 readonly 字段上的行为 - 使用
Span<T>处理数组切片,避免堆分配
评论
登录 后发表评论
暂无评论