托管堆和垃圾回收
一、托管堆基础
1,访问一个资源(文件、内存缓冲区、屏幕空间、网络连接、数据库资源等)所需的步骤
①调用IL指令newobj,为代表资源的类型分配内存(一般使用c# new操作符来完成)
②初始化内存,设置资源的初始状态并使资源可用。类型的实例构造器负责设置初始状态
③访问类型的成员来使用资源(有必要可以重复)
④摧毁资源的状态以进行清理
⑤释放内存。垃圾回收器独自负责这一步
2,从托管堆分配资源
初始化进程时,CLR划出一个地址空间区域作为托管堆,一个区域被非垃圾对象填满后,CLR会分配更多的区域(32位进程最多能分配1.5GB,64为进程最多能分配8TB)。CLR还要维护一个指针(NextObjPtr),该指针指向下一个对象在堆中的分配位值。刚开始的时候,NextObjPtr设为地址空间区域的基地址。
3,C#的new操作符导致CLR执行以下步骤
①计算类型的字段(以及从基类型继承的字段)所需的字节数。
②加上对象的开销所需的字节数。每个对象都有两个开销字段:类型对象指针和同步快索引(32位:两个字段各需32位,所以每个对象要增加8字节。64位:每个字段各需64位,所以每个对象要增加16字节)(int=4字节;long=8字节)
③CLR检查区域中是否有分配对象所需的字节数。如果托管堆有足够的可用空间,就在NextObjPtr指针指向的地址处放入对象,为对象分配的字节会被清零。接着调用类型的构造器(为this参数传递NextObjPtr),new操作符返回对象的引用。就在返回这个引用之前,NextObjPtr指针的值会加上对象占用的字节数来得到一个新值,即下一个对象放入托管堆是的地址
4,垃圾回收算法
CLR使用一种引用跟踪算法。引用跟踪算法只关心引用类型的变量,因为只有这种变量才能引用堆上的对象,我们将所有引用类型的变量都称为根。
①CLR开始GC时,首先暂停进程中的所有线程(这样可以防止线程在CLR检查期间访问对象并更改其状态)
②CLR进入GC标记阶段(这个阶段,CLR遍历堆中所有对象,将同步块索引字段中的一位设为0。这表明所有的对象都应该删除)
③CLR检查所有活动根(根为null,则CLR忽略这个根),查看他们引用了那些对象。如果引用了堆上的对象,CLR都会标记那个对象(将对象的同步块索引中的位设置为1)
④检查完毕后,堆中的对象要么标记。要么未标记。已标记的对象不能被垃圾回收,因为至少有一个根在引用它,我们说这些对象时可达的
⑤进入GC的压缩阶段,在这个阶段,CLR对堆中已标记的对象进行“乾坤大挪移”,压缩所有幸存下来的对象,使它们占用连续的内存对象
⑥压缩之后,根现在的引用还是原来的位置,而非移动之后的位置。所以作为压缩阶段的一部分,CLR还要从每个根减去所引用的对象在内存中的偏移的字节数。这样就能保证根还是引用和之前一样的对象;只是对象在内存中换了位置
5,垃圾回收和调试
①使用Release编译后,允许可执行文件,会发现TimerCallback方法只被调用了一次。因为Timer在初始化之后再也没有用过变量t。(调试模式下Timer对象不会被回收)
static void Main(string[] args) { //创建没2000毫秒就调用一次TimerCallback方法的timer对象 Timer t = new Timer(TimerCallback, null, 0, 2000); Console.ReadLine(); } private static void TimerCallback(object o) { Console.WriteLine("a"); //出于演示目的,强制执行一次垃圾回收 GC.Collect(); }
②显示要求释放计时器,它才能活到被释放的那一刻
static void Main(string[] args) { //创建没2000毫秒就调用一次TimerCallback方法的timer对象 Timer t = new Timer(TimerCallback, null, 0, 2000); Console.ReadLine(); //在ReadLine之后引用t(在Dispose方法返回之前,t会在GC中存活) t.Dispose(); } private static void TimerCallback(object o) { Console.WriteLine("a"); //出于演示目的,强制执行一次垃圾回收 GC.Collect(); }
二、代:提升性能
对象越新,生存期越短
对象越老,生存期越长
回收堆的一部分,速度快于回收整个堆
1,原理
①CLR初始化堆时为0代和1代选择预算容量(以kb为单位)。后期CLR会自动调节预算容量
②如果分配一个新的对象造成第0代超过预算,就必须启动一次垃圾回收
③经过垃圾回收之后,第0代的幸存者被提升到1代(第一代的大小增加);第0代又空了出来
④由于第0代已满,所以必须垃圾回收。但这一次垃圾回收器发现第1代用完了预算容量。所以这次垃圾回收器决定检查第1代和第0代的所有对象。两代被垃圾回收以后,第1代的幸存者提升到2代,第0代的幸存者提升到1代
2,垃圾回收触发的条件
①最常见触发条件:CLR在检查第0代超过预算时触发一次GC
②代码显示调用Sytem.GC的静态Collect方法
③Windows报告底内存情况
④CLR正在卸载AppDomain
⑤CLR正在关闭(CLR在进程正常终止时)
3,大对象
目前认为85000字节或更大的对象时大对象。(之前讨论的都是小对象)。大对象一般是大字符串(比如XML或JSON)或者用于I/O操作的字节数组(比如从文件或网络将字节读入缓冲区一遍处理)
①大对象不是在小对象的地址空间分配,而是在进程地址空间的其他地方分配
②目前版本的GC不压缩大对象,因为在内存中移动它们的代价过高
③大对象总是第2代,绝不可能是第0代或者第1代
4,垃圾回收模式
CLR启动时会选择一个GC模式,进程终止前该模式不会变。
①两个主要模式:
1>工作站
该模式针对客户端应用程序优化GC。GC造成的延时很低,应用程序线程挂起时间很短,避免是用户感到焦虑。
2>服务器
该模式针对服务器应用程序优化GC。被优化的主要是吞吐量和资源利用。
②应用程序模式以“工作站”GC模式运行
③显示告诉CLR使用服务器回收站
<runtime> <gcServer enabled="true"></gcServer> </runtime>
//询问CLR它是否在“服务器”GC模式中运行 Console.WriteLine(GCSettings.IsServerGC); Console.ReadLine();
④两个子模式(并发(默认)或非并发)
在并发模式中,垃圾回收器有一个额外的后台线性,它能在应用程序运行时并发标记对象
<runtime> <!--告诉CLR不要使用并发回收器--> <gcConcurrent enabled="false"></gcConcurrent> </runtime>
⑤GCSettings的LatencyMode属性对垃圾回收进行某种程度的控制
符号名称 |
说明 |
Batch(“服务器”GC模式的默认值) |
关闭并发GC |
Interactive(“工作站”GC模式的默认值) |
打开并发GC |
LowLatency |
在短期的、时间敏感的操作中(如果动画绘制)使用这个延迟模式。这些操作不适合对第二代进行回收 |
Sustained LowLatency |
使用这个延迟模式,应用程序的大多数操作都不会发生长的GC暂停。只要有足够的内存,它将禁止所有会造成阻塞的第二代回收操作。事实上,这种应用程序(例如需要迅速响应的股票软件)的用户应该考虑安装更多的RAM来防止发生生长的GC暂停 |
⑥正确的使用LowLatency
static void Main(string[] args) { GCLatencyMode oldModel = GCSettings.LatencyMode; Console.WriteLine(oldModel); //约束执行区域(CER) System.Runtime.CompilerServices.RuntimeHelpers.PrepareConstrainedRegions(); try { GCSettings.LatencyMode = GCLatencyMode.LowLatency; //在这里运行你的代码... } finally { GCSettings.LatencyMode = oldModel; } Console.ReadLine(); }
5,强制垃圾回收
public static void Collect(int generation, System.GCCollectionMode mode, bool blocking, bool compacting)
符号名称 |
说明 |
Default |
等同于不传递任何符号名称。目前还等同于Forced,但未来的版本可能对此进行修改 |
Forced |
强制回收指定的代(以及低于它的所有代) |
Optimized |
只有在能释放大量内存或者能减少碎片化的前提下,才执行回收。如果垃圾回收没有什么效率,当前调用就没有任何效果 |
如果写一个CUI(控制台用户界面)或GUI(图形用户界面)应用程序,你可能希望建议垃圾回收的时间;为此,请将GCCollectionMode设置为Optimized并调用Collect。Default和Forced模式一般用于调试、测试和查找内存泄露
如果刚才发生了某个非重复性的事件,并导致大量旧对象死亡,就可考虑手动调用一次collect方法。由于是非重复性事件,垃圾回收器基于历史的预测可能不准确。所以,这是调用collect方法时合适的
//查看某一代发生了多少次垃圾回收 Console.WriteLine(GC.CollectionCount(0)); //查看托管堆中的对象当前使用了多少内存 Console.WriteLine(GC.GetTotalMemory(true));
三、使用需要特殊清理的类型
包含本机资源的类型被GC时,GC会回收对象在托管堆中使用的内存。但这样会造成本机资源的泄漏,所以CLR提供了称为终结的机制,允许对象在被判定为垃圾之后,但在对象内存被回收之前执行一些代码。任何包装了本机资源(文件、网络连接、套接字、互斥体)的类型都支持终结。CLR判定一个对象不可达时,对象将终结自己,释放它包装的本机资源。之后,GC会从托管堆回收对象
1,Finalize
它是为释放本机资源而设计的
internal sealed class SomeType { //这是一个Finalize方法 ~SomeType() { //这里的代码会进入Finalize方法 } }
2,SafeHandle
创建封装了本机资源的托管类型是,应该先从using System.Runtime.InteropServices.SafeHandle这个特殊基类派生一个类
public abstract class SafeHandle : CriticalFinalizerObject, IDisposable { //这是本机资源句柄 protected IntPtr handle; protected SafeHandle(IntPtr invalidHandleValue, Boolean ownsHandle) { handle = invalidHandleValue; //如果ownsHandle为true,那么这个从SafeHandle派生的对象被回收时,本机资源会被关闭 } protected SafeHandle(IntPtr invalidHandleValue) { handle = invalidHandleValue; } //显式释放资源 public void Dispose(){Dispose(true);} //默认的Dispose实现(如下所示)正是我们希望的。强烈建议不要重写这个方法 protected virtual void Dispose(Boolean disposing) { //这个默认实现会忽略disposing参数 //如果资源已经释放,那么返回 //如果ownsHandle为true,那么返回 //设置一个标志来指明该资源已经释放 //调用虚方法ReleaseHandle //调用GC.SuppressFinalize(this)方法来阻止调用Finalize方法 //如果ReleaseHandled返回true,那么返回 //如果走到这一步,就激活ReleaseHandleFailed托管调试助手(MDA) } //派生类型要从写这个方法以实现释放资源的代码 protected abstract Boolean ReleaseHandle(); //默认的Dispose实现(如下所示)正是我们希望的。强烈建议不要重写这个方法 ~SafeHandle(){Dispose(false);} public void SetHandleAsInvalid() { //设置标志来指出这个资源已经释放 //调用GC.SuppressFinalize(this)方法来阻止调用Finalize方法 } public Boolean IsClosed { get { //返回指出资源是否释放的一个标志} } public abstract Boolean IsInvalid { //派生类要重写这个属性 //如果句柄的值不代表资源(通常意味着句柄为0或-1),实现应返回true get; } //以下三个方法设计安全性和引用计数 public void DangerousAddRef(ref Boolean success){} public IntPtr DangerousGetHandle(){} public void DangerousRelease(){} }
CLR赋予这个类以下三个很酷的功能
①首次构造CriticalFinalizerObject派生类型对象时,CLR立即对继承层次结构中的所有Finalize方法进行JIT编译。构造对象时接编译这些方法,可确保放当对象被确定为垃圾之后,本机资源肯定会得以释放。不对Finalize方法进行提前编译,那么也许能分配并使用本机资源,但无法保证释放。内存紧张时,CLR可能找不到足够的内存来编译Finalize方法,这会阻止Finalize方法的执行,造成本机资源泄漏。另外,如果Finalize方法中的代码引用了另一个程序集中的类型,但CLR定位该程序集失败,那么资源将得不到释放。
②CLR是在调用了非CriticalFinalizerObject派生类型的Finalize方法之后,才调用CriticalFinalizerObject派生类的Finalize方法。这样,托管资源类就可以在它们的Finalize方法中成功地访问CriticalFinalizerObject派生类型的对象。例如,FileStram类型的Finalize方法可以放心地将数据从内存缓冲区flush到磁盘,它知道此时磁盘文件还没有关闭
③如果AppDomain被一个宿主应用程序(例如Microsoft SQL Server或者Microsoft ASP.NET)强行中断,CLR将调用CriticalFinalizerObject派生类型的Finalize方法。宿主应用程序不再信任它内部允许的托管代码,也利用这个功能确保本机资源得以释放。
3,SafeHandle派生类
SafeHandle派生类非常有用,因为它们保证本机资源在垃圾回收得以释放
internal static class SomeType { //这个原型不健壮 [DllImport("Kernal32",CharSet = CharSet.Unicode,EntryPoint = "CreateEvent")] private static extern IntPtr CreateEventBad(IntPtr pSecurityAttribute, Boolean manualReset, Boolean initialState, string name); //这个原型是健壮的 [DllImport("Kernal32", CharSet = CharSet.Unicode, EntryPoint = "CreateEvent")] private static extern SafeWaitHandle CreateEventGood(IntPtr pSecurityAttribute, Boolean manualReset, Boolean initialState, string name); public static void SomeMethod() { IntPtr handle = CreateEventBad(IntPtr.Zero, false, false, null); SafeWaitHandle swh = CreateEventGood(IntPtr.Zero, false, false, null); } }
4,使用包装了本机资源的类型
1>以FileStream为例,可以用它打开一个文件,从文件中读取字节,向文件中写入字节,然后关闭文件
①FileStream对象在构造时会调用Win32 CreateFile函数
②函数返回句柄保存到SafeFileHandle对象中
③然后通过FileStream对象的一个私有字段来维护对象的引用
2>FileStream的Dispose方法
①FileStream实现了IDisposable接口。FileStream的Dispose方法会调用SafeFileHandle字段上的Dispose方法。
②FileStream调用Dispose方法会清理本机资源。(并非一定要调用Dispose才能保证本机资源得以清理。本机资源的清理最终总会发生,调用Dispose只是控制这个清理动作的发生时间)
③FileStream调用Dispose方法不会导致FileStram对象从托管堆中删除。只有在垃圾回收之后,托管堆中的内存才会得以回收
static void Main(string[] args) { //创建要写入临时文件的字节 byte[] bytesToWrite = new byte[] {1, 2, 3, 4, 5}; //创建临时文件 FileStream fs = new FileStream("Temp.dat", FileMode.Create); //将字节写入临时文件 fs.Write(bytesToWrite, 0, bytesToWrite.Length); //删除临时文件 File.Delete("Temp.dat");//抛出IOException异常 Console.ReadLine(); }
static void Main(string[] args) { //创建要写入临时文件的字节 byte[] bytesToWrite = new byte[] {1, 2, 3, 4, 5}; //创建临时文件 FileStream fs = new FileStream("Temp.dat", FileMode.Create); //将字节写入临时文件 fs.Write(bytesToWrite, 0, bytesToWrite.Length); //结束写入后显式关闭文件 fs.Dispose(); fs.Write(bytesToWrite, 0, bytesToWrite.Length);//抛出ObjectDisposedException //删除临时文件 File.Delete("Temp.dat");//抛出IOException异常 Console.ReadLine(); }
5,一个有趣的依赖性问题
//创建临时文件 FileStream fs = new FileStream("Temp.txt", FileMode.Create); StreamWriter sw = new StreamWriter(fs); sw.Write("abc"); //不要忘记这个Dispose的调用,不执行sw.Dispose()数据写不进文件 sw.Dispose(); //注意:调用StreamWriter.Dispose会关闭FileStream; //FileStream对象无需显示关闭
不需要再FileStream对象上显式调用Dispose,因为StreanWrite会帮你调用。但如果非要显式调用Dispose,FileStream会发现对象已经清理过了,所以方法什么都不做直接返回
6,终结器的内部工作原理
①应用程序创建新对象时,New操作符会从推中分配内存。如果对象的类型定义了Funalize方法,那么在该类型的实例构造器被调用之前,会将指向该对象的指针放到一个终结列表中
②垃圾回收时,对象B,D,E,F判定为垃圾。垃圾回收器扫描终结列表以查找这些对象的引用。找到一个引用之后,该引用从终结列表中移除,并附加到freachable队列中
③一个特殊的高优先级CLR线程专门调用Finalize方法。一旦freachable队列中有记录项出现,线程就会唤醒,将每一项都从freachable队列中移除,同时调用每个对象的Finalize方法。
④下一次对老一代垃圾回收时,会发现已终结的对象成为真正的垃圾,因为没有应用程序的根指向它们,freachable队列也不再指向它们,所以,这些对象的内存会直接回收
(注意:CLR会忽略System.Object定义的Finalize方法)
(注意:可终结对象需要执行两次垃圾回收才能释放它们的内存。在实际应用中,由于对象可能被提升至另一代,所以可能要求不止进行两次垃圾回收)
7,手动监视和控制对象的生存期
public struct GCHandle { //静态方法,用于在表中创建一个记录项 public static GCHandle Alloc(object value); public static GCHandle Alloc(object value,GCHandleType type); //静态方法,用于将一个GCHandle转成一个IntPtr public static explicit operator IntPtr(GCHandle value); public static IntPtr ToIntPtr(GCHandle value); //静态方法,用于将一个IntPtr转成一个GCHandle public static explicit operator GCHandle(IntPtr value); public static GCHandle FromIntPtr(IntPtr value); //实例方法,用于释放表中的记录项(索引设置为0) public void Free(); //实例属性,用于get/set记录项的对象引用 public object Target { get; set; } //实例属性,如果索引不为0,就放回true public Boolean IsAllocated { get; } //对于已固定(pinned)的记录项,这个方法返回对象的地址 public IntPtr AddrOfPinnedObject(); }
public enum GCHandleType { Weak = 0, //监事对象的存在 WeakTrackResurrection = 1, //监事对象的存在 Normal = 2, //控制对象的生存期 Pinned = 3 //控制对象的生存期 }
Weak:
该标志允许监视对象的生存期。可检测垃圾回收器再什么时候判定该对象在应用程序代码中不可达。注意,此时对象的Finalize方法可能执行,也可能没有执行,对象可能还在内存中。
WeakTrackResurrection:
该标志允许监视对象的生存期。可检测垃圾回收器在什么时候判定该对象在应用程序的代码不可达。注意,此时对象的Finalize方法(如果有的话)已经执行,对象的内存已经回收
Normal:
该标志允许控制对象的生存期。告诉垃圾回收器:即使应用程序中没有根引用对象,该对象也必须留在内存中。垃圾回收发生时,该对象的内存可以压缩(移动)。Alloc方法默认的标志
Pinned:
该标志允许控制对象的生存期。告诉垃圾回收器:即使应用程序中没有根引用对象,该对象也必须留在内存中。垃圾回收发生时,该对象的内存不压缩(移动)。需要将内存地址交给本机代码时,这个功能很好用。本机代码知道GC不会移动对象,所以能放心地向托管堆的这个内存写入。
1>垃圾回收器如何使用GC句柄表。当垃圾回收发生时,垃圾回收器的行为如下
①垃圾回收器标记所有可达的对象。然后。垃圾回收器扫描GC句柄表;所有Normal或Pinned对象都被看成是根,同时标记这些对象(包括对象通过他们的字段引用的对象)
②垃圾回收器扫描GC句柄表,查找所有Weak记录项。如果一个Weak记录项引用了未标记的对象,该引用标识的就是不可达对象(垃圾),记录项的引用值更改为null
③垃圾回收器扫描终结列表。在列表中,对未标记对象的引用标识的是不可达对象,这个引用从终结列表移至freachable队列,这是对象会被标记,因为对象又变成可达了
④垃圾回收器扫描GC句柄表,查找所有WeakTrackResurrection记录项。如果一个WeakTrackResurrection记录项引用了未标记的对象(它现在是有freachable队列中的记录项引用的),该引用标识的就是不可达对象(垃圾),该记录项的引用值更改为null
⑤垃圾回收器对内存进行压缩,填补不可达对象留下的内存“空调”,这其实就是一个内存碎片整理的过程。Pinned对象不会压缩(移动),垃圾回收器会移动它周围的其他对象