第 1 章:值类型与引用类型的底层真相

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

第 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 面试要点

  1. 值类型和引用类型的区别?

    • 存储位置、赋值行为、默认值、相等性比较
  2. "值类型在栈上"这句话对吗?

    • 不完全对。值类型作为类的字段时在堆上,被闭包捕获时也在堆上
  3. 装箱的性能代价?

    • 堆分配 + 值复制 + GC 压力。应该尽量避免
  4. readonly struct 的作用?

    • 保证不可变性,避免防御性复制
  5. ref struct 的限制和用途?

    • 只能在栈上,不能装箱。Span<T> 就是 ref struct

练习

  1. 使用 Unsafe.SizeOf<T>() 查看不同 struct 的大小,验证内存对齐
  2. 编写代码触发装箱,使用 BenchmarkDotNet 测量性能差异
  3. 对比 structreadonly struct 在 readonly 字段上的行为
  4. 使用 Span<T> 处理数组切片,避免堆分配

下一章:String 的底层实现与优化 →

评论

登录 后发表评论

暂无评论