.NET基础 (05)内存管理和垃圾回收
内存管理和垃圾回收
1 简述.NET中堆栈和堆的特点和差异
2 执行string abc="aaa"+"bbb"+"ccc"共分配了多少内存
3 .NET中GC的运行机制
4 Dispose方法和Finalize方法在何时被调用
5 GC中代(Generation)是什么,一共分几代
6 GC机制中如何判断一个对象是否仍在被使用
7 .NET的托管堆中是否可能出现内存泄漏现象
内存管理和垃圾回收
每一个.NET程序都最终会运行在一个操作系统中,假设这个操作系统是传统的32位操作系统,那么每个.NET程序都可以拥有一个4GB的虚拟内存。.NET会在这个4GB的内存块中开辟出3块内存分别作为堆栈、受托管的堆和非托管的堆。
.NET中的堆栈
.NET中的堆栈用来存储值类型的对象和引用类型对象的引用,堆栈的分配是连续的,在.NET程序中,始终存储了一个特殊的指针指向堆栈的尾部,这样一个堆栈内存的分配就直接从这个指针指向的内存位置开始向下分配。
堆栈上的地址从高位开始往低位分配内存,.NET只需要保存一个堆栈指针指向下一个未分配的内存地址。对于所需要分配的对象,依次分配到堆栈中,其释放也完全按照栈的逻辑,依次进行退栈。这里提到的“依次”,是指按照变量的作用域进行的。
ClassA a = new ClassA(); a.inta = 1; a.intb = 2;
这里假设ClassA是引用类型,则堆栈中依次需要分配a的引用、a.inta和a.intb。当a的作用域结束后,这3个变量则从堆栈中依次退出:a.intb、a.inta,然后才是a。
.NET中的托管堆
.NET中引用类型的对象是分配到托管堆上的。通常我们称.NET中的堆,指的就是托管堆。托管堆也是进程内存空间中的一块区域。托管堆的分配也是连续的。但是堆中存在暂时不能被分配却已经无用的对象内存块。当一个引用类型对象初始化时,就会通过堆上可用空间的指针分配一块连续的内存,然后使用堆栈上的引用指向堆上的这块内存块。
程序通过分配在堆栈上的引用来找到分配到托管堆上的对象实例。当堆栈中的引用退出作用域时,就仅仅断开引用和实际对象的联系。而当托管堆中的内存不够时,.NET开始执行垃圾回收。垃圾回收是一个非常复杂的过程,它不仅涉及托管堆中对象的释放,而且需要引动合并托管堆中的内存块。当垃圾回收后,堆内不被使用的对象才会被部分释放,而在这之前,它们在堆内是暂时不可用的。
.NET中的非托管堆
所有需要分配内存的非托管资源将会被分配到非托管堆上。非托管堆需要程序员用指针手动地分配并且手动释放。.NET的垃圾回收和内存管理制度不适用于非托管堆。
堆栈、托管堆、非托管堆的比较
堆栈的内存是连续分配的,按照作用域依次分配和释放。.NET依靠一个堆栈指针就可以进行内存操作,分配一个对象和释放一个对象的大部分操作就是自增或者自减堆栈指针。.NET中值类型对象和应用类型对象的引用是分配在堆栈中的。
托管堆的内存分配也是连续的,但它比堆栈复杂的多。一块内存分配需要涉及很多.NET内存管理机制的内部操作,另外当内存不够时,垃圾回收的代价也是非常大的。相对于堆栈,堆的分配效率低很多。.NET中引用类型对象是分配到托管堆上的,这些对象通过分配到堆栈上的引用来进行访问。
非托管堆和托管堆的区别在于非托管堆不受.NET的管理。非托管堆的内存是由程序员手动分配和释放的,垃圾回收机制不使用于非托管堆,内存块也不会被合并移动,所以非托管堆的内存分配是按块的、不连续的。
2 执行string abc="aaa"+"bbb"+"ccc"共分配了多少内存
它在堆栈上分配了一个存储字符串引用的内存块,并在托管堆上分配了一块用以存储“aaabbbccc”这个字符串对象的内存块。
static void Main(string[] args) { string str1 = "aaa" + "bbb" + "ccc"; string str2 = "aaabbbccc"; string str3 = "aaa" + "bbb" + 2.ToString(); Console.WriteLine(str1); Console.WriteLine(str2); Console.WriteLine(str3); Console.ReadKey(); }
对应的IL代码:
.method private hidebysig static void Main(string[] args) cil managed { .entrypoint // 代码大小 57 (0x39) .maxstack 2 .locals init ([0] string str1, [1] string str2, [2] string str3, [3] int32 CS$0$0000) IL_0000: ldstr "aaabbbccc" IL_0005: stloc.0 IL_0006: ldstr "aaabbbccc" IL_000b: stloc.1 IL_000c: ldstr "aaabbb" IL_0011: ldc.i4.2 IL_0012: stloc.3 IL_0013: ldloca.s CS$0$0000 IL_0015: call instance string [mscorlib]System.Int32::ToString() IL_001a: call string [mscorlib]System.String::Concat(string, string) IL_001f: stloc.2 IL_0020: ldloc.0 IL_0021: call void [mscorlib]System.Console::WriteLine(string) IL_0026: ldloc.1 IL_0027: call void [mscorlib]System.Console::WriteLine(string) IL_002c: ldloc.2 IL_002d: call void [mscorlib]System.Console::WriteLine(string) IL_0032: call valuetype [mscorlib]System.ConsoleKeyInfo [mscorlib]System.Console::ReadKey() IL_0037: pop IL_0038: ret } // end of method Program::Main
可见C#编译器将"aaa"+"bbb"+"ccc"编译成功"aaabbbccc"。但是对于"aaa" + "bbb" + 2.ToString() 则编译成了"aaabbb"和临时变量CS$0$0000,还要对对临时变量ToString(),最后还要合并。
垃圾回收是指释放托管堆上不再被使用的对象内存。其过程包括:通过算法找到不再被使用的对象、移动对象是所有仍在被使用的对象紧靠托管堆的一边和调整各个状态变量。
垃圾回收的运行成本很高,对性能的影响较大。程序员在编写.NET代码时,应该避免不必要的内存分配,尽量减少或避免使用GC.Collect来执行垃圾回收。
实现了Dispose方法不能得到任何有关释放的保证,Dispose方法的调用依赖于类型的使用者。当类型被不恰当的使用,Dispose方法将不会被调用,但using语法的存在帮助了类型Dispose方法的调用。
由于Dispose方法的调用依赖于使用者,为了弥补这一缺陷,.NET同时提供了Finalize方法。Finalize方法在GC执行垃圾回收时调用,具体机制如下:
- 当每个包含Finalize方法的类型的实例对象被分配时,.NET会在一张特定的表结构中添加一个引用并且指向这个实例对象。方便起见称为“带析构对象表”。
- 当GC执行并且检测到一个不被使用的对象是,需要进一步检查“带析构对象表”来查看该对象类型是否有Finalize方法,如果没有则该对象被视为垃圾,如果存在Finalize方法,则把该对象的引用从“带析构对象表”移到另外一张表中,这里暂时称它为“等待析构表”。并且该对象实例被视为仍在被使用。
- CLR将有一个单独的线程负责处理“等待析构表”,其方法就是依次通过引用调用其中每个对象的Finalize方法,然后删除引用,这时托管堆中的对象实例将处于不再被使用的状态。
- 下一个GC执行时,将释放已经被调用Finalize方法的那些对象实例。
Dispose和Finalize方法都是为了释放对象中的非托管资源。
Dispose方法被使用者主动调用,而Finalize方法在对象被垃圾回收的第一轮回收后,由一个专用.NET线程进行调用。Dispose方法不能保证被执行,而.NET的垃圾回收机制保证了拥有Finalize方法并且需要被调用的类型对象的Finalize方法被执行。调用Finalize方法性能代价非常高,程序员可以通过GC.SupressFinalize方法通知.NET对象的Finalize方法不需要被调用。
垃圾回收按照对象不被使用的可能性把托管堆内的对象分为3代:0代、1代、2代。越小的代拥有越多的释放机会,CLR每执行n次0代回收,才会执行1次1代回收,每执行n次1代回收,才执行1次2代回收,而每一次GC中任存活的对象实例将被移到下一代上。
当没有任何引用指向堆中的某个对象的实例时,这个对象就被视为不再使用。
垃圾回收机制把引用分为以下2类:
根引用:往往指那些静态字段的引用,或者存活的局部变量的引用。
非根引用:指那些不属于根引用的引用,往往是对象实例中的字段。
垃圾回收时,GC从所有仍在使用的根引用出发遍历所有对象实例,那些不能遍历到的对象将被视为不再被使用而进行回收。
查看下面代码:
class Employee { public Employee _boss; public override string ToString() { if (_boss == null) { return "没有BOSS"; } else { return "有一个BOOS"; } } } class Program { public static Employee staticEmployee; static void Main(string[] args) { staticEmployee=new Employee();//静态变量 Employee a=new Employee();//局部变量 Employee b=new Employee();//局部变量 staticEmployee._boss=new Employee();//实例成员 Console.Read();
Console.WriteLine(a); } }
代码中拥有两个局部变量和一个静态变量,这些引用都是根引用。其中一个局部变量a拥有一个成员实例对象,这个引用就是一个非根引用。当代码执行到Console.Read()时,存活的根引用有staticEmployee和a,前者是因为它是一个公共静态变量,后这是因为后续代码任然使用a。通过这两个存活的引用GC会找到一个非根引用staticEmployee._boss,并且发现3个仍然存活的对象。而b的对象则被视为不再使用而被释放。
这里GCzzz侦测出b引用不再被使用从而释放了b对象,更简单地确保b对象被视为不再被使用的方法是把b引用置null,即b=null。
当一个从根引用出发遍历抵达一个已经被视为使用的对象时,将结束这一分支的遍历,这样做是为了避免死循环。
.NET托管堆可能出现严重的内存泄露现象,主要原因有:大对象的频繁分配和释放、不恰当地保留根引用和错误的Finalize方法。
大对象的分配
.NET中的大对象被分配在托管堆的一个特殊的区域,这里暂时称呼它为“大对象堆”。在回收大对象堆内的对象时,其他的大对象不会被移动,这是考虑到大规模地移动对象需要耗费过的资源。这样,程序过多的分配和释放大对象后,会产生很多内存碎片。程序员应该尽量减少大对象的分配次数,尤其是那些作为局部变量的,将被大规模分配和释放的大对象,典型的例子就是String类型。
不恰当地保存根引用
最常见的错误就是把一个对象申明为公共静态变量,一个公共静态变量将一直被GC视为一个在使用的根引用。当这个对象内部还包含更多的对象引用是,这些对象同样不会被释放。这里只是从性能方面考虑问题,在实际设计时还要考虑程序的架构和可扩展性。
不正确的使用Finalize方法
Finalize方法应该只致力于快速而简单地释放非托管资源,并且尽可能快地返回。不正确的Finalize方法可能包含这样的代码:
- 没有保护地写文件日志
- 访问数据库
- 访问网络
- 把当前对象赋给某个存活的引用
转载请注明出处: