C# 值类型与引用类型详解:存储位置、赋值机制、参数传递、相等比较、装箱拆箱与常见陷阱
在开始之前首先祝大家中秋快乐!! 一句话核心结论:值类型"存值"------赋值时把整份数据复制一份,两份互不影响;引用类型"存地址"------赋值只复制引用,多个变量指向堆上同一个对象。
目录
0. 前置知识:栈与堆(内存两大区域)
C# 的内存分为两大区域:栈 和堆 。核心区别可以一句话概括------值类型"住"在栈上,引用类型的"门牌号(引用)"在栈上、"房子(对象本体)"在堆上。
| 栈 Stack | 堆 Heap | |
|---|---|---|
| 存放 | 局部值类型变量、引用变量的地址 | class 实例、数组、对象本体 |
| 生命周期 | 方法执行完自动释放 | GC 自动回收,时机不确定 |
| 分配速度 | 很快 | 相对慢 |
| 赋值行为 | 值类型直接复制内容 | 复制地址,指向同一个堆对象 |
展开讲:
- 栈(Stack) :线程私有、后进先出。方法调用时局部变量和参数自动入栈,方法返回时自动出栈。速度极快(只需移动栈指针),但容量小(默认约 1MB),递归过深会抛
StackOverflowException。由编译器自动管理,不需要 GC。 - 堆(Heap) :进程共享、运行时动态分配。
new出来的对象(class、string、数组、委托)都在这里。分配较慢(要寻找空闲块),生命周期不确定,由**垃圾回收器(GC)**分代回收。容量大,但存在内存碎片和 GC 停顿成本。 - 最容易混淆的点 :
int x = 42是值类型,直接放栈上;Person p = new Person()中p这个引用(地址)在栈上,而Person对象本体在堆上。所以"引用类型"指的是"变量里存的是引用",并不是"整个对象都在栈上"。 - 值/引用语义 :值类型赋值是复制一份(互不影响);引用类型赋值只复制引用(指向同一个对象,互相影响)。另外,值类型转
object会发生装箱,在堆上生成一个副本。
【插图:栈与堆内存分布示意图】
风险:递归过深 → StackOverflowException
递归会反复调用自身、不断压入栈帧。递归层数太多时栈空间被填满,就会直接抛出栈溢出异常 StackOverflowException。
GC 分代回收(Gen0 / Gen1 / Gen2)
GC 的核心机制是分代回收:
- Gen0(0 代):新创建的对象,回收频率最高;大部分对象都在 0 代被回收。
- Gen1(1 代):0 代回收后存活下来的对象,作为 0 代和 2 代之间的缓冲区。
- Gen2(2 代):长期存活的对象/大对象,回收代价最高,很少触发。
一次完整回收分为三步:标记 → 清除 → 压缩。
- 标记:从栈、静态变量等"根引用"出发,找出所有还在被使用的对象;
- 清除:把没有标记的垃圾对象所占内存标记为空闲;
- 压缩:移动存活对象、消除内存碎片(移动对象会同步修改引用地址)。
一句话记忆:栈"自动、快、小、线程私有";堆"动态、慢、大、进程共享"。 写完代码多想一想------"这个变量是被拷贝了,还是被共享了"。
1. 定义与类型体系
1.1 什么是值类型
值类型(Value Type)的变量直接保存数据本身。赋值给另一个变量时,复制的是完整的一份数据。
csharp
int a = 10;
int b = a; // b 得到 a 的"副本"
b = 99;
Console.WriteLine(a); // 输出 10(a 不受影响)
1.2 什么是引用类型
引用类型(Reference Type)的变量保存的是对象的地址(引用)。对象本身存放在托管堆上,赋值时只复制地址,不复制对象。
csharp
var a = new List<int> { 1, 2, 3 };
var b = a; // a 与 b 指向同一个对象
b.Add(4);
Console.WriteLine(a.Count); // 输出 4(a、b 共享同一个对象)
1.3 类型体系
所有类型最终都继承自 object。值类型经 System.ValueType 间接继承,引用类型则直接或间接继承 object。
#mermaid-svg-otHb88opK93k3lCq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-otHb88opK93k3lCq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-otHb88opK93k3lCq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-otHb88opK93k3lCq .error-icon{fill:#552222;}#mermaid-svg-otHb88opK93k3lCq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-otHb88opK93k3lCq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-otHb88opK93k3lCq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-otHb88opK93k3lCq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-otHb88opK93k3lCq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-otHb88opK93k3lCq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-otHb88opK93k3lCq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-otHb88opK93k3lCq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-otHb88opK93k3lCq .marker.cross{stroke:#333333;}#mermaid-svg-otHb88opK93k3lCq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-otHb88opK93k3lCq p{margin:0;}#mermaid-svg-otHb88opK93k3lCq .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-otHb88opK93k3lCq .cluster-label text{fill:#333;}#mermaid-svg-otHb88opK93k3lCq .cluster-label span{color:#333;}#mermaid-svg-otHb88opK93k3lCq .cluster-label span p{background-color:transparent;}#mermaid-svg-otHb88opK93k3lCq .label text,#mermaid-svg-otHb88opK93k3lCq span{fill:#333;color:#333;}#mermaid-svg-otHb88opK93k3lCq .node rect,#mermaid-svg-otHb88opK93k3lCq .node circle,#mermaid-svg-otHb88opK93k3lCq .node ellipse,#mermaid-svg-otHb88opK93k3lCq .node polygon,#mermaid-svg-otHb88opK93k3lCq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-otHb88opK93k3lCq .rough-node .label text,#mermaid-svg-otHb88opK93k3lCq .node .label text,#mermaid-svg-otHb88opK93k3lCq .image-shape .label,#mermaid-svg-otHb88opK93k3lCq .icon-shape .label{text-anchor:middle;}#mermaid-svg-otHb88opK93k3lCq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-otHb88opK93k3lCq .rough-node .label,#mermaid-svg-otHb88opK93k3lCq .node .label,#mermaid-svg-otHb88opK93k3lCq .image-shape .label,#mermaid-svg-otHb88opK93k3lCq .icon-shape .label{text-align:center;}#mermaid-svg-otHb88opK93k3lCq .node.clickable{cursor:pointer;}#mermaid-svg-otHb88opK93k3lCq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-otHb88opK93k3lCq .arrowheadPath{fill:#333333;}#mermaid-svg-otHb88opK93k3lCq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-otHb88opK93k3lCq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-otHb88opK93k3lCq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-otHb88opK93k3lCq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-otHb88opK93k3lCq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-otHb88opK93k3lCq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-otHb88opK93k3lCq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-otHb88opK93k3lCq .cluster text{fill:#333;}#mermaid-svg-otHb88opK93k3lCq .cluster span{color:#333;}#mermaid-svg-otHb88opK93k3lCq div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-otHb88opK93k3lCq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-otHb88opK93k3lCq rect.text{fill:none;stroke-width:0;}#mermaid-svg-otHb88opK93k3lCq .icon-shape,#mermaid-svg-otHb88opK93k3lCq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-otHb88opK93k3lCq .icon-shape p,#mermaid-svg-otHb88opK93k3lCq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-otHb88opK93k3lCq .icon-shape .label rect,#mermaid-svg-otHb88opK93k3lCq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-otHb88opK93k3lCq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-otHb88opK93k3lCq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-otHb88opK93k3lCq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} object(所有类型的基类)
System.ValueType
引用类型
简单类型:int / long / double / bool / char / decimal...
struct 结构体(DateTime、自定义)
enum 枚举
Nullable<T> 可空值类型(int?)
string 字符串(不可变)
class / record class
interface 接口
数组 T\[\]
delegate 委托 / event
说明:
System.Int32本质就是一个struct,所以所有数值类型天然都是值类型。
1.4 几个特殊成员
| 成员 | 类型 | 特殊之处 |
|---|---|---|
string |
引用类型 | 不可变 ;== 被重载为按内容比较,用起来"像值类型" |
record class |
引用类型 | 自动按成员内容比较(值语义) |
record struct |
值类型 | 结构体 + 值语义比较 |
object / dynamic |
引用类型 | dynamic 编译期类型是 object,运行时再动态解析 |
2. 存储位置与赋值机制
2.1 破除常见误区
"值类型在栈上、引用类型在堆上"这句话并不准确,它只描述了局部变量的常见情况。
真实的规则是:
- 值类型作为局部变量时,通常分配在栈上;
- 值类型作为类的字段时,跟随对象存放在堆上;
- 值类型作为数组元素时,存放在数组对象内部(堆上);
- 值类型被装箱后,也存放在堆上。
所以栈/堆只是运行时的实现细节。值类型与引用类型真正的区别只有一条:赋值时复制的是数据本身,还是复制引用。
2.2 内存示意
值类型赋值(复制数据):
text
栈(示意)
┌─────────────┐
│ a = 10 │
├─────────────┤
│ b = 10 │ ← b = a:拷贝整份数据
└─────────────┘
之后修改 b,a 不受影响
引用类型赋值(复制地址):
text
栈(示意) 堆
┌─────────────┐ ┌──────────────┐
│ p → 地址 ├─────→│ { 1, 2, 3 } │
├─────────────┤ │ (只有一份) │
│ q → 地址 ├─────→└──────────────┘
└─────────────┘
p、q 指向同一个对象,改一个全变
2.3 代码案例
csharp
// 值类型:拷贝的是数据
int x = 10;
int y = x;
y = 99;
Console.WriteLine($"x={x}, y={y}"); // x=10, y=99
// 引用类型:拷贝的是引用
var list1 = new List<int> { 1, 2, 3 };
var list2 = list1;
list2.Add(4);
Console.WriteLine($"list1.Count={list1.Count}"); // 4
Console.WriteLine(ReferenceEquals(list1, list2)); // True(同一个对象)
3. 参数传递
C# 默认所有参数都按值传递。但对两类类型,含义完全不同:
| 传参类型 | 方法内"修改对象内容" | 方法内"给参数重新赋值" |
|---|---|---|
| 值类型 | 不影响外部 | 不影响外部 |
| 引用类型 | 影响外部(改的是同一个堆对象) | 不影响外部(只改了形参里的地址副本) |
3.1 引用类型参数:改内容 vs 重新赋值
csharp
static void TryChange(List<int> list)
{
list.Add(100); // ① 修改对象内容 → 外部可见
list = new List<int>(); // ② 重新赋值 → 外部不可见
}
var list = new List<int>();
TryChange(list);
Console.WriteLine(list.Count); // 输出 1(只有 ① 生效)
这是最常见的坑:以为
list = new ...能把外部变量换成新对象,其实只改了形参里那份地址副本。
3.2 用 ref / out / in 真正按引用传递
csharp
static void RealChange(ref List<int> list)
{
list = new List<int> { 7, 8 };
}
var list2 = new List<int>();
RealChange(ref list2);
Console.WriteLine(string.Join(", ", list2)); // 输出 7, 8
对值类型同样适用:
csharp
static void AddOne(int n)
{
n++; // 只修改副本
}
static void AddOneRef(ref int n)
{
n++;
}
int num = 5;
AddOne(num);
Console.WriteLine(num); // 输出 5(值类型按值传,外部不变)
AddOneRef(ref num);
Console.WriteLine(num); // 输出 6(ref 按引用传,外部改变)
out与ref类似,但out参数在方法内必须被赋值;in用于只读地按引用传递,避免大结构体拷贝。
4. 相等性比较
- 值类型 :
==默认比较内容(结构是否相等)。 - 引用类型 :
==默认比较引用(是不是同一个对象)。 - string 例外 :
==按内容比较。 ReferenceEquals永远比较引用,与是否重写无关。
csharp
using System.Text;
int x = 5, y = 5;
Console.WriteLine(x == y); // True(值比较)
var s1 = new StringBuilder("a");
var s2 = new StringBuilder("a");
Console.WriteLine(s1 == s2); // False(两个不同的对象)
Console.WriteLine(ReferenceEquals(s1, s2)); // False
string t1 = "abc", t2 = "abc";
Console.WriteLine(t1 == t2); // True(string 按内容比较)
string 有字符串常量池(驻留池) ,相同字面量会复用;StringBuilder 没有常量池,每次 new 都是全新对象。
以字符串拼接为例,理解 string 的"不可变":
csharp
string str = "a";
str += "b";
它看起来像把 "b" 追加到了后面,真实过程是:
- 堆上原有的
"a"保持不动; - 在堆里新建一块内存 ,生成
"ab"; str的引用指向这个新对象;- 旧的
"a"变成垃圾,等待 GC 回收。
而 StringBuilder 不会反复新建对象 ,它直接在内部的字符缓冲区上追加内容、复用同一块内存,适合频繁拼接、动态组装长文本(日志、报文、SQL、串口/视觉输出字符串等)。
最后是浮点数比较:
csharp
double d1 = 0.1 + 0.2;
double d2 = 0.3;
Console.WriteLine(d1 == d2); // False(浮点精度问题)
Console.WriteLine(Math.Abs(d1 - d2) < 1e-9); // True(推荐:差值容差判断)
// 注意:float / double 比较有精度问题,建议用差值容差,不要直接用 ==
5. 装箱与拆箱
值类型转换成 object 或接口时,会在堆上创建一个包装对象,称为装箱 ;反向转换称为拆箱。
csharp
int i = 42;
object o = i; // 装箱:堆上新建包装对象
int j = (int)o; // 拆箱:要求类型精确匹配,否则抛 InvalidCastException
Console.WriteLine(j); // 42
性能注意:
- 装箱有内存分配和数据拷贝的开销,高频场景应尽量避免;
- 优先使用泛型集合
List<T>/Dictionary<TKey, TValue>,从根本上避免装箱。
csharp
// 避免装箱:使用泛型集合
var list = new List<int> { 1, 2, 3 };
6. 常见陷阱
陷阱 1:struct 改不动集合元素
list[i] 返回的是一个副本,直接修改它会编译报错:
csharp
struct Point
{
public int X;
public int Y;
}
var points = new List<Point>();
points.Add(new Point { X = 1, Y = 2 });
// points[0].X = 5; // 编译错误:不能修改"索引器返回的临时变量"
Point p = points[0]; // 先取出副本
p.X = 5; // 修改副本
points[0] = p; // 再放回集合
Console.WriteLine(points[0].X); // 输出 5
原因:
struct是值类型,存在 List 里时,list[下标]取出来的是一份拷贝出来的复制品,而不是盒子里的原件。
陷阱 2:引用类型参数重新赋值无效
见第 3.1 节:修改对象内容生效,替换对象不生效;要在方法内替换外部对象,必须使用 ref。
陷阱 3:引用类型默认值为 null
csharp
int n = default; // 0
bool b = default; // false
string s = default; // null ------ 使用前必须判空
// int x = null; // 编译错误:值类型不能为 null(除非可空)
int? y = null; // 可以:可空值类型
引用类型不判空就直接使用,会抛出
NullReferenceException。
陷阱 4:浅拷贝 vs 深拷贝
引用类型默认的拷贝(MemberwiseClone)是浅拷贝 :克隆出的新对象仍然共享内部的引用成员。
csharp
class Order
{
public List<string> Items { get; set; } = new();
}
var order1 = new Order();
var order2 = order1; // 两个变量指向同一个对象(共享)
// 需要独立副本时,要自己做深拷贝(复制每个引用成员)
var order3 = new Order();
order3.Items = new List<string>(order1.Items); // 新建列表,彼此独立
浅拷贝(
MemberwiseClone)只创建新对象、内部引用成员仍共享;深拷贝把对象及其内部所有引用对象全部复制,两个实例完全独立。
7. 判断口诀
类、接口、数组、委托、字符串 → 引用类型;结构体、枚举、数字、布尔、字符 → 值类型。
记不清时可以用代码兜底:
csharp
typeof(int).IsValueType; // True → 值类型
typeof(string).IsValueType; // False → 引用类型
typeof(DateTime).IsValueType; // True(DateTime 是 struct)
附录:速查表
| 维度 | 值类型 | 引用类型 |
|---|---|---|
| 典型成员 | int、double、bool、char、decimal、struct、enum | string、class、interface、数组、delegate |
| 赋值语义 | 复制整份数据 | 复制引用(共享对象) |
| 修改影响 | 互不影响 | 通过任一变量修改,都作用于同一对象 |
| 默认值 | 0 / false | null |
== 默认行为 |
比较内容 | 比较引用(string、record 例外) |
| 能否为 null | 默认不能 | 可以 |
| 典型存储 | 局部变量在栈;作字段/数组元素时在堆 | 引用在栈(或内联),对象在堆 |
