【C#】理解.NET内存机制:堆、栈与装箱拆箱的底层逻辑及优化技巧

发布于:2025-09-12 ⋅ 阅读:(24) ⋅ 点赞:(0)


前言

编写一个健壮的程序离不开对资源的高效利用,这里说的无非就是内存,算力。我们基于.NET平台编写程序的时候,了解内存机制,对程序的性能与运行稳定性都会有帮助。

本篇文章将介绍堆(Heap)和栈(Stack)这两种基础内存区域,了解程序运行的时候堆和栈是如何决定数据的存储与访问方式。并且探究装箱与拆箱是如何偷走我们程序的内存和无端消耗资源的,以及如何去避免。下面就开始深入理解堆与栈。


一、栈与堆

程序运行时,CLR 在操作系统提供的虚拟地址空间基础上的,将虚拟内存空间划分和管理为多个区域,其中堆和栈是 C# 中最核心的两个数据存储区域(也可以称之为托管堆,托管栈)。这两者采用不同的数据结构,存储的内容也不同,性能差异上也有巨大的差异。下面分别就二者的设计目的分别介绍。
CLR

CLR是.NET框架的核心组件,负责C#这类托管代码的执行。运行在NET平台上的程序,其内存管理正是由CLR调度,CLR在操作系统提供的虚拟地址空间基础上划分托管内存区域,这正包括了堆和栈。

值类型和引用类型

值类型和引用类型是 C# 类型的两个主要类别。 值类型的变量包含类型的实例。 它不同于引用类型的变量,后者包含对类型实例的引用。 默认情况下,在分配中,通过将实参传递给方法并返回方法结果来复制变量值。
对于值类型,每个变量都有其自己的数据副本,并且一个变量上的作不会影响另一个变量。
对于引用类型,两种变量可引用同一对象。因此,对一个变量执行的操作会影响另一个变量所引用的对象。

1.1 栈(Stack)

1.1.1 基本信息

栈是一种先进后出(LIFO)的连续内存区域,由CLR自动管理分配,它存储的是值类型、引用类型的引用和方法上下文。

  1. 值类型无非就整型数值(sbyte,byte,short,ushort,int,uint,long,ulong,nint,nuint)浮点类型(float,double,decimal)、布尔型(bool),字符型(char),枚举类型(enum)和结构类型(struct)。
  2. 引用类型的实际数据存储在堆中,但其 "指针"存储在栈上,也就是引用类型的引用。
  3. 方法的上下文内容包括方法参数、局部变量、和返回地址等。其中局部变量包括值类型和引用类型的引用

当程序调用一个方法的时候,CLR会在栈上创建一个栈帧(Stack Frame)。这个栈帧用于存储方法的参数;方法内的局部变量,如果是值类型就存它本身,引用类型存储其引用类型的引用;方法执行完后回到调用处的位置的返回地址。

1.1.2 特点

栈的内存分配是连续的,由CLR自动管理。由于它是一片连续的内存,无需复杂操作就能实现入栈 和出栈,分配和释放速度极快。当一段方法执行完毕,也就是数据超出了作用域范围,其栈上的内存会被自动释放。但是栈的内存空间很小,几MB的大小,不适合存储大批量数据。

回想在基于C语言的开发中,经常是手动申请栈空间和手动释放栈内存,稍有不慎就会造成栈溢出。

1.2 堆(Heap)

1.2.1 基本信息

比起小且连续的栈。堆是一种无序结构的大内存区域。.NET的GC(垃圾回收器)自动管理内存的分配和释放。堆主要用来存储引用类型本身。

引用类型大致可以分成两类,一类是需要用显式声明引用类型(class,interface,delegate,record),还有一类是.NET内置的基础引用类型(dynamic类型,object,string)

1.2.2 特点

前面提到堆是无序结构的大内存区域,在堆上面内存分配需要查找可用空间。对堆内存的释放也依赖 GC的定期清理,这里面是有一部分的性能开销存在的。虽然开发者无需手动释放堆内存,GC 会自动回收不再被引用的对象。但是频繁分配和释放可能导致不连续的空闲空间,GC虽然也会自动进行压缩操作会缓解但也有开销的存在。这种不连续的空闲空间进一步减慢了分配速度。

