C#万字详解 栈与托管堆 的底层逻辑

文章目录

栈(Stack)

  • 内存分配
    • 栈是一种**后进先出(LIFO)**的内存结构。
    • 它由系统自动管理,用于存储局部变量、方法参数和返回地址等。
    • 栈的内存分配和释放非常快速
  • 存储内容
    • 值类型 (如 intdoublebool 等)直接存储在栈上。
    • 方法调用时,会将方法的参数、局部变量和返回地址压入栈中
  • 生命周期
    • 栈上的变量具有短暂的生命周期,当方法执行完毕时,它们会自动释放
  • 特点
    • 速度快,效率高。
    • 空间有限。

堆(Heap)

  • 内存分配
    • 堆是一种动态分配的内存区域,用于存储引用类型(如类、对象、字符串等)。
    • 堆的内存分配和释放由垃圾回收器(Garbage Collector)管理
    • 堆的内存分配和释放相对较慢。
  • 存储内容
    • 引用类型的数据存储在堆上。
    • 栈上的变量存储的是对堆上数据的引用(内存地址)。
  • 生命周期
    • 堆上的变量具有较长的生命周期,直到垃圾回收器认为它们不再被使用时才会被释放。
  • 特点:
    • 空间较大,可以动态分配。
    • 速度相对较慢。

总结

  • 栈用于存储值类型和方法调用,速度快,但空间有限。
  • 堆用于存储引用类型,空间大,但速度相对较慢。
  • "堆栈"这个词,在数据结构中,指的是栈(Stack)这种数据结构。
  • "堆栈"这个词,在内存管理中,是堆(Heap)和栈(Stack)的统称

**通用类型系统(CTS)**区分两种基本类型:值类型和引用类型。它们之间的根本区别在于它们在内存中的存储方式。.NET使用两种不同的物理内存块来存储数据---栈和托管堆

简单来说,托管代码(Managed Code)之所以叫"托管" ,就是因为它把内存申请、垃圾回收、安全检查这些重活儿全甩给了 CLR 去管

负责保存我们的代码执行(或调用)路径,说白了就是程序以机器码的形式存储在内存里面的,每一句机器码都有自己的位置,称为地址路径,记录要返回的地址的。

则负责保存对象 (或者说数据,接下来将谈到很多关于堆的问题)的路径。

可以简单理解,栈是抽屉一层层的,堆就是床上衣服,

栈是自行维护的,也就是说内存自动维护栈,当栈顶的盒子不再被使用,它将被抛出。

相反的,堆需要考虑垃圾回收

栈和堆里到底有什么

当我们的代码执行的时候,栈和堆中主要放置了四种类型的数据:值类型(Value Type),引用类型(Reference Type),指针(Pointer),指令(Instruction)。

关于具体存放内容,记住以下两点

  1. 引用类型 总是放在中。

  2. 值类型和指针总是放在它们被声明的地方。

就像我们先前提到的,栈是负责保存我们的代码执行(或调用)时的路径

当我们的代码开始调用一个方法时,将放置一段编码指令(在方法中)到栈上,紧接着放置方法的参数,然后代码执行到方法中的被"压栈"至栈顶的变量位置。

值类型(System.ValueType)

值类型 则是继承Systerm.Object的子类Systerm.ValueType类

  • bool
  • byte
  • char
  • decimal
  • double
  • enum
  • float
  • int
  • long
  • sbyte
  • short
  • struct
  • uint
  • ulong
  • ushort

引用类型(System.Object)

  • class
  • interface
  • delegate
  • object
  • string
csharp 复制代码
// 值类型,保存在栈中
int num = 100;

// 引用类型,保存在堆中
int[] nums = {1,2,3,4,5};

// 接下来,我们输出一下
Console.WriteLine(num);  // 100
Console.WriteLine(nums); // System.Int32[]
num为值类型,所以它直接输出了值。
而nums为引用类型,它无法直接输出值,而是输出了一个引用。

值类型与引用类型的区别

值类型与引用类型都继承自Systerm.Object类 。不同之处,几乎所有的引用类型都是直接从Systerm.Object继承 ,而值类型 则是继承Systerm.Object的子类Systerm.ValueType类

我们在给引用类型 的变量赋值的时候,其实只是赋值了对象的引用 ;而给值类型变量赋值的时候 是创建了一个副本(说通俗点,就是克隆了一个变量)。

值类型直接存储其值,引用类型存储对值的引用,值类型存在堆栈上,引用类型存储在托管堆上,值类型转为引用类型叫做装箱,引用类型转为值类型叫拆箱

值类型变量引用类型变量的内存分配模型也不一样。为了理解清楚这个问题,读者首

先必须区分两种不同类型的内存区域:线程堆栈(Thread Stack)托管堆(Managed Heap)

每个正在运行的程序都对应着一个进程(process)

在一个进程内部,可以有一个或多个线程(thread),每个线程都拥有一块"自留地",称为"线程堆栈",大小为1M,用于保存自身的一些数据,比如函数中定义的局部变量、函数调用时传送的参数值等,这部分内存区域的分配与回收不需要程序员干涉。所有值类型的变量都是在线程堆栈中分配的。

另一块内存区域称为"堆(heap)",在.NET 这种托管环境下,堆由CLR 进行管理,所

以又称为"托管堆(managed heap)"。

用new 关键字创建的类的对象时,分配给对象的内存单元就位于托管堆中 。在程序中我们可以随意地使用new 关键字创建多个对象,因此,托管堆中的内存资源是可以动态申请并使用的,当然用完了必须归还。

打个比方更易理解:托管堆相当于一个旅馆,其中的房间相当于托管堆中所拥有的内存单元。当程序员用new 方法创建对象时,相当于游客向旅馆预订房间,旅馆管理员会先看

一下有没有合适的空房间,有的话,就可以将此房间提供给游客住宿。当游客旅途结束,要办理退房手续,房间又可以为其他旅客提供服务了。

引用类型共有四种:类类型、接口类型、数组类型和委托类型。

所有引用类型变量所引用的对象,其内存都是在托管堆中分配的。

严格地说,我们常说的"对象变量"其实是类类型的引用变量。但在实际中人们经常将

引用类型的变量简称为"对象变量",用它来指代所有四种类型的引用变量。

csharp 复制代码
class A
{
     public int i;
}
class Program
{
  static void Main(string[] args)
  {
    A a;
    a= new A();
    a.i = 100;
    A b=null;
    b = a; //对象变量的相互赋值
    Console.WriteLine("b.i=" + b.i);
   }
}

// b.i=100;

为什么 a.i 在堆上?

