C#(.NET)-内存相关
- 栈、堆:内存怎么放
- struct、class: 类型怎么定义
- 值类型、引用类型:变量里装的是什么
- 深拷贝、浅拷贝:复制时复制到什么程度
- 装箱、开箱:值类型怎么变成 object
- GC:堆上的内存怎么回收
一、栈(stack)和 堆(Heap)到底是什么
栈
栈是线程私有的一块内存,主要用于:
- 方法调用信息
- 局部变量
- 参数
- 返回地址
特点:
- 分配非常快
- 回收也非常快
- 按 “后进先出” 工作
- 方法结束,对应栈帧就弹出
栈式方法执行时的临时工作台。
堆
在 .NET 里,通常我们说的是托管堆(Managed Heap)
主要存放:
- 用 new 闯将的大多数引用类型对象
- 装箱后的值类型对象
- 数组
- 字符串
- 委托等
特点:
- 生命周期不跟方法严格绑定
- 回收不是立刻的,而是由 GC 管理
- 适合存活时间不确定的对象
堆是程序运行时的大仓库
Example:
void Demo()
{
int a = 10;
Point p = new Point(1, 2);
Person person = new Person();
}- a:值类型局部变量,通常在栈上
- p:Point 如果是结构体,通常作为值直接存放栈帧里
- person:局部变量本身通常在栈上,但它保存的是一个引用
- new Person():创建的对象本体通常在堆上
也就是说:
- 栈上可能放引用
- 堆上也可以直接放值
二、class、struct、值类型、引用类型的关系
class 定义的是 引用类型,struct 定义的是 值类型。
class
class Person
{
public string Name;
}Person 是引用类型。
变量里存的不是对象本体,而是“对象的引用”。
Person p1 = new Person();
Person p2 = p1;此时:
- p1 和 p2 保存的是同一个对象的引用
- 它们指向同一个堆对象
struct
struct Point
{
Point int X;
Point int Y;
}Point 是值类型。
变量里存的是数据本身。
Point p1 = new Point { X = 1, Y = 2};
Point p2 = p1;
p2.X = 100;结果:
- p1.X 还是 1
- p2.X 是 100
因为 p2 = p1 时,复制的是整个值。
三、值类型和引用类型,到底差别在哪
值类型
值类型变量保存的是实际数据。
- int
- double
- bool
- char
- DateTime
- decimal
- enum
- struct
- ValueTuple
- Nullable
引用类型
引用类型变量保存的是对象引用,而不是对象本身。
- class
- string
- object
- array
- delegate
- interface
注意:字符串变量实际上是在堆上分配了一块内存控件,这个控件用来存储字符串的内容。而字符则是值类型,它存储在栈上,所以字符的性能要比字符串好很多
最核心的区别:复制行为不同
值类型复制:复制数据
int a = 10;
int b = a;
b = 20;result:
- a == 10
- b == 20
引用类型赋值:复制引用
Person p1 = new Person();
p1.Name = "Tom";
Person p2 = p1;
p2.Name = "Jerry";result:
- p1.Name == “Jerry”
- p2.Name == “Jerry”
因为 p1 和 p2 指向同一个对象。
四、一个误区:“值类型都在栈上,引用类型都在堆上”
这个说法不准确,正确的说法应该是:
- 值类型通常"按值保存"
- 引用类型通常"通过引用访问对象"
- 具体在栈上还是堆上,要看它出现的位置
值类型不一定在栈上
Example:
class MyClass
{
public int Age;
}Age 是值类型,但它是 MyClass 对象的一部分。
而 MyClass 对象在堆上,所以 Age 也在这个堆对象内部。
int[] arr = new int[100];数组是引用类型,数组对象在堆上。
数组里的 100 个 int 对象,也是在这个堆上的数组对象内部,不是在栈上。
引用类型变量不一定在堆上
Person p = new Person();- p 这个局部变量通常在栈上
- 但 p 里面保存的是一个指向堆对象的引用
所以不要把"变量"和"对象本体"混为一谈。
五、new 不等于一定在堆上
Point p = new Point();如果 Point 是 struct,这里 new 的意思是:
- 创建并初始化一个值
它不代表"一定去堆上分配"。
所以:
- new 在 C# 里更像是"创建实例/初始化"的语义,不要简单理解成"去堆上申请内存"。
六、参数传递
C# 默认是按值传递,是变量按值传递。
值类型参数
void Change(int x)
{
x = 100;
}
int a = 10;
Change(a);result:
- a 还是 10,因为传进去的是 a 的一个副本。
引用类型参数
void Change(Person p)
{
p.Name = "jerry";
}
Person person = new Person { Name = "Tom" };
Change(person);result:
- person.Name = “Jerry”
why?:
- 因为传进去的是引用副本。副本和原引用都指向同一个对象,所以修改对象内容能看到。
But if this:
void Change(Person p)
{
p = new Person { Name = "New" };
}调用后,外面的 person 不会变成新对象。
因为只是改了方法内部那个"引用副本"。
“引用类型变量保存的是引用,而不是对象本身”。
七、深拷贝、浅拷贝、引用复制
1.引用复制
Person p2 = p1;这不是"拷贝对象",只是复制引用。
- p1
- p2
指向同一个对象。
2.浅拷贝
创建一个新对象,但只复制第一层字段。
对于值类型字段:复制值
对于引用类型字段:复制引用
Example:
class Address
{
public string City { get; set; }
}
class Person
{
public string Name { get; set; }
public Address Address { get; set; }
public Person ShallowCopy()
{
return (Person)this.MemberwiseClone();
}
}Use:
Person p1 = new Person
{
Name = "Tom",
Address = new Address { City = "Beijing" }
};
Person p2 = p1.ShallowCopy();
p2.Name = "Jerry";
p2.Address.City = "Shanghai";Result:
- p1.Name 还是 “Tom”,因为 Name 还是 string 引用,但字符串不可变,重新赋值指向新字符串
- p1.Address.City 变成了 “Shanghai”,因为 p1.Address 和 p2.Address 指向同一个 Address
3.深拷贝
不仅拷贝当前对象,还递归拷贝它引用到得对象。
class Person
{
public string Name { get; set; }
public Address Address { get; set; }
public Person DeepCopy()
{
return new Person
{
Name = this.Name,
Address = this.Address == null
? null
: new Address { City = this.Address.City }
};
}
}Result:
- p1.Name:“Tom”,p1.Address.City:“Beijing”
- p2.Name:“Jerry”,p2.Address.City:“Shanghai”
此时 p1.Address 和 p2.Address 就不是同一个对象了。
对 struct 得一个细节
结构体复制通常是"整块复制"
但如果结构体内部有引用类型字段,那复制过去得还是引用
Example:
struct Wrapper
{
public StringBuilder Builder;
}Wrapper w1 = new Wrapper { Builder = new StringBuilder("A") };
Wrapper w2 = w1;
w2.Builder.Append("B");Result:
- w1.Builder:“AB”,w2.Builder:“AB”
- w1.Builder 变了 ,因为复制的是 Builder 这个引用。
So:
- “值类型复制,不代表内部引用对象也会被深拷贝。”
八、装箱(Boxing)和 开箱(Unboxing)
概念本质:“把值类型"包装"成一个引用类型对象”
装箱
int x = 123;
object obj = x;What happend?
- x 是值类型
- CLR 在堆上创建了一个对象
- 把 123 复制进这个对象
- obj 引用这个堆对象
这就是装箱
开箱
object obj = 123;
int x = (int)obj;What happend?
- 先确定 obj 里真的是一个装箱后的 int
- 再把里面的值复制出来给 x
这就是开箱
NOTICE:开箱必须类型匹配
object obj = 123;
long x = (long)obj; // 异常虽然 int 可以转 long,但这里不行。
因为 obj 里装箱的真实类型是 int, 你不能直接按 long 开箱。
正确写法:
long x = (int)obj;装箱的代价
- 堆分配
- 值复制
- GC压力
所以在性能敏感代码里要尽量避免。
WPF 种常见的装箱场景
1) object 型 API
很多框架 API 接受 object,比如依赖属性底层的:
SetValue(DependencyProperty dp, object value)如果传入 int、double、bool 之类的值类型,通常会发生装箱。
2) 非泛型集合
ArrayList list = new ArrayList();
list.Add(123); // 装箱而:
List<int> list = new List<int>();
list.Add(123); // 不装箱九、GC到底回收什么
主要负责:“回收托管堆上不再被引用的对象所占用的内存”
它不管栈,因为栈会随着方法返回自动释放。
GC怎么判断对象该不该回收
GC会从一组" 根对象(GC Roots)" 开始找
常见根对象包括:
- 栈上的引用
- 静态字段
- CPU 寄存器种的引用
- 一些运行时句柄
如果一个堆对象从这些根出发再也找不到,它就被认为是"不可达对象",可以回收。
Example:
void Demo()
{
Person p = new Person();
}方法执行期间:
- 栈上有一个引用 p
- 它指向堆上的 Person 对象
方法结束后:
- 栈帧弹出
- p 这个引用消失
- 如果没有别的地方再引用这个 Person
- 它就变成可回收对象
但不是"立刻释放",而是等待 GC 合适的时候回收
GC 是分代的
.NET GC 里通常有:
- Gen 0 :新创建、短命对象
- Gen 1 :中间代
- Gen 2 :存货较久对象
大多数临时对象死得快,所以先从 Gen 0 回收,效率更高。
GC还会整理内存
GC不只是"清垃圾",还可以做压缩整理,让堆更紧凑,减少碎片
这也是为什么不能把托管对象地址当成固定地址长期使用。
GC不等于释放一切资源
GC主要回收的是内存。
像下面这些资源不应该只依赖 GC:
- 文件句柄
- 数据库连接
- Socket
- GDI 资源
- 非托管资源
这些应该用:
- IDisposable
- using
WPF里一个很实际的问题:内存泄漏
GC不是万能的。
如果对象"还有引用链",哪怕你觉得它"应该没用了",GC也不会回收
- 事件订阅没取消
- 静态对象引用页面/控件/ViewModel
- 缓存把对象长期持有
- 闭包把对象抓住
所以很多 WPF “内存泄漏"其实不是 GC 失效,而是对象仍然可达。
Example
struct Point
{
public int X;
public int Y;
}
class Person
{
public string Name;
public Point Location;
}void Demo()
{
Point p = new Point { X = 1, Y = 2 };
Person person = new Person();
person.Name = "Tom";
person.Location = p;
}栈上
- p:保存 { X=1, Y=3 }
- person:保存一个引用
堆上
Person 对象:
- Name:一个字符串引用
- Location:一个 Point 值,直接嵌再 Person 对象内部
注意:这里 Location 虽然是值类型,但它属于 Person 对象的一部分,所以跟着 Person 在堆对象内部。
总结
- 栈和堆是内存区域。
- class 是引用类型,struct 是 值类型。
- 值类型变量保存数据本身;引用类型变量保存对象引用。
- 值类型不一定在栈上,引用类型不一定在堆上。
- 引用类型赋值是复制引用,不是复制对象。
- 浅拷贝复制第一层;深拷贝递归复制引用对象。
- GC只管理托管堆上的对象,不管理栈。
| 概念 | 本质 | 关键点 |
|---|---|---|
| 栈 | 方法调用的临时内存 | 快,自动回收,线程私有 |
| 堆 | 对象存储区域 | 生命周期不固定,由 GC 管理 |
| struct | 值类型定义定义方式 | 赋值复制数据 |
| class | 引用类型定义方式 | 赋值赋值引用 |
| 值类型 | 变量保存数据本身 | 不等于一定在栈上 |
| 引用类型 | 变量保存对象引用 | 对象通常在堆上 |
| 浅拷贝 | 只赋值第一层 | 内部引用对象仍共享 |
| 深拷贝 | 递归赋值对象图 | 相互独立 |
| 装箱 | 值类型变 object /接口 | 通常要堆分配 |
| 开箱 | 从装箱对象取回值 | 需要类型分配 |
| GC | 回收不可达堆对象 | 不负责栈,不等于即使释放资源 |
简化版:
- 局部简单值:通常像放在栈上的临时数据
- 对象实例:通常在堆上,由 GC 管
- 变量是 class 类型:你手里拿的是“门牌号”,不是房子本体
- 变量是 struct 类型:你手里拿的是“内容本身”
- a = b
- 如果是 struct:复制内容
- 如果是 class:复制地址
- 深浅拷贝只在“对象内部还有引用对象”时有意义
- 装箱就是把值类型塞进一个 object 盒子里
- GC 只回收没人再能找到的堆对象