1.3 从代码中窥见堆栈

分别声明一个结构体(值类型)和一个类(引用类型),结构体是存储在栈上,类的实例存储在堆上,变量仅保存引用地址存放在栈上。

值类型之间的复制传递的是栈上的值,也就是复制一个新的结构体的时候,是在栈上开辟一个新的空间保存原始结构体的值。

引用类型之间复制虽然本质上传递的也是栈上的引用,复制一个新的类的时候,也会在栈上开辟一个空间存储类型的引用。这个引用地址指向堆,也就是类实例实际存放数据的位置。

这种特性就引申出了一个经典的话题,深拷贝和浅拷贝。
对于值类型来说,原始值类型和被复制的值类型之间数据是相互独立的,它们保存在栈上的不同空间。对其中一个的修改,不会影响到对方。
对于引用类型,赋值时复制的是引用。原始对象和复制对象之间的在栈上虽然不是保存在一个位置,但是保存的都是同一个引用。也就是说如果通过其中一个栈上引用找到的堆上数据进行修改,也会影响到另一个对象。

Console.WriteLine("================== 结构体(栈存储)===========================");
StackItem item1 = new StackItem(1, "原始结构体");
StackItem item2 = item1;  //复制整个值到栈上的新位置
Console.WriteLine($"修改前 - item1: Id={item1.Id}, Data={item1.Data}");
Console.WriteLine($"修改前 - item2: Id={item2.Id}, Data={item2.Data}");
item2.Id = 2;
item2.Data = "修改后结构体";  //只修改栈上的副本
Console.WriteLine($"修改后 - item1: Id={item1.Id}, Data={item1.Data}");  //原始值不变
Console.WriteLine($"修改后 - item2: Id={item2.Id}, Data={item2.Data}");  //副本被修改

Console.WriteLine("================== 类(堆存储)===========================");
HeapItem obj1 = new HeapItem(1, "原始对象");  // 对象在堆上,obj1是栈上的引用
HeapItem obj2 = obj1;  // 复制引用(栈上的地址),指向同一个堆对象

Console.WriteLine($"修改前 - obj1: Id={obj1.Id}, Data={obj1.Data}");
Console.WriteLine($"修改前 - obj2: Id={obj2.Id}, Data={obj2.Data}");

obj2.Id = 2;
obj2.Data = "修改后对象";  //通过引用修改堆上的同一个对象

Console.WriteLine($"修改后 - obj1: Id={obj1.Id}, Data={obj1.Data}");  // 原始对象被修改
Console.WriteLine($"修改后 - obj2: Id={obj2.Id}, Data={obj2.Data}");  // 引用指向的对象被修改

public struct StackItem {
    public int Id;
    public string Data; 
    public StackItem(int id, string data)
    {
        Id = id;
        Data = data;
    }
}

public class HeapItem
{
    public int Id;
    public string Data;
    public HeapItem(int id, string data)
    {
        Id = id;
        Data = data;
    }
}

二、装箱与拆箱

值类型和引用类型是之间是能相互转换的,比如object是所有类型的最终基类,自然也是值类型的基类。特定条件下值类型能转换成object,object也能转换为值类型。前者值类型转换成引用类型称之为装箱,后者引用类型转换为值类型称之为拆箱。

值类型与引用类型之间转换的两种操作背后是内存里栈和堆的转换。这里面涉及内存分配、数据复制和类型检查等过程,理解装箱与拆箱能帮我们注意到各种容易引起性能消耗的陷阱。

2.1 装箱

将值类型转换为引用类型的过程,称为装箱。

值类型是存储在栈上,而引用类型的实际数据是存储在堆上。当一个值类型要转换成引用类型,首先创建一个新的引用类型对象,需要在堆上分配内存,这个内存大小为栈上值类型数据的大小和引用类型自身额外元数据的占用(一个存储类型标识,和同步块索引的对象头);然后将栈上值类型的值复制到堆上的装箱对象中;最后在栈上开辟一个空间存储这个新的引用类型对象的引用地址。