这是一个初学者最容易混淆的地方:"值类型不是都在栈上吗?" 答案是否定的。 值类型的存储位置取决于它在哪里声明

  1. 局部变量 :在方法内部声明的 int x,确实在栈上。
  2. 对象字段i 是类 A 的成员。因为类 A 的实例在堆上,为了保证对象的完整性,作为对象"零件"的 i 必须跟着对象一起存在里。

当你执行 b = a; 时,发生的不是"克隆",而是"共享"

两个对象变量的相互赋值意味着什么?

事实上,两个对象变量的相互赋值意味着赋值后两个对象变量所占用的内存单元其内容

是相同的。

讲得详细一些:

第10 句创建对象以后,其首地址(假设为"1234 5678")被放入到变量a 自身的4 个

字节的内存单元中。

第12 句又定义了一个对象变量b,其值最初为null(即对应的4 个字节内存单元中为

"0000 0000")。

第13 句执行以后,a 变量的值被复制到b 的内存单元中,现在,b 内存单元中的值也为

"1234 5678"。

根据前面介绍的对象内存模型,我们知道现在变量a和b都指向同一个实例对象

如果通过b.i 修改字段i 的值,a.i 也会同步变化,因为a.i 与b.i 其实代表同一对象的同

一字段。都对应托管堆同一地址

由此得到一个重要结论:

对象变量的相互赋值不会导致对象自身被复制,其结果是两个对象变量指向同一对象。

另外,由于对象变量本身是一个局部变量,因此,对象变量本身是位于线程堆栈中的。

严格区分对象变量与对象变量所引用的对象,是面向对象编程的关键之一。由于对象变量类似于一个对象指针,这就产生了"判断两个对象变量是否引用同一对象"的问题。

C#使用"=="运算符比对两个对象变量是否引用同一对象,"!="比对两个对象变量

是否引用不同的对象。参看以下代码:

csharp 复制代码
//a1与a2引用不同的对象
A a1= new A();
A a2= new A();
Console.WriteLine(a1 == a2);//输出:false
a2 = a1;//a1与a2引用相同的对象
Console.WriteLine(a1 == a2);//输出:true

当你执行 a1 = a2; 时:
栈上的变化:a1 存储的地址被改写成了 a2 指向的地址。
堆上的状态:原来的 a1 对象如果没有其他引用,将变成垃圾等待 GC(垃圾回收)处理;现在的 a1 和 a2 同时指向堆中同一个 A 实例。

术语的"锅":堆栈到底是什么?

在软件开发中,大家常说的"堆栈"其实通常指的就是 栈 (Stack)

  • 英文原文 :一个是 Stack,一个是 Heap
  • 中文习惯 :很多老教材喜欢把 Stack 翻译成"堆栈",把 Heap 翻译成"堆"。
  • 重复了吗? :从翻译角度看,确实重复了。但在现代 C# 语境下,为了避免混淆,我们严谨地称呼它们为:栈 (Stack)托管堆 (Managed Heap)

专家提示 :以后你听到"堆栈"这个词,99% 的情况下对方指的都是 栈 (Stack)。你可以把它理解为一个"叠在一起的盘子",后进先出。

三个概念的清晰界限

为了让你不再混淆,我们可以把内存看作一个多层级的架构:

术语 英文名 角色 存储内容
栈 (堆栈) Stack 临时工 局部变量、方法参数、指向堆的地址(引用)
托管堆 Managed Heap 长期宿舍 所有的引用类型实例(Object)、字符串、数组。
NextObjPtr 指针 施工队长 它是 CLR 内部的一个变量,专门标记托管堆哪里还有空位。

堆和栈的理解

C#程序在CLR上运行的时候,内存从逻辑上划分两大块:栈,堆。这俩基本元素组成我们C#程序的运行环境。

  • **堆(heap):**堆是一种经过排序的树形数据结构,每个结点都有一个值。在C里面叫堆,在C#里面叫托管堆
  • 栈(stack):它是一种具有后进先出性质的数据结构,也就是说后存放的先取,先存放的后取。(PS:颇有砌墙的砖------后来者居上的赶脚。),其实也就是堆栈
  • 堆栈 :由堆和栈的概念,可以清晰的知道:堆栈,是一种数据项按序排列的数据结构,只能在一端(称为栈顶(top))对数据项进行插入和删除

***PS:*结合查阅的资料,通常所说的堆栈,实际上更偏向于指栈。

  • **托管堆:**托管堆不同于堆,它是由CLR(公共语言运行库(Common Language Runtime))管理,当堆中满了之后,会自动清理堆中的垃圾。所以,做为.net开发,我们不需要关心内存释放的问题。

内存堆栈与数据堆栈

  • 内存堆栈:存在内存中的两个存储区(堆区,栈区)。

栈区:存放函数的参数、局部变量、返回数据等值,由编译器自动释放。
堆区:存放着引用类型的对象,由CLR释放。

  • 数据堆栈:是一种后进先出的数据结构,它是一个概念,主要是栈区。

堆栈(栈)Stack

但是考虑到CPU内空间有限,寄存器不能存储过量的临时结果,于是找到了内存

在内存里划出一片专门的区域,临时存储数据,叫栈Stack

内存里一个个存储单元

往栈里存数据的时候,栈顶位置上移,存入数据。(压栈/入栈PUSH)

取出数据时,把数据复制到CPU中的寄存器里,栈顶位置下移,叫(弹栈/出栈POP)

出栈后数据还是在堆栈里,但是已经被当作垃圾处理了。

栈是自行维护的,也就是说内存自动维护栈,当栈顶的盒子不再被使用

什么是堆栈(栈)

堆栈中存储值类型

堆栈实际上是向下填充 ,即由高 内存地址指向低内存地址填充。

堆栈的工作方式是先分配内存的变量后释放先进后出原则)。

堆栈中的变量是从下向上释放,这样就保证了堆栈中先进后出的规则不与变量的生命周期起冲突!

堆栈的性能非常高,但是对于所有的变量来说还不太灵活,而且变量的生命周期必须嵌套

通常我们希望使用一种方法分配内存来存储数据,并且方法退出后很长一段时间内数据仍然可以使用。此时就要用到堆(托管堆)!

与函数的关系

程序在运行时,是以机器码的形式存储在内存里面的,每一句机器码都有自己的位置 ,称为地址

CPU执行程序的过程,就是把程序中的每一句机器码拿出来,分析之后,相应操作。

函数的跳转/返回

堆栈就是记录要返回的地址的,地址复制的过程

函数调用是嵌套的,返回地址要按照顺序保存,所以堆栈比较合适

每一次函数调用都会消耗堆栈的空间,空间有限,调用不返回,容易导致栈溢出。

堆(托管堆)Heap

什么是托管堆

堆(托管堆)存储引用类型。

