目录

C#(.NET)-内存相关

  1. 栈、堆:内存怎么放
  2. struct、class: 类型怎么定义
  3. 值类型、引用类型:变量里装的是什么
  4. 深拷贝、浅拷贝:复制时复制到什么程度
  5. 装箱、开箱:值类型怎么变成 object
  6. 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?

  1. x 是值类型
  2. CLR 在堆上创建了一个对象
  3. 把 123 复制进这个对象
  4. obj 引用这个堆对象

这就是装箱

开箱

object obj = 123;
int x = (int)obj;

What happend?

  1. 先确定 obj 里真的是一个装箱后的 int
  2. 再把里面的值复制出来给 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 在堆对象内部。

总结

  1. 栈和堆是内存区域。
  2. class 是引用类型,struct 是 值类型。
  3. 值类型变量保存数据本身;引用类型变量保存对象引用。
  4. 值类型不一定在栈上,引用类型不一定在堆上。
  5. 引用类型赋值是复制引用,不是复制对象。
  6. 浅拷贝复制第一层;深拷贝递归复制引用对象。
  7. GC只管理托管堆上的对象,不管理栈。
概念 本质 关键点
方法调用的临时内存 快,自动回收,线程私有
对象存储区域 生命周期不固定,由 GC 管理
struct 值类型定义定义方式 赋值复制数据
class 引用类型定义方式 赋值赋值引用
值类型 变量保存数据本身 不等于一定在栈上
引用类型 变量保存对象引用 对象通常在堆上
浅拷贝 只赋值第一层 内部引用对象仍共享
深拷贝 递归赋值对象图 相互独立
装箱 值类型变 object /接口 通常要堆分配
开箱 从装箱对象取回值 需要类型分配
GC 回收不可达堆对象 不负责栈,不等于即使释放资源

简化版:

  • 局部简单值:通常像放在栈上的临时数据
  • 对象实例:通常在堆上,由 GC 管
  • 变量是 class 类型:你手里拿的是“门牌号”,不是房子本体
  • 变量是 struct 类型:你手里拿的是“内容本身”
  • a = b
    • 如果是 struct:复制内容
    • 如果是 class:复制地址
  • 深浅拷贝只在“对象内部还有引用对象”时有意义
  • 装箱就是把值类型塞进一个 object 盒子里
  • GC 只回收没人再能找到的堆对象