2.1 .NET中栈和堆的差异?
每一个.NET应用程序最终都会运行在一个OS进程中,假设这个OS的传统的32位系统,那么每个.NET应用程序都可以拥有一个4GB的虚拟内存。.NET会在这个4GB的虚拟内存块中开辟三块内存作为 堆栈、托管堆 以及 非托管堆。
(1).NET中的堆栈
堆栈用来存储值类型的对象和引用类型对象的引用(地址),其分配的是一块连续的地址,如下图所示,在.NET应用程序中,堆栈上的地址从高位向低位分配内存,.NET只需要保存一个指针指向下一个未分配内存的内存地址即可。
对于所有需要分配的对象,会依次分配到堆栈中,其释放也会严格按照栈的逻辑(FILO,先进后出)依次进行退栈。(这里的“依次”是指按照变量的作用域进行的),假设有以下一段代码:
TempClass a = new TempClass(); a.numA = 1; a.numB = 2;
其在堆栈中的内存图如下图所示:
这里TempClass是一个引用类型,拥有两个整型的int成员,在栈中依次需要分配的是a的引用,a.numA和a.numB。当a的作用域结束之后,这三个会按照a.numB→a.numA→a的顺序依次退栈。
(2).NET中的托管堆
众所周知,.NET中的引用类型对象时分配在托管堆上的,和堆栈一样,托管堆也是进程内存空间中的一块区域。But,托管堆的内存分配却和堆栈有很大区别。受益于.NET内存管理机制,托管堆的分配也是连续的(从低位到高位),但是堆中却存在着暂时不能被分配却已经无用的对象内存块。
当一个引用类型对象被初始时,会通过指向堆上可用空间的指针分配一块连续的内存,然后使堆栈上的引用指向堆上刚刚分配的这块内存块。下图展示了托管堆的内存分配方式:
如上图所示,.NET程序通过分配在堆栈中的引用来找到分配在托管堆的对象实例。当堆栈中的引用退出作用域时,这时仅仅就断开和实际对象实例的引用联系。而当托管堆中的内存不够时,.NET会开始执行GC(垃圾回收)机制。GC是一个非常复杂的过程,它不仅涉及托管堆中对象的释放,而且需要移动合并托管堆中的内存块。当GC之后,堆中不再被使用的对象实例才会被部分释放(注意并不是完全释放),而在这之前,它们在堆中是暂时不可用的。在C/C++中,由于没有GC,因此可以直接free/delete来释放内存。
(3).NET中的非托管堆
.NET程序还包含了非托管堆,所有需要分配堆内存的非托管资源将会被分配到非托管堆上。非托管的堆需要程序员用指针手动地分配和释放内存,.NET中的GC和内存管理不适用于非托管堆,其内存块也不会被合并移动,所以非托管堆的内存分配是按块的、不连续的。因此,这也解释了我们为何在使用非托管资源(如:文件流、数据库连接等)需要手动地调用Dispose()方法进行内存释放的原因。
2.2 执行string abc="aaa"+"bbb"+"ccc"共分配了多少内存?
这是一个经典的基础知识题目,它涉及了字符串的类型、堆栈和堆的内存分配机制,因此被很多人拿来考核开发者的基础知识功底。首先,我们都知道,判断值类型的标准是查看该类型是否会继承自System.ValueType,通过查看和分析,string直接继承于System.Object,因此string是引用类型,其内存分配会遵照引用类型的规范,也就是说如下的代码将会在堆栈上分配一块存储引用的内存,然后再在堆上分配一块存储字符串实例对象的内存。
string a = "edc";
现在再来看看string abc="aaa"+"bbb"+"ccc",按照常规的思路,字符串具有不可变性,大部分人会认为这里的表达式会涉及很多临时变量的生成,可能C#编译器会先执行"aaa"+"bbb",并且把结果值赋给一个临时变量,再执行临时变量和"ccc"相加,最后把相加的结果再赋值给abc。But,其实C#编译器比想象中要聪明得多,以下的C#代码和IL代码可以充分说明C#编译器的智能:
// The first format string first = "aaa" + "bbb" + "ccc"; // The second format string second = "aaabbbccc"; // Display string Console.WriteLine(first); Console.WriteLine(second);
该C#代码的IL代码如下图所示:
正如我们所看到的,string abc="aaa"+"bbb"+"ccc";这样的表达式被C#编译器看成一个完整的字符串"aaabbbccc",而不是执行某些拼接方法,可以将其看作是C#编译器的优化,所以在本次内存分配中只是在栈中分配了一个存储字符串引用的内存块,以及在托管堆分配了一块存储"aaabbbccc"字符串对象的内存块。
那么,我们的常规思路在.NET程序中又是怎么体现的呢?我们来看一下一段代码:
int num = 1; string str = "aaa" + num.ToString(); Console.WriteLine(str);
这里我们首先初始化了一个int类型的变量,其次初始化了一个string类型的字符串,并执行 + 操作,这时我们来看看其对应的IL代码:
如上图所示,在这段代码中执行 + 操作,会调用String的Concat方法,该方法需要传入两个string类型的参数,也就产生了另一个string类型的临时变量。换句话说,在此次内存分配中,堆栈中会分配一个存储字符串引用的内存块,在托管堆则分配了两块内存块,分别存储了存储"aaa"字符串对象和"1"字符串对象。
可能这段代码还是不熟悉,我们再来看看下面一段代码,我们就感觉十分亲切熟悉了:
string str = "aaa"; str += "bbb"; str += "ccc"; Console.WriteLine(str);
其对应的IL代码如下图所示:
如图可以看出,在拼接过程中产生了两个临时字符串对象,并调用了两次String.Concat方法进行拼接,就不用多解释了。
2.3 简要说说.NET中GC的运行机制
GC是垃圾回收(Garbage Collect)的缩写,它是.NET众多机制中最为重要的一部分,也是对我们的代码书写方式影响最大的机制之一。.NET中的垃圾回收是指清理托管堆上不会再被使用的对象内存,并且移动仍在被使用的对象使它们紧靠托管堆的一边。下图展示了一次垃圾回收之后托管堆上的变化(这里仅仅为了说明,简化了GC的执行过程,省略了包含Finalize方法对象的处理以及大对象分配的特殊性):
如上图所示,我们可以知道GC的执行过程分为两个基本动作:
(1)一是找到所有不再被使用的对象:对象A和对象C,并标记为垃圾;
(2)二是移动仍在被使用的对象:对象B和对象D。
这样之后,对象A和对象C所占用的内存空间就被腾空出来,以备下次分配的时候使用。
PS:通常情况下,我们不需要手动干预垃圾回收的执行,不过CLR仍然提供了一个手动执行垃圾回收的方法:GC.Collect()。当我们需要在某一批对象不再使用并且及时释放内存的时候可以调用该方法来实现。But,垃圾回收的运行成本较高(涉及到了对象块的移动、遍历找到不再被使用的对象、很多状态变量的设置以及Finalize方法的调用等等),对性能影响也较大,因此我们在编写程序时,应该避免不必要的内存分配,也尽量减少或避免使用GC.Collect()来执行垃圾回收。
2.4 Dispose和Finalize方法在何时被调用?
由于有了垃圾回收机制的支持,对象的析构(或释放)和C++有了很大的不同,这就需要我们在设计类型的时候,充分理解.NET的机制,明确怎样利用Dispose方法和Finalize方法来保证一个对象正确而高效地被析构。
(1)Dispose方法
// 摘要: // 定义一种释放分配的资源的方法。 [ComVisible(true)] public interface IDisposable { // 摘要: // 执行与释放或重置非托管资源相关的应用程序定义的任务。 void Dispose(); }
Microsoft考虑到很多情况下程序员仍然希望在对象不再被使用时进行一些清理工作,所以.NET提供了IDispose接口并且在其中定义了Dispose方法。通常我们会在Dispose方法中实现一些托管对象和非托管对象的释放以及业绩业务逻辑的结束工作等等。
But,即使我们实现了Dispose方法,也不能得到任何有关释放的保证,Dispose方法的调用依赖于类型的使用者,当类型被不恰当地使用,Dispose方法将不会被调用。因此,我们一般会借助using等语法来帮助Dispose方法被正确调用。
(2)Finalize方法
刚刚提到Dispose方法的调用依赖于类型的使用者,为了弥补这一缺陷,.NET还提供了Finalize方法。Finalize方法类似于C++中的析构函数(方法),但又和C++的析构函数不同。Finalize在GC执行垃圾回收时被调用,其具体机制如下:
①当每个包含Finalize方法的类型的实例对象被分配时,.NET会在一张特定的表结构中添加一个引用并且指向这个实例对象,暂且称该表为“带析构方法的对象表”;
②当GC执行并且检测到一个不被使用的对象时,需要进一步检查“带析构方法的对象表”来查询该对象类型是否含有Finalize方法,如果没有则将该对象视为垃圾,如果存在则将该对象的引用移动到另外一张表,暂且称其为“待析构的对象表”,并且该对象实例仍然被视为在被使用。
③CLR将有一个单独的线程负责处理“待析构的对象表”,其执行方法内部就是依次通过调用其中每个对象的Finalize方法,然后删除引用,这时托管堆中的对象实例就被视为不再被使用。
④下一个GC执行时,将释放已经被调用Finalize方法的那些对象实例。
(3)结合使用Dispose和Finalize方法:标准Dispose模式
Finalize方法由于有CLR保证调用,因此比Dispose方法更加安全(这里的安全是相对的,Dispose需要类型使用者的及时调用),但在性能方面Finalize方法却要差很多。因此,我们在类型设计时一般都会使用标准Dispose模式:Finalize方法作为Dispose方法的后备,只有在使用者没有调用Dispose方法的情况下,Finalize方法才被视为需要执行。这一模式保证了对象能够被高效和安全地释放,已经被广泛使用。
下面的代码则是实现这种标准Dispose模式的一个模板:
public class BaseTemplate : IDisposable { // 标记对象是否已经被释放 private bool isDisposed = false; // Finalize方法 ~BaseTemplate() { Dispose(false); } // 实现IDisposable接口的Dispose方法 public void Dispose() { Dispose(true); // 告诉GC此对象的Finalize方法不再需要被调用 GC.SuppressFinalize(this); } // 虚方法的Dispose方法做实际的析构工作 protected virtual void Dispose(bool isDisposing) { // 当对象已经被析构,则不必再继续执行 if(isDisposed) { return; } if(isDisposing) { // Step1:在这里释放托管资源 } // Step2:在这里释放非托管资源 // Step3:最后标记对象已被释放 isDisposed = true; } public void MethodA() { if(isDisposed) { throw new ObjectDisposedException("对象已经释放"); } // Put the logic code of MethodA } public void MethodB() { if (isDisposed) { throw new ObjectDisposedException("对象已经释放"); } // Put the logic code of MethodB } } public sealed class SubTemplate : BaseTemplate { // 标记子类对象是否已经被释放 private bool disposed = false; protected override void Dispose(bool isDisposing) { // 验证是否已被释放,确保只被释放一次 if(disposed) { return; } if(isDisposing) { // Step1:在这里释放托管的并且在这个子类型中申明的资源 } // Step2:在这里释放非托管的并且这个子类型中申明的资源 // Step3:调用父类的Dispose方法来释放父类中的资源 base.Dispose(isDisposing); // Step4:设置子类的释放标识 disposed = true; } }