此堆非彼堆,.NET中的堆由垃圾收集器自动管理,由CLR管理

与堆栈不同,堆是从下往上分配,所以自由的空间都在已用空间的上面。

比如创建一个对象:

csharp 复制代码
Customer cus;
cus = new Customer();

申明一个Customer的引用cus ,在堆栈上给这个引用分配存储空间 。这仅仅只是一个引用,并不是实际的Customer对象!

堆栈给这个引用分配了存储空间,仅仅是个地址引用。

cus占4个字节的空间,包含了存储Customer的引用地址

接着分配堆上的内存以存储Customer对象的实例,假定Customer对象的实例是32字节,为了在堆上找到一个存储Customer对象的存储位置。

.NET运行库在堆中搜索第一个从未使用的,32字节的连续块存储Customer对象的实例!

然后把分配给Customer对象实例的地址赋给cus变量

从这个例子中可以看出,建立对象引用的过程比建立值变量的过程复杂,且不能避免性能的降低!

实际上就是.NET运行库保存对的状态信息,在堆中添加新数据时,堆栈中的引用变量也要更新。性能上损失很多!

有种机制在分配变量内存的时候,不会受到堆栈的限制:把一个引用变量的值赋给一个相同类型的变量,那么这两个变量就引用同一个堆中的对象。

当一个应用变量出作用域时,它会从堆栈中删除。但引用对象的数据仍然保留在堆中,一直到程序结束 或者 该数据不被任何变量应用时,垃圾收集器会删除它。

托管堆的优化

看上去似乎很简单,但是垃圾收集器实际采用的步骤和堆管理系统的其他部分并非微不足道,其中常常涉及为提高性能而作的优化设计。

举例来说,垃圾收集遍历整个内存池具有很高的开销。然而,研究表明大部分在托管堆上分配的对象只有很短的生存期,因此堆被分成三个段,称作generations。

新分配的对象被放在generation 0中。这个generation是最先被回收的------在这个generation中最有可能找到不再使用的内存,由于它的尺寸很小(小到足以放进处理器的L2 cache中),因此在它里面的回收将是最快和最高效的。

托管堆的另外一种优化操作与locality of reference规则有关。

该规则表明,一起分配的对象经常被一起使用。如果对象们在堆中位置很紧凑的话,高速缓存的性能将会得到提高。由于托管堆的天性,对象们总是被分配在连续的地址上,托管堆总是保持紧凑,结果使得对象们始终彼此靠近,永远不会分得很远。这一点与标准堆提供的非托管代码形成了鲜明的对比,在标准堆中,堆很容易变成碎片,而且一起分配的对象经常分得很远。

还有一种优化是与大对象有关的。通常,大对象具有很长的生存期。当一个大对象在.NET托管堆中产生时,它被分配在堆的一个特殊部分中,这部分堆永远不会被整理。因为移动大对象所带来的开销超过了整理这部分堆所能提高的性能。

关于外部资源(External Resources)的问题

垃圾收集器能够有效地管理从托管堆中释放的资源,但是资源回收操作只有在内存紧张而触发一个回收动作时才执行。那么,类是怎样来管理像数据库连接或者窗口句柄这样有限的资源的呢?等待,直到垃圾回收被触发之后再清理数据库连接或者文件句柄并不是一个好方法,这会严重降低系统的性能。

所有拥有外部资源的类,在这些资源已经不再用到的时候,都应当执行Close或者Dispose方法。从Beta2(译注:本文中所有的Beta2均是指.NET Framework Beta2,不再特别注明)开始,Dispose模式通过IDisposable接口来实现。

需要清理外部资源的类还应当实现一个终止操作(finalizer)。在C#中,创建终止操作的首选方式是在析构函数中实现,而在Framework层,终止操作的实现则是通过重载System.Object.Finalize 方法。以下两种实现终止操作的方法是等效的:

csharp 复制代码
~OverdueBookLocator()
{
   Dispose(false);
}
  和:
public void Finalize()
{
   base.Finalize();
   Dispose(false);
}

在C#中,同时在Finalize方法和析构函数实现终止操作将会导致错误的产生。

除非你有足够的理由,否则你不应该创建析构函数或者Finalize方法。终止操作会降低系统的性能,并且增加执行期的内存开销。同时,由于终止操作被执行的方式,你并不能保证何时一个终止操作会被执行。

总结

  • 栈(Stack):线程私有、连续的内存区域,遵循「后进先出(LIFO)」原则,由 CLR 自动分配 / 释放,无垃圾回收(GC)参与。
  • 堆(Heap):应用程序域共享(托管堆)、非连续的内存区域,分配 / 释放灵活,托管堆由 GC 负责回收,非托管堆需手动释放。
  • 栈内存是自行维护的,堆需要借助垃圾回收,所以栈内存,申请,释放相对,堆肯定要快
  • 方法在栈中执行,依次是入栈,方法指令,参数,局部变量,
  • 当我们使用引用类型时,我们在和指向引用类型的指针打交道,而不是引用类型本身。
  • 当我们使用值类型时,我们就是在和值类型本身打交道。

关键区别对比

维度 栈(Stack) 堆(Heap)
内存归属 线程私有(每个线程独立栈) 应用程序域共享(所有线程可访问)
存储内容 1. 局部值类型(如int、struct局部变量)2. 引用类型的「引用(指针)」3. 方法调用栈帧(返回地址、参数)4. ref struct(如Span) 1. 引用类型实例(对象、数组、字符串)2. 装箱的值类型3. 类的成员值类型(随对象存储)4. 静态变量(堆的静态存储区)
分配方式 自动分配(方法调用时压栈,结束时弹栈) 手动(非托管)/CLR 分配(需查找可用内存块)
释放方式 自动释放(栈帧弹出时销毁) 托管堆:GC 回收(基于可达性)非托管堆:手动释放(如Marshal.FreeHGlobal)
内存布局 连续内存,地址从高到低增长 非连续内存,地址从低到高增长,易碎片化
访问速度 极快(连续内存 + CPU 缓存友好) 较慢(需解引用 + 缓存不友好)
生命周期 与方法执行周期绑定(方法结束即销毁) 由 GC 决定(可达则存活,跨方法 / 线程)
大小限制 固定且小(默认~1MB,可配置) 受物理 / 虚拟内存限制(远大于栈)
线程安全 天然安全(线程私有,无并发访问) 非线程安全(需加锁 / 原子操作)
异常类型 栈溢出(StackOverflowException) 内存不足(OutOfMemoryException)

装箱拆箱转化

csharp 复制代码
class Boxing
{
  public static void Main()
  {
      int i = 110;//变量占用的内存是内存栈中分配的
      object obj = i;//将变量110存放到内存堆里,而obj在内存栈里
      i = 220;
      Console.WriteLine("i={0},obj={1}",i,obj);
      obj = 330;
      Console.WriteLine("i={0},obj={1}",i,obj);
  }
}

