C#的一个优点就是程序员不需要担心具体的内存管理,但是还是需要理解后台内存管理时发生的事情。
1、值数据类型
我们知道值数据类型存储在内存中的一个称为堆栈的区域中。我们不知道堆栈在地址空间在什么地方,这些信息在进行C#开发时也是不需要知道的。堆栈指针(操作系统维护的一个变量)表示堆栈中的下一个自由空间的地址。程序第一次运行时,堆栈指针指向为堆栈保留的内存块末尾。堆栈实际上是向下填充的(即从高内出地址到低内存地址填充)。当数据入栈后,堆栈指针就会随之调整,使之始终指向下一个自由空间。
当堆栈数据超出其作用域时,计算机就知道不再需要这个变量了。因为变量的生成周期总是嵌套的,当值变量在作用域中时,无论发生什么情况,都可以保证堆栈指针总是会指向存储该变量的内存空间。当需要从内存中删除这个变量时,应该给堆栈指针递增其占用内存字节数,从而达到内存释放的目的。
如果编译器遇到int i,j这样的代码,由于这两个变量是同时声明的,也是同时出作用域的,因此尽管这两个变量进入作用域的顺序是不确定的,但是编译器会确保先放在内存中的那个变量后删除,这样就能保证该规则不会与变量的生存期冲突。
2、引用数据类型
所有的引用类型分配的内存在一个称为托管堆的内存区域。
如上代码,假设Customer为一个类。内存分配情况如下:
1)Customer a --> 声明一个Customer的引用a,在堆栈中给这个引用分配存储空间,这是一个引用,相当于C++中的指针,并不是实际的Customer对象。
2)a = new Customer(); ---> 分配托管堆上的内存,以存储Customer的实例。然后把变量a的值设置为分配给新Customer对象的内存地址。
为了在堆上找到一个存储新Customer对象的存储位置,.NET运行库在堆中搜索,选取一个未使用的,Customer对象需要占用的内存大小的连续内存块。
与堆栈不同,堆上内存是向上分配的。在建立引用变量的过程要比建立值变量的过程要复杂,且不可避免的照成性能的降低。另外在堆中添加数据时,.NET运行库需要保存堆的状态信息,这些信息也要更新。把一个引用变量的值赋予另一个相同类型的变量,就有两个引用了内存中同一对象的变量了。当一个引用变量出作用域时,它会从堆栈中删除,但引用对象的数据仍保留在堆中,一直到程序停止或垃圾收集器删除它为止,而只有在该数据不再被任何变量引用时才会删除。
3、垃圾收集
托管堆的工作方式非常类似于堆栈,在某种程度上,对象会在内存中一个挨一个地放置,这样就很容易使用下一个空闲存储单元的堆指针,来确定下一个对象的位置。在堆上添加更多的对象时也容易调整,但是这比较复杂,因为基于堆的对象的生存期与引用它们的基于堆栈的变量的作用域不匹配。
在垃圾收集器运行时,会在堆中删除不再引用的所有对象。在完成删除动作后,堆会立即把对象分散开来,与已经释放的内存混在一起。但是,垃圾收集器不会让对处于这种状态,只要它释放了能释放的所有对象,就会把其他对象移动回堆的端部,再次形成一个连续的内存块。同时垃圾收集器在移动对象时,这些对象的所有引用也同步用正确的新地址更新。
垃圾收集器的这个压缩操作是托管堆和非托管的旧堆的区别所在。使用托管的堆,就只需要读取堆指针的值即可,而不是搜索链接地址列表,来查找一个地方来放置新对象数据。因此在.Net下实例化对象会快得多。同时,访问它们也比较快,因为它们经过压缩到堆上相同的内存区域,导致需要交换的页面较少。
垃圾收集器虽然需要做一些工作,压缩堆,修改它移动的所有对象的引用。致使性能下降,但是在其他地方会得到弥补。
一般情况下,垃圾收集器在.Net运行库认为需要时运行。可以通过调用System.GC.Collect()强迫垃圾收集器在代码的某个地方运行。这种场合很少,如:代码中有大量的对象刚刚停止引用,就适合调用垃圾收集器,但是,垃圾收集器的逻辑不能保证在一次垃圾收集过程中,所有的未引用对象都从堆中删除。
4、释放未托管的资源
垃圾收集器不知道如何释放未托管的资源(如文件句柄,网络连接和数据库连接等)。托管类在封装未托管资源的直接或间接引用时,需要定制专门的规则,确保未托管的资源在回收类的一个实例时释放。
定义一个托管类时,可以使用两种机制自动释放未托管的资源。这些机制往往放在一起实现,因为每个机制都为问题提供了略微不同的解决方法:
1)声明一个析构函数(或终结器)作为类的一个成员
2)在类中执行System.IDisposable接口
4.1 析构函数
在底层的.NET结构中,析构函数被称为终结器(Finalizer),在C#中定义析构函数时,编译器发送给程序集的实际上是Fianlize()方法,并确保执行父类的Finalize()方法。
在使用C#析构函数时有两个问题:
1)由于垃圾收集器的工作方式,无法确定C#对象的析构函数何时执行。所以不能在析构函数中放置需要在某一时刻运行的代码,也不应使用能以任意顺序对不同类实例调用的析构函数。如果对象占用了宝贵而重要的资源,应尽快释放这些资源,此时就不能等待垃圾收集器来释放了。
2)C#析构函数的执行会延迟对象最终从内存中删除的时间。没有析构函数的对象会在垃圾收集器的一次处理中从内存删除,但有析构函数的对象需要两次处理才能删除:第一次调用析构函数时,没有删除对象,第二次调用才真正删除对象。另外,运行库使用一个线程来执行所有对象的Finalize()方法。如果频繁使用析构函数,而且使用它们执行很长时间的清理任务,对性能的影响就会非常显著。
4.2 IDisposable接口
在C#中,推荐使用IDisposable接口替代析构函数。它定义了一个模式(具有语言级的支持),为释放未托管的资源提供了确定的机制,并避免产生析构函数固有的与垃圾收集器相关的问题。
4.3 实现IDisposable接口和析构函数
释放未托管资源的两种实现方式:
1)利用运行库强制执行析构函数,但析构函数的执行是不确定的,而且,由于垃圾收集器的工作方式,它给运行库增加不可接受的系统开销。
2)IDisposable接口提供了一种机制,允许类的用户控制释放资源的时间,但需要确保执行Dispose().
一般情况下,最好的方法是执行这两种机制,获得这两种机制的优点。