【转】C#的内存管理:堆栈、托管堆与指针
在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类型的数组:
double* pDouble=stackalloc double[50]
stackalloc会给pDouble数组在堆栈中分配50个double类型大小的内存空间,可以使用pDouble[0]、*(pDouble+1)这种方式操作数组,与在C++中一样,使用指针时必须知道自己在做什么,确保访问的正确的内存空间,否则将会出现无法预料的错误。
掌握托管堆、堆栈、垃圾收集器和不安全代码的工作原理和方式,将有助于你成为真正的优秀C#程序员。
进程中每个线程都有自己的堆栈,这是一段线程创建时保留下的地址区域。我们的“栈栈(Stack)是操作系统在建立某个进程时或者线程(在支持多线程的操作系统中是线程)为这个线程建立的存储区域,该区域具有FIFO的特性,在编译的时候可以指定需要的Stack的大小。在编程中,例如C/C++中,所有的局部变量都是从栈中分配内存空间,实际上也不是什么分配,只是从栈顶向上用就行,在退出函数的时候,只是修改栈指针就可以把栈中的内容销毁,所以
堆(Heap)是应用程序在运行的时候请求操作系统分配给自己内存,一般是申请/给予的过程,C/C++分别用malloc/New请求分配Heap,用free/delete销毁内存。由于从操作系统
在Java中除了简单类型(int,char等)都是在堆中分配内存,这也是程序慢的一个主要原因。但是跟C/C++不同,Java中分配Heap内存是自动初始化的。在Java中所有的对象(包括int的wrapper Integer)都是在堆中分配的,但是这个对象的引用却是在Stack中分配。也就是说在建立一个对象时从两个地方都分配内存,在Heap中分配的内存实际建立这个对象,而在Stack中分配的内存只是一个指向这个堆对象的指针(引用)而已。
在.NET的所有技术中,最具争议的恐怕是垃圾收集(Garbage Collection,GC)了。作为.NET框架中一个重要的部分,托管堆和垃圾收集机制对我们中的大部分人来说是陌生的概念。在这篇文章中将要讨论托管堆,和你将从中得到怎样的好处。
为什么要托管堆?
.NET框架包含一个托管堆,所有的.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 方法。以下两种实现终止操作的方法是等效的:
~OverdueBookLocator()
{
Dispose(false);
}
和:
public void Finalize()
{
base.Finalize();
Dispose(false);
}
在C#中,同时在Finalize方法和析构函数实现终止操作将会导致错误的产生。
除非你有足够的理由,否则你不应该创建析构函数或者Finalize方法。终止操作会降低系统的性能,并且增加执行期的内存开销。同时,由于终止操作被执行的方式,你并不能保证何时一个终止操作会被执行。
内存分配和垃圾回收的细节
对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方法。这个方法被用来释放外部资源并抑制终止操作,就象下面这个程序片断所演示的那样:
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
}
}