定义整数类型变量i 的时候,这个变量占用的内存是内存栈中分配的,第二句是装箱操作将变量 110存放到了内存堆中,而定义object对象类型的变量obj则在内存栈中,并指向int类型的数值110,而该数值是付给变量i的数值副本。

所以运行结果是

i=220,obj=110

i=220,obj=330

内存格局通常分为四个区

全局数据区:存放全局变量,静态数据,常量

代码区:存放所有的程序代码

栈区:存放为运行而分配的局部变量,参数,返回数据,返回地址等,

堆区:即自由存储区

什么是装箱什么是开箱

在 C# 的内存模型中,**装箱(Boxing)和拆箱(Unboxing)**是值类型(Value Type)与引用类型(Reference Type)之间转换的桥梁。

由于值类型(如 int, struct)默认在栈上,而引用类型(如 object)在堆上,这种跨区域的转换涉及到了内存的重新分配。

1. 装箱 (Boxing)

定义 :将值类型隐式或显式地转换为 object 类型(或其实现的接口类型)的过程。

  • 底层操作
    • 托管堆中分配内存。
    • 将栈上的拷贝到堆内存中。
    • 返回堆中新对象的引用(地址),存储在栈上的变量里。
csharp 复制代码
int i = 123;      // 值类型,在栈上
object o = i;     // 装箱:在堆上创建对象,把123拷贝进去,o指向该对象

2. 拆箱 (Unboxing)

定义 :将 object 类型显式转换为值类型的过程。

  • 底层操作
    • 检查对象实例,确保它确实是目标值类型的装箱值。
    • 将堆中对象的值拷贝回栈上的变量中。
csharp 复制代码
object o = 123;   // 先装箱
int j = (int)o;   // 拆箱:从堆中拷贝回栈

3. 性能影响

正如你之前提到的,堆的操作比栈慢得多。

  • 装箱:最耗时,因为它需要请求堆内存分配(Allocation)并进行数据拷贝。
  • 拆箱:虽然也涉及拷贝,但主要是类型检查的开销,比装箱快。
  • 后果:频繁的装箱/拆箱会产生大量的内存碎片,并增加垃圾回收器(GC)的负担,导致程序变慢。

4.泛型的优化

在现代 C# 开发中,泛型(Generics) 的出现几乎终结了大部分被迫装箱的场景。例如,使用 List<int> 而不是老的 ArrayList,可以完全避免集合操作中的装箱开销,因为泛型在编译时就确定了类型

1. 有损性能的旧做法(ArrayList)

每往里面存一个数字,都会发生一次装箱。

csharp 复制代码
ArrayList list = new ArrayList();
list.Add(10); // 发生装箱:int (栈) -> object (堆)
int i = (int)list[0]; // 发生拆箱:object (堆) -> int (栈)
2. 高性能的新做法(List)

泛型在编译时就确定了类型,内存直接分配对应大小的空间,不涉及 object 转换。

csharp 复制代码
List<int> list = new List<int>();
list.Add(10); // 直接存入:无装箱
int i = list[0]; // 直接读取:无拆箱

在处理大规模数据(如游戏开发、高频交易)时,装箱导致的 GC 压力 比拷贝数据本身更致命。频繁装箱会触发 GC 全线停顿(Stop-the-world),导致掉帧或卡顿。因此,在循环体内部,要极力避免将值类型赋值给 object 或接口变量。

核心机制拆解

  1. 连续地址空间:CLR 并不是每次需要内存都向操作系统申请,而是预先"包场"一大块空间。
  2. NextObjPtr(下一个对象指针):这是托管堆性能极高的原因。在 C++ 里找内存像是在杂乱的仓库里找空位,而 C# 在堆上分配内存就像在纸上画线,指针挪一下,内存就分好了,速度接近在栈(Stack)上分配。

内存分配流程图

在 Windows 操作系统中,一个进程启动后会获得一块巨大的虚拟地址空间。CLR 从这块大空间里"圈"出了一块,专门用来存放 C# 的引用类型对象,这块被圈出来的领地就是托管堆 (Managed Heap)

两个"堆"的层级关系

这里的逻辑其实是:托管堆是在进程堆(虚拟地址空间)之上建立的一个逻辑分区。

  1. 进程堆/原生堆 (Native Heap):这是操作系统层面的内存,C++ 程序员直接操作的地方。
  2. 托管堆 (Managed Heap):CLR 向系统申请了一块连续的进程堆空间,然后自己制定了一套分配规则(也就是你提到的那个指针)。

所以,当你看到"将在 中分配"时,这个"堆"指的就是 托管堆本身


指针的工作原理

想象一个卷轴,卷轴展开的部分就是已经使用的内存。

  • NextObjPtr:始终指在"已分配"和"待分配"的交界线上。
  • 当你执行 new MyClass() 时,CLR 只需要做两件事:
    • 确认 NextObjPtr + 对象大小 没超过托管堆的边界。
    • 把对象塞进 NextObjPtr 指向的位置,然后把指针向后挪。

栈(Stack)确实指向了堆(Heap)。

  1. 引用的指向(地址转换):栈上的变量存储了一个内存地址,这个地址"指向"堆里的对象数据。
  2. 分配的指向(进度追踪) :托管堆内部维护的一个指针(NextObjPtr),它"指向"堆里下一个还没被占用的空位。
  3. 一句话总结:栈上的变量是指向堆里的"住户",而堆内部的指针是指向堆里的"空房"。

1. 宏观视图:栈 vs 堆

当你写下 MyClass obj = new MyClass(); 时,发生了两件事:

  • 在堆上 :CLR 根据 NextObjPtr 的位置,划出一块空间存放 MyClass 的实例数据。
  • 在栈上 :声明了一个变量 obj,它里面存的不是对象本身,而是那块堆空间的首地址

2. 为什么说"托管堆维护一个指针"?

你提到的那个"维护指针",是 CLR 为了管理堆内部的"房产开发进度"。

想象托管堆是一条很长的空地,CLR 是开发商:

  • 栈上的引用:像是业主手里的房产证,上面写着"我家在 101 号"。
  • 托管堆内部的指针(NextObjPtr):像是开发商手里的地图,上面标记着"下一个地块从 105 号开始盖"。

如果没有堆内部这个指针,CLR 每次 new 对象时,都得遍历整个堆去寻找哪里有空位,那性能就太差了。

3. 内存分配协作流程

内存分配的完整链路

