1.3 值类型与引用类型的内存模型


1.3 值类型与引用类型的内存模型

本节摘要:值类型变量直接持有数据,引用类型变量持有的是地址。本节用可运行的观察代码展示两种类型在赋值、传参、相等比较时的行为差异,绘制内存布局图,并实测装箱拆箱的性能代价——这是全书最重要的地基一节。

一个能测出区别的小实验

先做实验再讲理论。新建结构体与类各一个,成员完全相同:

struct PointV { public int X, Y; } class PointR { public int X, Y; } var v1 = new PointV { X = 1, Y = 2 }; var v2 = v1; // 值拷贝:v2 是独立副本 v2.X = 99; var r1 = new PointR { X = 1, Y = 2 }; var r2 = r1; // 引用拷贝:r2 与 r1 指向同一对象 r2.X = 99; Console.WriteLine($"值类型 v1.X = {v1.X}"); // 1,未受影响 Console.WriteLine($"引用类型 r1.X = {r1.X}"); // 99,被同步修改

v2.X = 99 之后 v1.X 纹丝不动,而 r2.X = 99 之后 r1.X 跟着变。原因只有一个:赋值时,值类型把 8 个字节的数据原样复制了一份;引用类型只复制了 8 字节的堆地址,两个变量指向堆上同一个对象。

内存布局图

图 值类型与引用类型的存储对比

图 值类型与引用类型的存储对比

值类型通常分配在栈上(准确说:随其所在的存储位置——局部变量在栈上,作为类字段时随对象在堆上),生命周期与方法调用栈同步,方法返回即消亡,不劳 GC 动手。引用类型的实例永远在托管堆上,每个对象带 8 字节对象头加方法表指针(32 位下减半),由 GC 追踪回收。这也意味着创建一百万个 PointV 几乎不惊动 GC,创建一百万个 PointR 则是实实在在的分配压力——第四章 GC 分代一节会量化这件事。

相等比较的行为分裂

Console.WriteLine(v1.Equals(v2)); // True:逐字段比较 Console.WriteLine(ReferenceEquals(r1, r3)); // False:地址不同即不等

值类型的 Equals 默认逐字段比较(结构体越大反射开销越明显,可重写或实现 IEquatable<T> 优化);引用类型的 Equals 默认比地址,string 是著名的例外——它重写了相等语义为内容比较,所以两个内容相同的不同字符串实例判等返回真。这种"例外"正是下一节字符串话题的伏笔。

装箱:值类型混进引用世界的通行费

CTS 规定一切类型最终可视为 object。值类型要装进 object、接口或非泛型集合时,编译器会生成装箱指令:在堆上分配一个小对象,把值复制进去。反方向是拆箱,做显式类型转换时发生,从堆对象拷回栈。

装箱极其隐蔽,看这个真实案例——统计非泛型 ArrayList 里的整数:

var list = new ArrayList(); for (int i = 0; i < 1_000_000; i++) list.Add(i); // 每次调用都是一次装箱 int sum = 0; foreach (int x in list) sum += x; // 每次迭代都是一次拆箱

一百万次装箱意味着一百万次堆分配。换成 List<int>,泛型集合对值类型做了专门具化(第三章详述),存储就是连续的 int 数组,零装箱。两段代码的功能一模一样,性能差距在十倍量级。用领域知识类比:装箱像货币兑换,每次跨越值/引用边界都要付一次手续费,频繁小额兑换的成本会吞掉收益——正确做法是尽量留在同一侧。

还有一些隐蔽装箱点值得记:调用值类型的 GetType(返回值本身是引用)、把结构体传给接收 object 的方法、字符串拼接值类型、枚举作为字典键时的某些哈希调用。识别手段是对怀疑的代码跑内存分析工具,看分配栈里是否出现装箱指令。

结构体还是类:一个工程决策

选 struct 的条件要苛刻:数据小(一般 16 字节以内)、不可变、生命周期短、逻辑上是"一个值"(坐标、金额、颜色)。不满足就老老实实用 class——可变结构体是 C# 头号坑源,方法内修改通过接口传入的结构体属性,改动落在副本上,编译器还不报错。历史上著名的教训是某些图形库的可变 Point 结构引发的"改了没生效"系列 bug。

💡 关键直觉:看到 struct 关键字,脑中应立刻浮现两件事——赋值即拷贝、混入引用世界要装箱。这两件事决定你九成的结构体使用错误。

本节要点回顾

  • 赋值语义是分水岭:值类型拷数据、引用类型拷地址,实验代码可随时验证;
  • 生命周期归属不同:值类型随栈或宿主对象消亡,引用对象交给 GC;
  • 装箱有真实价格:一次堆分配加一次拷贝,非泛型集合是重灾区,泛型是解药;
  • string 是引用里的例外:内容相等语义,详见下一节;
  • struct 门槛:小、不可变、值语义,否则选 class。

下一节把镜头移到变量声明本身:const 与 readonly、var 与 dynamic,编译器在哪些时刻替你做决定,又在哪些时刻把决定推迟。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U