首页 > 编程笔记 > C#笔记

C#/.NET堆与栈

堆与栈都是内存空间的一部分,其中,堆又可以分为托管堆和非托管堆。托管堆和栈由 CLR 管理。

对托管堆中的一部分——GC 堆中不用的对象进行释放就是垃圾回收的主要工作,而托管堆的其他部分,和开发者关系相对没有那么大。对非托管堆的管理则需要由开发者完成。

在 CLR 开始执行第一行代码之前,它会先建立三个程序域:系统域、共享域以及默认的一个应用程序域(AppDomain 类型的一个实例)。

其中,开发者无法直接操作系统域和共享域,但 AppDomain 类型的实例可以有多个。

对于简单的程序,例如控制台程序,第一个默认域的名称就是执行文件的全名,例如 abc.exe。可以使用 CreateDomain 方法创建更多的应用程序域。

每一个 AppDomain 的实例都有自己的加载堆,下面就会介绍加载堆到底是什么。

这里的堆(heap)是托管堆(managed heap)的简称。顾名思义,它由 CLR 进行管理。

它是在运行程序时,CLR 申请的一块内存空间。它基于进程,属于进程内存空间的一部分。

这块空间可以划分为下面几个主要部分:

大对象堆(large object heap)是 2 代堆的一部分,它存放超过 85KB 大小的对象。

因为大对象堆里面的对象太大,移动代价过高,所以微软设计的意图是直接将它提升到 2 代,避免升代移动引起的性能损失。

其中,加载堆(loader heap)存在于每一个程序域中,存放 CLR 自己的类型系统以及用户定义的类型对象。

不同域的加载堆存放的对象不同。另外,AppDomain 的加载堆也存放静态对象,由于静态对象是全局的,不会成为垃圾,所以加载堆不受垃圾收集器管辖。

加载堆又可以分为高频堆(大小为 32KB),低频堆(大小为 8KB)等。顾名思义,高频堆存放的是 CLR 认为访问次数可能较多的对象,例如类型方法表等。

下图简单展示了托管堆的结构。

托管堆结构简图

还有其他的堆,例如 JIT 代码堆,用来存放 JIT 之后的本地代码,但一些较不常见的堆并不重要。

这里记住垃圾收集器只会光顾 GC 堆就可以了。

当创建新对象时,若该对象是引用类型或者包括引用类型的值类型,就会在 GC 堆上申请空闲的内存空间,CLR 会先计算需要的空间大小。

如果堆上已经没有剩余空间了,就触发一次垃圾回收。

如果回收之后仍然无法获得足够的剩余空间,则掷出 OutOfMemory 异常。

GC 堆维护一个叫做 NextObjPtr 的指针,指向 GC 堆的下一个可用地址。

为了尽量合理利用空间,GC 堆的内存分配是连续的。

当垃圾收集结束之后,由于有些空间被释放,内存可能出现碎片,此时,会进行压缩,将内存重新变回连续状态。

当然,GC 堆只是全部内存资源的一小部分。

对于非托管资源来说,它们会占用另一部分的内存。 这块空间叫做本地堆(Native Heap),或者非托管堆(Unmanaged Heap),CLR 不负责这块 空间的垃圾回收。

非托管资源有很多,比如文件流、数据库连接、打印机资源等。

如果没有妥善地处理非托管资源,就会发生各种稀奇古怪的错误。

栈(stack),又称“线程栈”(thread stack),顾名思义,它是基于线程的。

它的空间比较小,在每开启一个新的线程时,从内存中开辟大约 1M 空间,作为该线程的自留地。

线程栈是一个先进后出的栈数据结构,所以它一直都是连续的。

CLR 维护一个指针,指向栈的下一个自由空间的地址,当成员出入栈时,指针地址跟着发生变化。

因为栈中的对象离开了定义域就会被自动销毁,通常栈的空间是够用的。

不过,如果程序写的有问题,还是可能会爆栈,此时就会掷出大名鼎鼎的 StackOverflow 异常。

写岀一个爆栈的程序很容易,例如一个没有出口的递归即可,此时,所有的变量都还在它们的定义域中。

对于非静态的、纯粹的值类型(例如,不包含任何引用类型成员的结构体),初始化时,CLR 会计算它需要的空间大小,然后将其值存储在栈上。例如,一个 int 的大小是 4 个字节。

而对于引用类型,它也会使用栈,但栈上仅仅存储一个地址(即引用),就是它在托管堆上的内存地址。

通过访问栈上的地址,就可以间接访问到堆上的引用类型对象,以及对象真正的成员和它们的值。

由于栈有着得天独厚的优势(只能从顶部放入和拿走数据),栈中的内存总是连续的,不需要进行 GC。

所有教程

优秀文章