当你运行 MyClass obj = new MyClass(); 时,内存里发生了这样的"三点一线":

  1. 栈 (Stack) :开辟一个小格子,名字叫 obj
  2. 托管堆 (Managed Heap) :施工队长 NextObjPtr 在空地上画了一块地,盖了一个 MyClass 的房子。
  3. 连接 :把这个房子的"门牌号"(内存地址)写回到栈上的 obj 格子里。
  • 变量 obj 是什么? 它是一个引用(Reference),本质上是一个内存地址(类似于 C 语言中的指针,在 64 位系统下占用 8 个字节)。
  • 对象是什么? 它是 new MyClass() 在堆中生成的真实数据。
  • 之所以在栈里给 obj 开辟小格子,是因为 obj 是一个局部变量
  • 栈的职责 :管理函数运行时的生命周期。当你进入一个方法时,方法内定义的所有局部变量(不管是 int 还是 MyClass 的引用)都会在栈上分配空间。
  • 格子里装的是什么
    • 如果是 int i = 10;,栈里的格子里直接装的是 10(数据本身)。
    • 如果是 MyClass obj = new MyClass();,栈里的格子里装的是 0x123456...(堆中对象的首地址)。

所以,引用类型在栈中也有空间,只是那个空间里存的不是对象的数据,而是通往堆内存的"门牌号"(内存地址)

总结

栈里并不全是值类型,栈里存放的是所有局部变量。只不过值类型的变量直接把"货"存在栈里,而引用类型的变量在栈里只存了一张"取货单"。

流程图:地址的传递

  • 栈 (Stack):它的管理非常简单,随着方法的调用而增长,随着方法结束而自动销毁(弹出)。所以它不需要复杂的指针管理。
  • 托管堆 (Managed Heap) :因为对象的生命周期不确定,不能像栈那样简单弹出,所以需要 CLR 专门用一个 NextObjPtr 指针来记录分配进度,并用 GC 来清理。
  • 引用 (Reference):你可以把它看作是一个"远程遥控器",遥控器在手(栈)上,但电视机(对象)在柜台(堆)上。

C# 处理内存回收的深度拆解

1. 为什么不需要手动 GC?

在.NET的所有技术中,最具争议的恐怕是垃圾收集(Garbage Collection,GC)了。作为.NET框架中一个重要的部分,托管堆和垃圾收集机制对我们中的大部分人来说是陌生的概念。

C# 的垃圾回收器(GC)是一个高度自动化且具备自适应能力的引擎。它会监控程序的内存使用情况、CPU 负载以及操作系统层面的内存压力。

  • 自动触发机制 :当 NextObjPtr 走到托管堆的末尾,或者特定的"代(Generation)"内存用尽时,GC 会自动跳出来接管一切。
  • 代级假设 (Generational Hypothesis) :这是 C# 性能优化的核心。GC 将对象分为 0、1、2 三代。
    • 0 代 :刚 new 出来的短命对象。
    • 1 代:在一次 GC 中活下来的对象。
    • 2 代:常驻内存的长寿对象(如配置、单例)。 因为新对象通常死得快,GC 只需要频繁清理一小块内存(0 代),这比扫描整个堆要快得多。

2. 手动 GC 会有什么后果?

虽然你可以通过 GC.Collect() 强制回收,但在生产代码中这通常被视为坏习惯

  • 打乱代级逻辑:强制 GC 会让本该在 0 代被清理的对象"早熟"变成 2 代,导致它们在内存里待得更久,反而降低了长期运行的效率。
  • 性能抖动:GC 运行时通常会"挂起"所有线程(Stop The World),手动触发可能在程序处理关键任务时造成无意义的卡顿。

3. GC 的"清理与重置"流程图

当内存不足,GC 开始介入时,它不只是删除对象,还会整理堆空间。

4. 专业词汇详解

  • GC Root (垃圾回收根):GC 判断对象是否该杀的依据。如果一个对象从栈变量、静态变量或 CPU 寄存器都无法追溯到,它就是"垃圾"。
  • 代 (Generations):一种分类机制。基于"越新的对象越容易死"的统计学规律,提高扫描效率。
  • 压缩 (Compaction) :像整理磁盘碎片一样,把零散的对象排在一起,从而腾出连续的大块空间,让 NextObjPtr 重新获得广阔的"跑道"。

5. 什么时候需要你动手?

虽然不用手动 GC.Collect(),但作为 C# 程序员,你有一种必须手动执行的操作:

处理非托管资源

如果你使用了文件流 (FileStream)、数据库连接或网络套接字,这些东西不受 CLR 托管。你需要使用 IDisposable 接口:

csharp 复制代码
// 推荐写法:使用 using 自动调用 Dispose()
using (var stream = new FileStream("test.txt", FileMode.Open))
{
    // 处理文件...
} // 离开大括号时,非托管资源立即释放,不依赖 GC

总结一下 : C# 里的内存就像是一个由专业管家打理的房间。你只需要负责往里搬东西(new),管家会决定什么时候清走没用的东西。你唯一要做的,是当你用完一些带锁的箱子(非托管资源)时,记得亲手把锁打开(Dispose)。

6.为什么要托管堆?

.NET框架包含一个托管堆,所有的.NET语言在分配引用类型对象时都要使用它。像值类型这样的轻量级对象始终分配在栈中,但是所有的类实例和数组都被生成在一个内存池中,这个内存池就是托管堆。

垃圾收集器的基本算法很简单:

● 将所有的托管内存标记为垃圾

● 寻找正被使用的内存块,并将他们标记为有效

● 释放所有没有被使用的内存块

● 整理堆以减少碎片

7.内存分配和垃圾回收的细节

对GC有了一个总体印象之后,让我们来讨论关于托管堆中的分配与回收工作的细节。托管堆看起来与我们已经熟悉的C++编程中的传统的堆一点都不像。在传统的堆中,数据结构习惯于使用大块的空闲内存。在其中查找特定大小的内存块是一件很耗时的工作,尤其是当内存中充满碎片的时候。与此不同,在托管堆中,内存被组制成连续的数组,指针总是巡着已经被使用的内存和未被使用的内存之间的边界移动。当内存被分配的时候,指针只是简单地递增------由此而来的一个好处是,分配操作的效率得到了很大的提升。

当对象被分配的时候,它们一开始被放在generation 0中。当generation 0的大小快要达到它的上限的时候,一个只在generation 0中执行的回收操作被触发。

由于generation 0的大小很小,因此这将是一个非常快的GC过程。这个GC过程的结果是将generation 0彻底的刷新了一遍。不再使用的对象被释放,确实正被使用的对象被整理并移入generation 1中。

当generation 1的大小随着从generation 0中移入的对象数量的增加而接近它的上限的时候,一个回收动作被触发来在generation 0和generation 1中执行GC过程。