值类型到引用类型的装箱中,堆上的装箱对象与原栈上的值类型是相互独立的。它们复制的是值本身,修改原变量不会影响装箱对象,反之亦然。

值得注意的是装箱是隐式的,编译器会自动帮我们转换。也就是说我们在敲代码的时候是不需要额外操作就能将一个值类型赋值给引用类型。而前面我们了解到值类型到引用类型,需要一次堆空间分配,然后是栈到堆的复制,最后是栈分配引用类型的引用。这些都是在不经意间增大程序的性能开销。

int num = 25;
object obj = num;       //发生装箱

2.2 拆箱

将装箱后的引用类型转换回原来的值类型的过程,称为拆箱。
比起装箱的隐式方便,拆箱的步骤要求较为严格。在每一次拆箱前都需要验证堆上的装箱对象是否确实是目标值类型的装箱结果。类型验证通关后将堆上装箱对象中的值复制回栈上。

引用类型到值类型的拆箱需要手动触发,通过显式类型转换完成。并且拆箱也是值复制,栈上的新的值类型变量与堆上的装箱对象之间是相互独立,修改新的值类型变量不会影响堆上旧的装箱对象,反之亦然。

int num = 25;
object obj = num;       //发生装箱
int unboxedNum = (int)obj;  //执行拆箱

2.3 如何避免不必要的装箱与拆箱

堆的分配速度远慢于栈上内存的分配,频繁的装箱会消耗额外时间等待堆内存分配。而且频繁装箱可能导致GC频繁触发,占用系统资源。拆箱的时候类型验证也会消耗CPU资源。装箱和拆箱的过程中都会设计到数据的值复制,大批量数据复制会导致程序性能变差。

了解清楚了堆、栈与装箱拆箱的机制后,下面我们来讨论几个解决性能影响的方案。

2.3.1 泛型集合

泛型的关键特性是在编译时为不同的类型参数生成具体的类型实例,而不是依赖object作为中间类型。比方说ArrayList和List< T>。
给ArrayList添加值,最终值是被装箱成object对象,读取值本身也会经历一次拆箱

ArrayList arrayList = new ArrayList();
arrayList.Add("int");   //装箱
arrayList.Add("byte");
arrayList.Add("float");
string str = (string)arrayList[0]; //拆箱

而使用泛型集合,泛型通过类型参数化和编译时才把类型具体化,让值类型能够直接被存储和操作,无需转换为object类型。避免了装箱和拆箱。

 List<string> list = new List<string>();
 list.Add("string");
 list.Add("int");
 string str = list[0];

2.3.2 泛型参数

C#中方法参数的传递方式默认是按值传递的。对于值类型,传递的是变量的副本,方法内部修改参数变量不会改变外部原始变量。对于引用类型传递的是引用的副本,方法内部通过这个引用副本可以修改对象的内容。但是如果一旦修改引用副本本身这个引用值,比如在方法内部将引用副本重新赋值一个新的对象,这样副本引用值对应的堆上引用就和原始对象对应的堆上引用不同。

当值类型作为参数传递给方法参数是object时,默认会按值传递。值变量先装箱为object,再将装箱对象的引用传入方法。

public void Print(object obj) {
    Console.WriteLine(obj);
}

int num= 1;
Print(num);  //装箱

和上面的思路一样,使用泛型通过类型参数化和编译时才把类型具体化,让值类型能够直接被存储和操作,无需转换为object类型。避免了装箱和拆箱。

void Print<T>(T obj) where T : struct
{
    Console.WriteLine(obj);
}

int num = 1;
Print<int>(num);  

总结

文章讲解了.NET 中托管堆与托管栈的特性与数据存储差异,深入剖析了装箱、拆箱的原理及性能损耗,理解内存机制以优化程序性能。并给出泛型集合、泛型方法来等避免不必要装箱拆箱的方案。