目录
-
运行库在栈和堆上分配空间
-
垃圾回收
-
使用析构函数和IDisposable接口来释放非托管资源
.Net语言托管类型分值数据类型和引用数据类型,还得处理非托管资源。
C#编程的一个优点是程序员不需要担心具体的内存管理,垃圾回收器会自动处理所有的内存清理工作。但仍需理解后台发生的事情。
【在栈和堆上分配空间】
【什么是内存】
32位处理器上,Windows 使用虚拟寻址系统,为每个进程分配了4GB的虚拟地址空间(64位处理器上,这个数字会更大),简称内存。
这4GB的内存实际上包含了程序的所有部分,包括可执行代码、代码加载的所有DLL,以及程序运行时使用的所有变量。
这4GB内存中有栈、托管堆、大对象堆。
【栈】
栈的作用为:
1.存储值类型(对象的值类型成员不在这里)
2.调用一个方法时,存储传递给方法的所有参数的副本
值类型根据类型所占的字节数,在栈上分配空间。
栈的工作原理:
压栈,先进后出。
1 int a; 2 { 3 int b; 4 }
先进先出,嵌套进行
声明a => 声明b => 释放b => 释放a
【托管堆】
托管堆的作用为:
希望使用一种方法分配内存,来存储一些数据,并在方法退出后的很长一段时间内数据仍是可用的。
只要使用new运算符来请求分配存储空间,就存在这种可能 -- 例如,对于所有的引用类型。
对象根据自身大小在堆上分配空间。
托管堆的工作原理:
假定已定义了类 Human, 考虑下面代码:
1 Human h1; 2 h1 = new Human();
>>> Human h1;
首先,声明一个 Human 引用变量 h1,在栈上给这个引用分配存储空间,这只是一个引用,它不是实际的 Human 对象
>>> h1 = new Human();
这行代码完成了以下操作,首先它分配堆上的内存,以存储 Human 对象, 然后把对象的内存地址保存在栈上的 h1 上。
h1 引用变量占用4个字节空间,为什么是4个字节? 因为4个字节足够表示一个存储实际对象的地址(需要4个字节把0~4GB之间的内存地址表示为一个整数)。
对象保存在堆中,对象的地址用4个字节的引用保存的栈中。
把一个引用变量的值赋予给另一个相同类型的变量,这时就存在2个引用内存中同一对象的引用变量了。
当一个引用变量超出作用域时,变量出栈,但它所引用的对象数据仍保留在堆中,一直到程序终止或垃圾回收器删除它为止。而只有在该对象不再被任何变量引用时,它才会被删除。
这就是引用数据类型的强大之处,在C#代码中广泛使用了这个特性。这说明我们可以对数据的生存期进行强大的控制,因为只有保持对数据的引用,该数据就肯定存在于堆上。
将对象赋值为null,只是清除了对数据的引用,只要还有其它引用存在,对象并不会被垃圾回收器回收。
【垃圾回收器】
变量在栈上使用完自动会出栈,不需要垃圾回收,垃圾回收器主要在堆上工作。
一般情况下,垃圾回收器在.NET运行库认为需要进行垃圾回收时运行。
垃圾回收器运行时,它实际上会降低应用程序性能,因为在垃圾回收器完成其任务之前,应用程序不能继续运行。所以最好让运行库决定何时进行垃圾回收。
传统堆在释放内存后,空闲内存可能是不连续的,这样就需要搜索整个堆来找到足够大的内存连续内存块来存储新对象。
托管堆在垃圾回收器的控制下工作,托管堆释放内存后,垃圾回收器会把对象移动到堆的端部(叫压缩堆操作)(同时垃圾回收器会更新对象引用中因为移动对象而造成的对象地址变动),这样空闲内存块就是连续的。尽管垃圾回收器有一些额外的开销,但这样在.Net下实例化对象和访问对象会比较快。
可以调用System.GC.Collect()方法,强迫垃圾回收器进行一次垃圾回收工作。但是垃圾回收器的逻辑不能保证在一次垃圾收集过程中,所有未引用的对象都能从堆中删除。
GC.Collect()有多个重载版本,指示对哪一代内存进行垃圾回收。
没有析构函数的对象能够保证在垃圾回收器的一次处理中被删除。
有析构函数的对象需要两次才能被删除。第一次回收后,对象所处的代会+1,所以对象会在内存中停留非常长的时间(不同代的回收频率不一样)。
垃圾回收器的工作方式:
堆中划分了第0代、第1代、第2代部分。
新创建的对象在第0代位置,经过1次垃圾回收,第0代位置剩余的对象会被压缩,然后移动到第1代位置。同理,第一代位置剩下的对象会移动到第2代位置。
第0代永远用来放新对象。
垃圾回收器假定第0代的对象生命周期最短。一般第0代和第1代的空间较小,第2代的空间要大得多,所以回收第0、1代时消耗较小。
0、1、2代的回收频率可能为1:10:100。
在给对象分配空间时,当第0代位置容量不够时,或者调用了GC.Collect()方法,就好进行垃圾回收。
另一种说法是当某一代容量不够时,回收某一代,所以即使对象不再被引用,但容量一直够,也不会发生垃圾回收。(待考证)
当Windows通知内存吃紧时,程序退出时,也会进行垃圾回收。
大于85000字节的对象存储在大对象堆上,压缩大对象比较昂贵,所以大对象堆上的对象不执行压缩过程。
垃圾回收器的出现意味着,不需要担心不再需要的对象,只要让这些对象的所有引用都超出作用域。
GC在判断对象是不是垃圾时,不是使用引用计数法,而是遍历堆中对象,对有引用的对象标记为非垃圾,这个引用计数不同。
-------以上托管类型由垃圾回收器自动进行 但不保证何时会释放,具有不确定性, 由系统的算法自行控制-------
【处理非托管资源】
对于非托管的数据类型(文件句柄,网络连接,数据库连接),垃圾回收器不知道如何释放非托管的资源。
即使是托管类,如果封装了对非托管资源的直接或间接的引用,垃圾回收器也是不知道如何释放的,必须先把非托管资源显示释放了,垃圾回收器才会去回收剩下的。
可以使用两种机制来自动释放非托管资源:
1.声明析构函数(终结器)。
2.实现System.IDisposable接口,在Dispose()方法中显示释放非托管资源。
【析构函数】
编译器会把析构函数编译进Finalize(),Finalize()由系统调用。
C#中很少使用析构函数,因为析构函数是在对象销毁时才调用的,而垃圾回收器在销毁对象时具有不确定性,不能保证对象何时会被销毁。
所有不能把需要在某一时刻运行的代码放在析构函数中。不能指望析构函数会按某种顺序执行。不要等待垃圾回收器销毁对象是才去释放非托管资源。
析构函数还会延迟对象最终从内存中删除的时间。没有析构函数的对象会在垃圾回收器的一次处理中删除,而又析构函数的对象需要两次。
第一次调用析构函数,第二次才真正删除对象。
运行库还使用了一个线程来执行Finalize()=>由析构函数编译而来,对性能影响非常显著。
【System.IDisposable接口】
推荐使用 IDisposable 替代析构函数。
Class MyClass : IDisposeble { public void Dispose() { // 显示释放 } }
Dispose()方法的实现代码显示地释放由对象直接使用的所有非托管资源,并在所有也实现IDisposable接口的封装对象上调用Dispose()方法。
这样,Dispose()方法为何时释放非托管资源提供了精确的控制,只要在使用完对象后就显示地调用一下Dispose()方法吧。
MyClass my;
try { my = new MyClass(); } catch(Exception) { } finally { my.Dispose(); // 保证会被调用到 }
使用using关键字自动调用Dispose()
using(MyClass my = new MyClass()) { //...dosomething } //出了using,即使异常了,也会自动调用Dispose()。
当程序已经使用try-catch来捕获其它异常了,建议使用有、第一种方法。
总之,在使用IDisposable接口释放资源时,要确保已经显示的调用了Dispose()方法。
下面考虑三个函数:Finalize()、Dispose()、Close()。
这三个函数都有释放资源的意思,那具体的区别是是么呢?
Finalize()函数由编译器将析构函数编译而得到,是由系统在删除对象时自动调用的。
Dispose()函数由实现IDisposable接口得到,一般在里面执行非托管资源的释放工作,由开发者来显示调用,用using块也可以自动的调用。
Close()函数比Dispose()函数更有逻辑性,比如在处理文件File,数据连接时,需要调用Close()。其实这些类也是实现了IDisposeable接口,再实现一个独立的Close()方法,Close()方法只调用了Dispose()方法。这种方法在类的使用上比较清晰。