如同在generation 0中一样,不再使用的对象被释放,正在被使用的对象被整理并移入下一个generation中。大部分GC过程的主要目标是generation 0,因为在generation 0中最有可能存在大量的已不再使用的临时对象。

对generation 2的回收过程具有很高的开销,并且此过程只有在generation 0和generation 1的GC过程不能释放足够的内存时才会被触发。如果对generation 2的GC过程仍然不能释放足够的内存,那么系统就会抛出OutOfMemoryException异常

带有终止操作的对象的垃圾收集过程要稍微复杂一些。当一个带有终止操作的对象被标记为垃圾时,它并不会被立即释放。相反,它会被放置在一个终止队列(finalization queue)中,此队列为这个对象建立一个引用,来避免这个对象被回收。后台线程为队列中的每个对象执行它们各自的终止操作,并且将已经执行过终止操作的对象从终止队列中删除。只有那些已经执行过终止操作的对象才会在下一次垃圾回收过程中被从内存中删除。这样做的一个后果是,等待被终止的对象有可能在它被清除之前,被移入更高一级的generation中,从而增加它被清除的延迟时间。

需要执行终止操作的对象应当实现IDisposable接口,以便客户程序通过此接口快速执行终止动作。IDisposable接口包含一个方法------Dispose。这个被Beta2引入的接口,采用一种在Beta2之前就已经被广泛使用的模式实现。从本质上讲,一个需要终止操作的对象暴露出Dispose方法。这个方法被用来释放外部资源并抑制终止操作,就象下面这个程序片断所演示的那样:

csharp 复制代码
public class OverdueBookLocator: IDisposable
{
   ~OverdueBookLocator()
   {
   InternalDispose(false);
   }
   public void Dispose()
   {
   InternalDispose(true);
   }
   protected void InternalDispose(bool disposing)
   {
   if(disposing)
   {
   GC.SuppressFinalize(this);
   // Dispose of managed objects if disposing.
   }
   // free external resources here
   }
}

易混淆

堆和托管堆?

在软件开发(尤其是 .NET/C#)的语境下,这两个词经常被混用,但从严谨的底层逻辑来看,它们的区别就像是**"原始土地"与"规划好的开发区"**。

核心区别对照表
特性 堆 (Heap / Native Heap) 托管堆 (Managed Heap)
管理者 操作系统 (OS) 公共语言运行时 (CLR)
分配方式 寻找空闲块链表(慢) 移动指针 NextObjPtr(极快)
回收方式 手动 (C++ 的 malloc/free) 自动 (GC 垃圾回收)
内存碎片 容易产生碎片,无法自动整理 GC 会进行压缩,消除碎片
安全性 存在悬挂指针、野指针风险 安全,无法直接访问非法内存
形象化的理解

堆 (Native Heap) ------ 传统的"自助餐"

想象一个巨大的自助餐厅。你想吃什么就得自己去端,吃完了必须自己把盘子收走(free/delete)。如果你忘了收盘子,位置就一直被占着(内存泄漏 )。如果大家都乱放盘子,桌子之间就会有很多空隙,导致想拼大桌子时拼不出来(内存碎片)。

托管堆 (Managed Heap) ------ 高级的"传送带火锅"

这就是 C# 的方式。你只管从传送带上拿吃的(new),吃完的盘子直接放在桌上。CLR 就像巡视的服务员(GC ),他发现你不再动某个盘子了,就会自动把它收走。更厉害的是,服务员还会定期把剩下的盘子推到一起,腾出后面一整块干净的桌面(内存压缩)。

在进程中的位置

实际上,托管堆是建立在堆之上的

当你的 C# 程序启动时,CLR 会向操作系统申请一大块虚拟地址空间。这块空间在操作系统眼里就是一块普通的"堆"内存,但在 CLR 内部,它将其划分为:

  1. 小对象堆 (SOH):放普通的类实例。
  2. 大对象堆 (LOH):放超过 85,000 字节的大数组或大对象。
为什么要分这么细?

之所以区分"托管"二字,是为了性能和安全 。 在 C# 中,如果你调用了一些 C++ 写的 DLL 库,那些库申请的内存就是在"原生堆"上,你需要手动负责它们的释放。如果你在 C# 里 new 一个 List,那它就在"托管堆"上,你完全不用担心释放问题。

既然你理解了托管堆是对原生堆的二次封装,你想看看 "大对象堆 (LOH)" 为什么被单独拎出来不进行压缩吗?

使用基于CLR的语言编译器开发的代码称为托管代码。

托管堆是CLR中自动内存管理的基础。初始化新进程时,运行时会为进程保留一个连续的地址空间区域。这个保留的地址空间被称为托管堆。托管堆维护着一个指针,用它指向将在堆中分配的下一个对象的地址。最初,该指针设置为指向托管堆的基址。

简单来说,托管代码(Managed Code)之所以叫"托管",就是因为它把内存申请、垃圾回收、安全检查这些重活儿全甩给了 CLR 去管。

核心细节与常见误区

1. 值类型在栈,引用类型在堆是误区!

存储位置取决于变量的声明上下文,而非类型本身:

  • ✅ 局部值类型(如方法内的 int a = 10):栈;
  • ❌ 类的成员值类型(如 class A { int b; }):随对象存在堆;
  • ❌ 闭包 /async 方法中的局部值类型:会被装箱到堆;
  • ✅ 引用类型的「引用」:栈(如 List<int> list = new List<int>()list 是栈上的引用,List 实例在堆);
  • ref struct(如 Span<T>):引用类型但仅能在栈(禁止装箱到堆)。
csharp 复制代码
// 示例1:基础存储逻辑
void Test1()
{
    int a = 10; // 栈(局部值类型)
    string s = "hello"; // s引用在栈,"hello"实例在堆(字符串堆)
    List<int> list = new List<int>(); // list引用在栈,List实例在堆
    MyStruct struct1 = new MyStruct(); // 栈(局部结构体)
}

// 示例2:类成员值类型(堆)
class MyClass
{
    int b = 20; // 随MyClass实例存在堆
}

void Test2()
{
    MyClass obj = new MyClass(); // obj引用在栈,实例(含b)在堆
}

// 示例3:栈上的引用类型(ref struct)
ref struct MyRefStruct // 仅能在栈,不能装箱到堆
{
    public int Value;
}

void Test3()
{
    MyRefStruct rs; // 栈
    rs.Value = 30;
    // MyRefStruct[] arr = new MyRefStruct[]; // 编译错误,ref struct不能存堆
}
2. 托管堆的额外特性
  • 分代回收:GC 将堆分为 0/1/2 代,优先回收新生代(0 代),减少全堆扫描开销;
  • 内存压缩:GC 回收后会整理内存,减少碎片化(栈无此问题,因连续分配);
  • 大对象堆(LOH):大于 85000 字节的对象(如大数组)存 LOH,回收策略更保守(避免频繁压缩)。
核心结论
核心优势 速度快、自动管理、无碎片 存储灵活、生命周期长、容量大
核心劣势 容量小、生命周期短 速度慢、需 GC、易碎片化
适用场景 短期局部变量、方法调用 长期对象、大数据、跨方法数据

理解堆和栈的区别是掌握 C# 内存管理的关键,尤其要避开「值类型必在栈」的误区 ,结合变量的声明上下文(局部 / 成员、ref struct等)判断存储位置。

示例

堆和栈的区别
特性 Stack (栈) Heap (堆)
申请方式 系统自动分配 程序员手动申请(如 new),需指明大小
申请效率 效率高,速度快,但不可控 效率较低,易产生碎片,但灵活方便
存储特性 空间较小,读取速度快 空间较大,读取速度慢

注意:栈内存无需我们管理,也不受GC管理。当栈顶元素使用完毕,立马释放。而堆则需要GC(Garbage Collection:垃圾收集器)清理,由CLR管理的托管堆会自动进行GC清理。

进一步理解

1.值类型只需要一段单独的内存,用于存储实际的数据(单独定义的时候放在栈中)。

2.引用类型需要两段内存。

第一段存储实际的数据,它总是位于堆中。
第二段是一个引用,指向数据在堆中的存放位置。

csharp 复制代码
public int AddFive(int pValue)
{
    int result;
    result = pValue + 5;
    return result;
}

首先方法(只包含需要执行的逻辑字节,即执行该方法的指令,而非方法体内的数据)入栈,紧接着是方法的参数入栈

注意:方法并不存在栈里,图只是为了阐述原理而放的引用。

接着,控制(即执行方法的线程)被传递到堆栈中AddFive()的指令上,

当方法执行时,栈会分配一块内存给变量result存放。

方法执行完成,然后方法的结果被返回,返回result。

通过将栈指针指向AddFive()方法曾使用的可用的内存地址所有在栈上的该方法所使用内存都被清空,且程序将自动回到栈上最初的方法调用的位置

在这个例子中,我们的"result"变量是被放置在栈上的,事实上,当值类型数据在方法体中被声明时,它们都是被放置在栈上的。

值类型数据有时也被放置在堆上。记住这条规则值类型总是放在它们被声明的地方。好的,如果一个值类型数据在方法体外被声明,且存在于一个引用类型中,那么它将被堆中的引用类型所取代。


我们来看一下对比的例子,

假如我们有这样一个MyInt类(它是引用类型因为它是一个类类型):

csharp 复制代码
public class MyInt
{
    public int MyValue;
}

然后执行下面的方法:

csharp 复制代码
public MyInt AddFive(int pValue)
{
     MyInt result = new MyInt();
     result.MyValue = pValue + 5;
     return result;
}

前面方法指令,参数入栈都是一样的,区别在于

由于MyInt是一个引用类型,它将被放到堆上并在栈上放一个指针指向它在堆里的存储。

这个引用变量本身 ,以及对应的值(内存地址)仍在栈上,只不过具体对象数据,放在堆上

同样的,栈上在AddFive()方法被执行之后, 栈上和这个方法相关的内存,被自动释放,

很明显,堆中对象所占内存空间,仍然孤独留在堆分配的内存中,这就是垃圾回收器(后简称GC)起作用的地方。

当我们的程序达到了一个特定的内存阀值,我们需要更多的堆空间的时候,GC开始起作用GC将停止所有正在运行的线程,找出在堆中存在的所有不再被主程序访问的对象,并删除它们,然后GC会重新组织堆中所有剩下的对象来节省空间,并调整栈和堆中所有与这些对象相关的指针。

不用说,这个过程非常耗费性能

同时也回应一个问题,为什么称为高程需要重视栈和堆。

堆栈原理对代码的影响
csharp 复制代码
public int ReturnValue()
{
     int x = new int();
     x = 3;
     int y = new int();
      y = x;
      y = 4;
      return x;
}

// return 3;

// 使用引用类型
public class MyInt
{
    public int MyValue;
}

public int ReturnValue2()
{
     MyInt x = new MyInt();
     x.MyValue = 3;
     MyInt y = new MyInt();
     y = x;
     y.MyValue = 4;
     return x.MyValue;
}

// return 4;

第一个示例中,x就是x就是3,y就是y就是4

第二个例子,操作栈里两个指针且他们指向堆里同一个对象


其他

在32位的Windows操作系统中,每个进程都可以使用4GB的内存,这得益于虚拟寻址技术,在这4GB的内存中存储着可执行代码、代码加载的DLL和程序运行的所有变量,在C#中,虚拟内存中有个两个存储变量的区域,一个称为堆栈 ,一个称为托管堆,托管堆的出现是.net不同于其他语言的地方,堆栈存储值类型数据,而托管堆存储引用类型如类、对象,并受垃圾收集器的控制和管理。在堆栈中,一旦变量超出使用范围,其使用的内存空间会被其他变量重新使用,这时其空间中存储的值将被其他变量覆盖而不复存在,但有时候我们希望这些值仍然存在,这就需要托管堆来实现。我们用几段代码来说明其工作原理,假设已经定义了一个类class1:

class1 object1;

object1=new class1();

第一句定义了一个class1的引用,实质上只是在堆栈中分配了一个4个字节的空间,它将用来存府后来实例化对象在托管堆中的地址,在windows中这需要4个字节来表示内存地址。

第二句实例化object1对象,实际上是在托管堆中开僻了一个内存空间来存储类class1的一个具体对象,假设这个对象需要36个字节,那么object1指向的实际上是在托管堆一个大小为36个字节的连续内存空间开始的地址。

由此也可以看出在C#编译器中为什么不允许使用未实例化的对象,因为这个对象在托管堆中还不存在。当对象不再使用时,这个被存储在堆栈中的引用变量将被删除,但是从上述机制可以看出,在托管堆中这个引用指向的对象仍然存在,其空间何时被释放取决垃圾收集器而不是引用变量失去作用域时。

在使用电脑的过程中大家可能都有过这种经验:电脑用久了以后程序运行会变得越来越慢,其中一个重要原因就是系统中存在大量内存碎片,就是因为程序反复在堆栈中创建和释入变量,久而久之可用变量在内存中将不再是连续的内存空间,为了寻址这些变量也会增加系统开销。在.net中这种情形将得到很大改善,这是因为有了垃圾收集器的工作,垃圾收集器将会压缩托管堆的内存空间,保证可用变量在一个连续的内存空间内,同时将堆栈中引用变量中的地址改为新的地址,这将会带来额外的系统开销,但是,其带来的好处将会抵消这种影响,而另外一个好处是,程序员将不再花上大量的心思在内在泄露问题上。

当然,以C#程序中不仅仅只有引用类型的变量,仍然也存在值类型和其他托管堆不能管理的对象,如果文件名柄、网络连接和数据库连接,这些变量的释放仍需要程序员通过析构函数或IDispose接口来做。

另一方面,在某些时候C#程序也需要追求速度,比如对一个含用大量成员的数组的操作,如果仍使用传统的类来操作,将不会得到很好的性能,因为数组在C#中实际是System.Array的实例,会存储在托管堆中,这将会对运算造成大量的额外的操作,因为除了垃圾收集器除了会压缩托管堆、更新引用地址、还会维护托管堆的信息列表。

所幸的是C#中同样能够通过不安全代码使用C++程序员通常喜欢的方式来编码,在标记为unsafe的代码块使用指针,这和在C++中使用指针没有什么不同,变量也是存府在堆栈中,在这种情况下声明一个数组可以使用stackalloc语法,比如声明一个存储有50个double类型的数组:

csharp 复制代码
double* pDouble=stackalloc double[50]

stackalloc会给pDouble数组在堆栈中分配50个double类型大小的内存空间,可以使用pDouble[0]、*(pDouble+1)这种方式操作数组,与在C++中一样,使用指针时必须知道自己在做什么,确保访问的正确的内存空间,否则将会出现无法预料的错误。

堆(Heap)是应用程序在运行的时候请求操作系统分配给自己内存,一般是申请/给予的过程,C/C++分别用malloc/New请求分配Heap,用free/delete销毁内存。由于从操作系统管理的内存分配所以在分配和销毁时都要占用时间,所以用堆的效率低的多!但是堆的好处是可以做的很大,C/C++对分配的Heap是不初始化的。

在Java中除了简单类型(int,char等)都是在堆中分配内存,这也是程序慢的一个主要原因。但是跟C/C++不同,Java中分配Heap内存是自动初始化的。在Java中所有的对象(包括int的wrapper Integer)都是在堆中分配的,但是这个对象的引用却是在Stack中分配。也就是说在建立一个对象时从两个地方都分配内存,在Heap中分配的内存实际建立这个对象,而在Stack中分配的内存只是一个指向这个堆对象的指针(引用)而已。

专业词汇解释

  • 内存碎片 (Memory Fragmentation):指内存中虽然总空闲空间够,但都是零散的小块,导致无法分配大对象。
  • 悬挂指针 (Dangling Pointer):在原生堆中,如果你手动释放了内存但指针还指向那,再次访问就会崩溃。托管堆通过 GC 彻底解决了这个问题。
  • 虚拟地址空间 (Virtual Address Space):程序看到的内存地址,由 CPU 和操作系统共同映射到物理内存(内存条)上。
  • CLR (Common Language Runtime):公共语言运行时。它是 .NET 程序的"虚拟机"或"管家",负责代码执行、内存管理和安全验证。
  • JIT (Just-In-Time) 编译 :.NET 在程序运行时,根据你传入的具体类型(如 intdouble),动态生成一份专门处理该类型的机器码。
  • 托管堆 (Managed Heap) :由 CLR 管理的一块内存区域。程序员只管 new 对象,不需要手动 delete,生命周期由垃圾回收器(GC)控制。
  • 基址 (Base Address):内存空间的起始起始位置。
  • 地址空间保留 (Address Space Reservation):进程启动时,先向操作系统声明"我要用这么多内存",但实际还没占用物理内存,只有真正写入数据时才会提交(Commit)。
  • 虽然描述中提到它是"连续的",但实际上托管堆内部会根据对象大小分为 小对象堆 (SOH)大对象堆 (LOH)
    • SOH:存放小于 85,000 字节的对象,会进行内存压缩(移动对象位置以消除空隙)。
    • LOH:存放大型对象,默认不压缩(因为移动大数据太耗性能),这可能会导致内存碎片。
  • 虚拟地址空间 (Virtual Address Space):操作系统为每个进程虚构的一套连续内存地址。它并不直接对应内存条上的物理插槽,而是由操作系统负责映射。
  • NextObjPtr (下一个对象指针):这是 CLR 内部维护的一个变量,存储的是一个内存地址。它保证了托管堆分配内存的顺序性。
  • 分配代价 (Allocation Overhead) :在托管堆上分配内存的代价极低,因为它不需要像 C 语言的 malloc 那样去维护一个复杂的空闲链表(Free List)来寻找合适的空隙。
  • 引用 (Reference):在 C# 中,引用类型变量存放在栈上。它本质上是一个 4 字节(32位系统)或 8 字节(64位系统)的整数,代表了对象在堆中的"门牌号"。
  • NextObjPtr :它是 CLR 内部的一个内部变量 (隐藏在幕后)。它不属于你的代码,而是属于内存管理器。它的存在是为了让 new 操作像"移动光标"一样快。
  • 对象开销 (Object Overhead) :在堆上分配对象时,除了你的字段,CLR 还会额外存两个东西:Type Object Pointer (指向类型信息)和 Sync Block Index(用于线程锁)。
  • Reference (引用):一个固定大小的数值(内存地址),指向堆中的位置。它是引用类型的"本体"在栈上的表现形式。
  • Object Instance (对象实例):存储在堆上的实际数据,包含你定义的各种字段。
  • Allocation (分配)
    • 栈分配:极快,移动一下栈指针即可。方法结束,空间立即释放。
    • 堆分配 :较慢,需要寻找足够大的连续空间。由 GC (垃圾回收器) 负责清理。
相关推荐
simple_whu2 小时前
通过微软账号登录Windows远程桌面
microsoft
martian6652 小时前
Tauri 2.10 + NSIS 打包踩坑实录:解决 Windows 系统 NSIS 下载失败的方法(附资源包)
windows
山檐雾2 小时前
C#泛型缓存
缓存·c#
波波0072 小时前
.NET真的被上海信创排除在外?
.net
LAM LAB3 小时前
Windows 10误删除微软商店怎么装回来
microsoft
追雨潮3 小时前
内存向量检索引擎设计与实现:C# 轻量级 Milvus 替代方案
开发语言·c#·milvus
海参崴-3 小时前
三足鼎立:Linux、苹果macOS与微软Windows的前世今生及核心差异
linux·microsoft·macos
小江的记录本3 小时前
【Docker】 Docker 全平台部署(Linux / Windows / MacOS)与 前后端分离项目 容器化方案
java·linux·windows·http·macos·docker·容器
大空大地20263 小时前
LINQ数据访问技术
c#·linq