02-C#中的内存管理

[TOC]

内存管理

一、托管堆基础

在面向对象中,每个类型代表一种可使用的资源,要使用该资源,必须为代表资源的类型分配内存:

  • 调用IL指令newObj,为代表资源的类型分配内存(一般使用new操作符完成);
  • 初始化内存,设置资源的初始状态,并使资源可用。类型的实例构造器完成该步骤;
  • 访问类型的成员来使用资源;
  • 摧毁资源的状态以进行清理;
  • 释放内存。由GC独立完成。

一般只要是可验证的、类型安全的代码(不使用 unsafe 关键字),内存一般不会被破坏。GC后仍可能出现内存泄漏的情况:

  • 在集合中存储了对象,但没有按需移除对象;
  • 静态字段引用某个集合对象,使集合一直存活,然后不停向集合中添加数据。

对于一般包装了本机资源,如文件、套接字和数据库连接等,的类型需要调用 Dispose 方法尽快手动清理,而不是等待GC接入。

1.1 从托管堆分配资源

托管堆与进程控件
进程初始化时,CLR划分出一个地址控件作为托管堆,并要求所有的对象都要从托管堆分配。
内用程序的内存受进程的虚拟地址空间限制,32位进程最多能分配1.5GB,64位最多能分配8TB。
托管堆中现有控件被非垃圾对象填满后,CLR会继续分配更多区域,直到整个进程地址空间被填满。

NextObjPtr指针
为了托管堆正常工作,CLR维护了一个指针,姑且命名为 NextObjPtr。它指向下一个对象在堆中的分配位置,初始时,该对象指向托管堆空间的基地址。

new操作符

  • 计算对象所需的字节数;
  • CLR检查区域空间并放入对象;
    对象的所需内存包括以下两部分:
  • 类型字段:自身包含的所有字段所需的内存(自身定义 + 基类继承);
  • 开销字段:每个对象都存在两个开销字段,类型对象指针同步快索引;
    • 32位程序,两个字段各需32位即4字节,每个对象增加8字节空间。
    • 64位程序,两个字段各需64位即8字节,每个对象增加16字节空间。

CLR初始化对象有以下步骤:

  • CLR检查托管堆中是否存在分配对象所需的字节数;
  • 若足够则在NextObjPtr当前指向位置放入对象,初始化各字段内存(若不够则执行垃圾回收);
  • 调用类型构造器(为this参数传递 NextObjPtr),使用对象内存更新指针位置,使指针指向下一个对象在堆中的位置;
  • new 操作符返回对象引用;

局部性原理
局部性原理:cpu访问存储器时,无论存取指令还是存取数据,所访问的存储单元都趋于聚集在一个较小的连续趋于中。
托管堆的性能体现:

  • 分配对象高速:只需要在指针上加一个新值即可;
  • 强关联的对象连续分配:如在分配BinaryWriter之前需要分配一个FileStream,而BinaryWriter在内部使用了FileStream;这些对象可以连续分配,并全部驻留在cpu缓存中;
  • CPU缓存数据可以以惊人的速度访问,而不会因为 cache miss 导致访问较慢的 RAM;

1.2 垃圾回收算法

1.2.1 传统的引用计数算法

原理:堆上的每个对象都维护者一个内存字段来统计程序中多少“部分”正在使用对象,当某个对象不再使用该对象时,就递减对象的引用计数字段,一旦该字段变成0,就从内存中删除该字段。
应用:大多数系统,如IOS、MOS、Microsoft的“组件对象模型”(Component Object Model, COM)等;
弊端:处理不好循环引用,通常需要加入其它的手段进行辅助控制,如弱引用;

1.2.2 引用跟踪算法

原理:引用跟踪算法只关心引用类型的变量,包括静态的、实例的、方法参数和局部变量,所有的引用类型变量称为根。过程为:

  • 准备阶段:CLR开始GC时,首先暂停进程中所有的线程,防止线程在CLR检查期间更改对象的状态;
  • 标记阶段:
    • CLR将堆中所有对象的同步块索引中的一位置为0,代表所有对象都应该删除;
    • CLR检查所有活动根,若根存在引用的对象,则将被引用对象的同步块索引中的位设为1,再检查被标记对象的所有根,标记它们所引用的对象。
    • 若发现某个对象已被标记,就不再检查对象的字段,避免遇到循环引用,检查陷入死循环。
    • 已标记的对象说明至少有一个根在引用,称为可达(reachable),不能被回收;
    • 未标记的对象不存在使该对象能再次访问的根,称为不可达(unreachable),可被回收;
  • 压缩(compact)阶段:
    • 压缩所有幸存的对象,使他们占用连续的内存,恢复引用的“局部化”,减小了进程的工作集,提升了将来访问这些对象的性能。
    • CLR从每个根减去所引用的对象再内存中偏移的字节数,使得对象能够正确寻址访问;
    • 托管堆的 NextObjPtr 指针指向最后一个幸存对象之后的位置;
      注意:若一次GC操作回收不到足够的内存,使得不足以再次分配新内存后,使用new操作符会抛出 OutOfMemoryException 异常。

1.2.3 GC优点

垃圾回收系统的好处有:

  • 无内存泄漏;
  • 无内存损坏;
  • 无地址控件碎片化;
  • 缩小进程工作集;
  • 同步线程;

说明:GC能作为线程同步机制来使用。由于GC会终结对象,所以可以知道所有线程都不再使用一个对象。

1.2.4 实例

public static class Program {
    public static void Main() {
        // 创建每2000ms就调用一次 TimerCallBack 方法的 Timer 对象
        Timer t = new Timer(TimerCallBack, null, 0, 2000);

        Console.ReadLine();

        // t = null; 无效代码,这种情况下会被JIT编译器优化掉
    }
    private static void TimerCallback(Object o) {
        Console.WriteLine("In TimerCallback: " + DateTime.Now);

        // 强制执行一次GC
        GC.Collect();
    }
}

现象:DEBUG模式下,timer会一直保持存活;RELEASE模式下,timer只调用了一次,在GC.Collect()中被强制回收了。
说明:在Debug下,JIT编译器会强制将变量的生存期延长至方法结束,所以会一直运行。注意,由于GC提前调用,JIT编译器会优化掉 t = null; 之类的无效代码;

二、代

代的工作假设前提:

  • 对象越新,生存期越短;
  • 对象越老,生存期越长;
  • 回收堆的一部分,速度快于回收整个堆;

GC代规则:

  1. GC为第0代设置一个预算容量,每次将不能回收的对象移动到第一代(第一次初始化第1代时,会为第1代分配一个预算空间);
  2. 当第1代满时,会回收第1代,并将回收不了的对象移动到第2代(首次初始化第2代,会分配一个预算空间)。重复1、2步骤;
  3. 当第2代满时,会检查并回收第2代空间,若空间不足,则抛出OutOfMemoryException异常;

GC对每一代的空间预算是动态调节的,如GC发现每次回收0代后,存活对象很少,就可能减少第0代的预算。
但已分配空间的减少,会使GC更频繁,但每次工作量也变少了,进程的工作集同时也减小了。
反之,每次回收0代后,存活对象多,则会增大预算。使得GC频率减少,单次工作量大。
对于第1代和第2代也使用同样的启发式算法来动态调整内存,根据App要求的内存负载来自动优化,提升App的整体性能。

public static class GCNotification {
    private static Action<Int32> s_gcDone = null;   // 事件字段

    public static event Action<Int32> GCDone {
        add {
            // 若之前没有登记的委托,就开始报告通知
            if(s_gcDone == null) { new GenObject(0); new GenObject(2);}
            s_gcDone += value;
        }
        remove { s_gcDone -= value; }
    }

    private sealed class GenObject {
        private Int32 m_generation;
        public GenObject(Int32 generation) { m_generation = generation; }
        ~GenObject() { // 这是 Finalize 方法
            // 若这个对象在我们希望的或更高的代中,就通知委托一次GC刚刚完成
            if(GC.GetGeneration(this) >= m_generation) {
                Action<Int32> temp = Volatile.Read(ref s_gcDone);
                if(temp != null) temp(m_generation);
            }
            // 若至少还存在已登记的委托,且AppDomain并非正在卸载,进程并非正在关闭,就继续报告通知
            if((s_gcDone != null)  
                && !AppDomain.CurrentDomain.IsFinalizingForUnload()
                && !Environment.HasShutdownStarted) {
                    // 对于第0代,创建一个新对象
                    // 对于第2代,复活对象,使第2代在下次回收时,GC会再次调用Finalize
                    if(m_generation == 0) new GenObject(0);
                    else GC.ReRegisterForFinalize(this);
                }
            else {
                /* 放过对象,让其被回收 */
            }
        }
    }
}

2.1 垃圾回收触发条件

  • CLR在检测到第0代超出预算时触发一次;
  • 显示调用 System.GC.Collect 方法;
  • Windows 通过Win32函数检测到内存低时触发;
  • CLR 正在卸载 AppDomain 时(一个AppDomain卸载时,CLR认为其中一切都不是根,执行一次涵盖所有代的GC);
  • CLR 正在关闭。CLR在进程正常终止时,CLR认为进程中的一切都不是根。但此时CLR不会视图压缩或释放内存,由Windows回收进程的全部内存。

2.2 大对象

大对象对于性能提升有很大影响。CLR将对象分为大对象和小对象,且以不同的方式对待他们。
CLR认为超过8500字节(约0.08M)或更大的对象是大对象。

  • 大对象不是在小对象的地址空间分配,而是在进程地址空间的其它地方分配;
  • 目前版本的GC不压缩大对象,移动他们代价较高,但可能造成地址空间碎片化,导致抛出 OutOfMemoryException;
  • 大对象总是在第2代,绝不可能在第0代或第1代。分配短时间存活的大对象会导致第2代频繁回收,损害性能;

大对象一般是大字符串(XML 或 JSON)或用于IO操作的字节数组;

关于大对象讲解的几篇博客
https://blog.csdn.net/jfkidear/article/details/18358551 大型对象堆揭秘
https://www.cnblogs.com/ygc369/p/4861610.html?utm_source=tuicool&utm_medium=referral 内存管理优化畅想
https://blog.csdn.net/cloudsuper/article/details/54924829 C#垃圾回收大对象

2.3 垃圾回收模式

CLR启动时会选择一个GC模式直到进程终止。存在两个GC模式:

  • 工作站:针对客户端应用程序优化GC,其特点有:
    • GC造成的延时低,线程挂起时间短;
    • 该模式中,GC假定机器上运行的其他应用程序都不会消耗太多的CPU资源;
  • 服务器:针对服务器端应用程序优化,其特点有:
    • 主要优化吞吐量和资源利用;
    • GC假定机器上没有运行其他应用程序,所有CPU都可以用来辅助完成GC;
    • 将托管堆拆分成几个区域Section,每个CPU管理一个;
    • GC开始时,在每个CPU上都运行一个特殊线程,所有线程并发回收自己的区域;

GC默认以工作站模式运行,服务器应用程序(ASP.NET 或 MS SQL SERVER)可请求CLR运行服务器模式;
如果服务器应用程序运行在单核的机器上,CLR将总是使用“工作站”模式运行;

GC模式的设置和查询
要设置GC运行模式,需要在应用程序的配置文件中进行,在runtime中添加一个gcServer元素:

<configuration>
    <runtime>
        <gcServer enabled="true"/>
    </runtime>
</configuration>

要查询运行中的GC模式,调用GCSettings的IsServerGC属性即可,为true为服务器模式运行:

bool isServerMode = GCSettings.IsServerGC;

子模式
除了“工作站”和“服务器”模式外,GC还支持两种子模式:并发(默认)和非并发。
并发模式主指并发标记对象,找到不可达对象集合。
在并发模式中,GC有一个额外的线程,在应用程序运行时并发标记对象。

一个线程因为分配对象造成第0代超出预算时,GC首先挂起所有线程,再判断要回收哪些代。如果要回收第0代或第1代,那么一切正常进行。但是,如果要回收第2代,就会增大第0代的大小(超过其预算),以便在第0代中分配新对象。然后,应用程序的线程恢复运行。

并发模式下,垃圾回收器运行一个普通优先级的后台线程来查找不可达对象。不可达对象集合构建好后,垃圾回收器会再次挂起所有线程,判断是否要压缩(移动)内存:

  • 压缩内存:内存会被压缩,根引用会被修正,应用程序线程恢复运行,该模式下,省去了查找不可达对象集合的时间;
  • 不压缩内存:若可用内存多,GC更倾向于不压缩内存。有利于增强性能,但会增大工作集空间;

使用并发模式的垃圾回收器,应用程序消耗的内存通常比使用非并发垃圾回收器要多。
可以在runtime节点下,添加gcConcurrent元素来告诉CLR不使用并发回收器:

<configuration>
    <runtime>
        <gcConcurrent enabled="false"/>
    </runtime>
</configuration>

GC模式是针对进程配置的,进程运行期间不能更改。但可以使用GCSettings类对GCLatencyMode属性对垃圾回收进行某种程度的控制。

Latency,潜在因素;潜伏的;延时

该属性定义如下:

枚举值名称 说明
Batch("服务器"GC模式的默认值) 关闭并发GC
Interactive("工作站"GC模式默认值) 打开并发GC
LowLatency 低延时模式,多用于短期的、时间敏感、不适合对第2代进行回收的的操作,如动画。
SustainedLowLatency 持续低延时模式。程序的大多数操作都不会发生长时间的GC暂停。

LowLatency 一般用于执行一次低延时操作,执行完毕后,再将模式设置回 Batch 或 Interactive。期间GC会全力避免回收第2代,除非调用GC.Collect或内存低等必须回收第2代的操作。该模式中,程序抛出 OutOfMemoryException 的几率较大。
注意事项:

  • 处于该模式的时间尽量短,避免分配太多对象,避免分配大对象;
  • 使用一个约束执行区域(CER)将模式设回 Batch 或 Interactive;
  • 延迟模式是进程级设置,可能存在多个线程并发修改该设置,可使用线程同步锁来操作该设置,如Interlocked更新计数器;

以下代码展示如何正确地使用LowLatency模式:

private static void LowLatencyDemo() {
    GCLatencyMode oldMode = GCSettings.LatencyMode;
    System.Runtime.CompilerServices.RuntimeHelpers.PrepareConstrainedRegions();
    try {
        GCSettings.LatencyMode = GCLatencyMode.LowLatency;
        // Do something in here
    } finally {
        GCSettings.LatencyMode = oldMode;
    }
}

2.4 强制垃圾回收

System.GC 类可对垃圾回收其进行一些直接控制:

  • GC.MaxGeneration:用来查询托管堆中支持的最大代数,该属性总是返回2;
  • GC.Collect():强制对小于或等于指定代执行垃圾回收,该方法最复杂的签名如下:
/// <summary>强制对小于或等于指定代执行垃圾回收<summary/>
/// <param>指定的代数</param>  
/// <param>回收模式</param>
/// <param>指定堵塞(非并发)或后代(并发)回收</param>
void Collect(Int32 generation, GCCollectionMode mode, Boolean blocking);

GCCollectionMode成员如下:

符号名称 说明
Default 默认模式,强制回收指定代以及低于他的所有代
Forced 效果等同于Default,CLR未来版本可能对此进行优化
Optimized 优化模式,只有在能释放大量内存或减少碎片化的前提下才进行回收

对于GUI或CUI(Console User Interface)程序,应用程序代码将拥有进程和进程中的CLR,这里应该将GCCollectionMode设置为Optimized。Default 和 Forced 一般用于调试、测试和查找内存泄漏。
最好让垃圾回收器依照自己的算法进行垃圾回收,根据程序的行为动态调整各个代的预算,避免手动调用Collect方法。

手动调用 GC.Collect 会导致代的预算发生调整,所以调用它不是为了改善应用程序的响应时间,而是为了减少进程工作集。如应用程序初始化完成或用户保存了一个数据文件之后,会导致大量的旧对象死亡,这里可以强制执行一次GC。

对于内存中存在大量对象的应用程序,一次完全GC可能耗费很长时间,如服务器应用程序。GC执行时会挂起所有线程,会影响程序的正常工作,如客户端请求超时。
GC类提供了一个RegisterForFullGCNotification方法,配合以下辅助方法:

  • WaitForFullGCApproach:和WaitForFullGCCompleted成对调用
  • WaitForFullGCCompleted:和WaitForFullGCApproach成对调用
  • CancelFullGCNotification

应用程序就会在垃圾回收器将要执行完全回收时收到通知,应用程序就可以在更恰当的时候强制回收。

2.5 监视应用程序的内存使用

可在进程中调用以下方法来监视垃圾回收器:

  • Int32 CollectionCount(Int32 generation):查看某一代发生了多少次垃圾回收
  • Int64 GetTotalMemory(Boolean forecefullCollecton):托管堆中的对象当前使用了多少内存

为了评估(profile)特定代码块的性能,可以在代码前后调用这些方法来计算差异。可以把握代码块对进程工作集的影响,并了解执行代码块时发生了多少次垃圾回收。

三、需要特殊清理的类型

大多数类型只要有内存就能正常工作,但有的类型除了内存还需要本机资源。
如:

  1. System.IO.FileStream 需要打开一个文件(本机资源)并保存文件的句柄,使用Read和Write方法用句柄操作文件;
  2. System.Threading.Mutex 类型打开一个 Windows 互斥体内核对象(本机资源)并保存其句柄,调用Mutex方法时使用该句柄;

包含本资源的类型被GC时,GC会回收对象在托管堆中的内存。但这样会造成本机资源(GC对它一无所知)的泄漏,这是致命的问题。
CLR 提供了终结(finalization)的机制,允许对象在被判定为垃圾之后,但在对象内存被回收之前执行一些代码。任何包装了本机资源(文件、网络连接、套接字、互斥体等)的类型都支持终结。CLR判定一个对象不可达时,对象将终结它自己,释放它包装的本机资源。之后,GC会从托管堆中回收对象。

System.Object 定义了受保护的虚方法 Finalize。垃圾回收器判定对象时垃圾后,会调用对象的Finalize方法(如果重写)。Microsoft的C#团队认为 Finalize 在编程语言中需要特殊的语法,类似于需要使用特殊语法定义构造函数 。因此,C#要求在类名前添加 ~ 符号来定义Finalize方法,如下所示:

internal sealed class SomeType {
    // 这是一个finalize方法
    ~SomeType() {
        // 这里的代码会进入 Finalize 方法
    }
}

编译以上代码,用ILDasm.exe检查的得到的程序集,会发现C#编译器实际是在模块的元数据中生成了名为 Finalize 的 protected override 方法。查看 Finalize 的 IL,会发现方法主体提的代码被放到一个 try 块中,在 finally 块中则放入了一个 base.Finalize 调用。

Finalize 方法会延长不可达对象,以及该对象所引用的对象的生存周期;

可终结的对象在垃圾回收的最后阶段,其Finalize方法被调用,由于Finalize方法要释放资源,可能访问对象中的字段,所以可终结对象在垃圾回收时必须存活,造成它被提升到另一代,以及字段所引用的对象也会被提升,这增大了内存消耗,所以尽可能避免终结。

Finalize 方法的执行时间是不确定的,应用程序请求更多内存时才有可能发生GC,而只有GC完成后才会运行Finalize方法,且CLR 不保证多个可终结对象的Finalize方法的调用顺序,所以在Finalize方法中不要访问其他可终结对象,因为这些对象可能已经被终结。但可以安全地访问值类型的实例或其他不可终结的对象。静态方法中也可能访问到已终结的对象,导致静态方法的行为变得无法预测。

CLR使用一个专用的更高级的线程来调用Finalize方法,但是只要该线程被堵塞,应用程序永远无法调用其它对象的Finalize方法,使得对象无法被回收,造成内存泄漏。若Finalize方法抛出异常,则进程终止,无法捕捉。

使用 Finalize 的问题较多,虽然他是为释放本机资源而设计的,但是尽量不要手动去释放他。

强烈建议不要重写Object类的Finalze方法。相反,使用Microsoft在FCL中提供的辅助类。这些辅助类重写了Finalize方法并添加了一些特殊的CLR魔法,可以从这些辅助类中派生出自己的类,从而继承CLR的魔法。

创建包装了本机资源的托管类型时,应该先从 System.Runtime.InteropServices.SafeHandle 这个特殊基类派生出一个类(SafeHandle从名称看出,安全句柄),该类的形式如下:

public abstract class SafeHandle : CriticalFinalizerObject, IDisposable {
    protected IntPtr handle;    //这是本机资源的句柄

    protected SafeHandle(IntPtr invalidHandleValue, Boolean ownsHandle) {
        this.handle = invalidHandleValue;
        // 如果 ownsHandle 为 true,那么这个从 SafeHandle 派生的对象将被回收时,本机资源会关闭
    }

    protected void SetHandle(IntPtr handle) {
        this.handle = handle;
    }
    // 可调用 Dispose 显式释放资源,实现了 IDisposable 接口
    public void Dispose() { Dispose(true); }

    // 默认的Dispose实现,强烈建议不要重写该方法
    protected virtual void Dispose(Boolean disposing) {
        // 这个默认的实现会忽略 disposing 参数;
        // 若资源已释放,那么返回;
        // 若 ownsHandle 为 false, 那么返回;
        // 设置一个标志来指明该资源已释放;
        // 调用虚方法 ReleaseHandle;
        // 调用GC.SuppressFinalize(this)方法来阻止调用 Finalize 方法;
        // 如果 ReleaseHandle 返回 true,那么返回;
        // 如果执行到这里,就激活 releaseHandleFailed 托管调试助手(MDA)
    }

    // 默认的 Finalize 实现,强烈建议不要重写这个方法。
    ~SafeHandle() { Dispose(false); }

    // 派生类需要重写这个方法以实现释放资源的代码
    protected abstract Boolean ReleaseHandle();

    public void SetHandleAsInvalid() {
        // 设置标志来指出这个资源已经释放
        // 调用GC.SuppressFinalize(this)方法来阻止调用Finalize方法
    }

    public Boolean IsClosed { get { /* 返回指出资源是否释放的一个标志 */ }}

    // 派生类要重写这个属性,如果句柄的值不代表资源(通常意味着句柄为0或-1),实现应返回true  
    public abstract Boolean IsInvalid { get;}

    // 以下方法涉及安全性和引用计数
    public void DangerousAddRef(ref Boolean success) { ... }
    public IntPtr DangerousGetHandle() { ... }
    public void DangerousRelease() { ... }
}

SafeHandle 类有两点需要注意:

  1. 派生自 CriticalFinalizerObject,其在 System.Runtime.ContrainedExecution 命名空间定义,CLR赋予这个类以下三个功能:
    • 构造 CriticalFinalizerObject 对象时,CLR会立即对继承层次中的所有Finalize方法JIT编译,防止内存紧张时,Finalize得不到编译,以至于本机资源无法正常释放;
    • CLR首先调用非 CriticalFinalizerObject 的 Finalize 方法,再调用派生类的 Finalize 方法,这样,托管资源类就可以在它们的Finalize方法中成功地访问 CriticalFinalizeObject 派生类型的对象。
    • 若 AppDomain 被一个宿主应用程序(如SqlServer 或 Asp.Net)强行中断,CLR将调用CriticalFinalizerObject派生类型的 Finalize 方法。宿主应用程序不再信任它内部运行的托管代码时,也利用这个功能确保本机资源得以释放。
  2. SafeHandle 是抽象类,必须有继承类重写受保护的构造器、抽象方法 ReleaseHandle 以及抽象属性 IsInvalid 的 get 访问器方法。

构造器不能虚或抽象,自然也不能重写。重写受保护的构造器意思是说,派生类会定义个.ctor来调用受保护的.ctor,再重写其他抽象成员。

SafeHandle 的派生类非常有用,它们能保证本机资源再垃圾回收时能够得以释放。
大多数本机资源都使用句柄(32位系统是32位值,64位系统是64位值)进行操作。所以SafeHandle类定义了受保护的 IntPtr 字段 handle。
在 Windows 中大多数值为0或-1的句柄都是无效的,所以Microsoft.Win32.SafeHandles命名空间包含继承自SafeHandle的SafeHandleZeroOrMinusOneIsInvalid抽象辅助类,其结构如下:

public abstract class SafeHandleZeroOrMinusOneIsInvalid : SafeHandle {
    protected SafeHandleZeroOrMinusOneIsInvalid(Boolean ownsHandle) 
        : base(IntPtr.Zero, ownsHandle) { }

    public override Boolean IsInvalid {
        get{
            if(base.handle == IntPtr.Zero) return true;
            if(base.handle == (IntPtr)(-1)) return true;
            return false;
        }
    }
}

要使用 SafeHandleZeroOrMinusOneIsInvalid 必须实现一个派生类,且“重写”它受保护的构造器和抽象方法 ReleaseHandle。.Net提供的派生类有:

  • SafeFileHandle;
  • SafeRegistryHandle;
  • SafeWaitHandle;
  • SafeMemoryMappedViewHandle;

其中 SafeFileHandle 类定义如下:

public sealed class SafeFileHandle : SafeHandleZeroOrMinusOneIsInvalid {
    public SafeFileHandle(IntPtr preexistingHandle, Boolean ownsHandle)
        :base(ownsHandle) {
            base.SetHandle(preexistingHandle);
    }
    protected override Boolean ReleaseHandle() {
        // 告诉 Windows 希望关闭本机资源
        return Win32Native.CloseHandle(base.handle);
    }
}

其他几种派生类的实现基本类似,其中SafeRegistryHandle类的ReleaseHandle方法调用的是 Win32 RegCloseKey 函数。
.Net之所以要提供这么多类,是要保证类型安全,禁止不同类型的句柄相互传递使用。

.Net 提供了很多额外的类型来包装本机资源,如:SafeProcessHandle, SafeThreadHandle, SafeTokenHandle, SafeLibraryHandle以及SafeLocalAllocHandle等。这些类只在定义它们的程序集内部使用,没有公开。可能是微软不想完整测试它们或不想花时间来编写文档。

SafeHandle第一个特性
与本机代码互操作时,SafeHandle派生类将获得CLR的特殊对待,如:

internal static class SomeType {
    [DllImport("Kernel32",CharSet=CharSet.Unicode, EntryPoint="CreateEvent")]
    private static extern IntPtr CreateEventBad(IntPtr pSecurityAttributes, 
                                                bool manualReset, 
                                                bool initialState, String name);

    [DllImport("Kernel32",CharSet=CharSet.Unicode, EntryPoint="CreateEvent")]
    private static extern SafeWaitHandle CreateEventGood(IntPtr pSecurityAttributes, 
                                                         bool manualReset, 
                                                         bool initialState, String name);

    public static void SomeMethod() {
        IntPtr         handle = CreateEventBad(IntPtr.Zero, false, false, null);
        SafeWaitHandle swh    = CreateEventGood(IntPtr.Zero, false, false, null);
    }
}

它们都调用了 CreateEvent 方法,该方法创建了一个本机事件资源,并将句柄返回。其中代码

IntPtr handle = CreateEventBad(IntPtr.Zero, false, false, null);

其功能如下:

  • CreateEventBad 返回一个 IntPtr;
  • 将 IntPtr 赋值给一个 handle 变量。

这种方式代码似乎没问题,但以这种方式与本机代码交互式不健壮的,在将句柄赋值给 handle 变量之前,可能会抛出一个 ThreadAbortException。虽然很少发生,但一旦发生,托管代码将造成本机资源的泄漏,只能终止进程才能关闭事件。
SafeHandle 类修正了这个潜在的资源泄漏的问题。
CreateEventGood 方法返回一个 SafeWaitHandle,当 Win32 函数 CreateEvent 返回至托管代码时,CLR知道 SafeWaitHandle 是从 SafeHandle 派生的,所以会自动在托管堆构造 SafeWaitHandle 的实例,向其传递 CreateEvent 返回的句柄值。
由于 SafeWaitHandle 对象的构造以及句柄的赋值是在本机代码中发生的,不可能被一个 ThreadAbortException 打断,所以托管代码不可能泄露这个本机资源。SafeWaitHandle 对象堆中会被垃圾回收,其Finalize方法会被调用,确保资源得以释放。

SafeHandle第二个特性
本机资源使用的一个安全漏洞:

一个线程试图使用一个本机资源,另一个线程视图释放该资源,这可能导致句柄循环使用漏洞。

SafeHandle 类防范这个安全隐患的办法是使用引用计数。SafeHandle 类内部定义了一个私有字段来维护一个计数器。一旦某个SafeHandle派生对象被设为有效句柄,计数器就被设为1。

  • 将 SafeHandle 派生对象作为实参传给一个本机方法(非托管方法),CLR就会自动递增计数器。
  • 当本机方法返回到托管代码时,CLR自动递减计数器。
  • 计数器递减为0,资源才会得以释放。

Win32 的 SetEvent 函数原型如下:

[DllImport("Kernel32", ExactSpelling=true)]
private static extern Boolean SetEvent(SafeWaitHandle swh);

调用该方法并传递一个 SafeWaitHandle 对象的引用,CLR会在调用前递增计数器,在调用后递减计数器。对计数器的操作都是以线程安全的方式进行的。
若要将句柄作为一个 IntPtr 来操作,可以通过 SafeHandle 对象的 DangerousGetHandle 方法来返回原始句柄。但手动对原始句柄的访问需要显示操作引用计数器。可通过 DangerousAddRef 和 DangerousRelease 方法来完成。

System.Runtime.InteropServices 还提供了一个 CriticalHandle 类。该类除了不提供引用计数外,其他方面和 SafeHandle 相同。CriticalHandle 类及其派生类通过牺牲安全性来换取性能(因为不使用操作计数器)。
CriticalHandle 也有提供了以下派生类:

  • CriticalHandleMinusOneIsInvalid;
  • CriticalHandleZeroOrMinusOneIsInvalid;

由于 Microsoft 倾向于建立更安全而不是更快的系统,所有类库中没有提供从这两个类派生的类型。使用时,建议权衡好安全性和性能之后来选择 SafeHandle 或者 CriticalHandle。

3.1 使用包装了本机资源的类型

System.IO.FileStream
FileStream在构造时会调用win32的 CreateFile 函数,该函数返回一个句柄保存在SafeFileHandle 类型的私有字段中。FileStream类还提供了Length,Position,CanRead等属性,Read,Write,Flush等方法。
该类型的实现利用了一个内存缓冲区,只有缓冲区满时,类型才将缓冲区中的数据刷入文件。

public void DemoMethod(){
    Byte[] bytesToWrite = new Byte[] { /* Some datas */ };
    // 创建临时文件
    FileStream fs = new FileStream("temp.dat", FileMode.Create);
    // 将字节写入
    fs.Write(bytesToWrite, 0, bytesToWrite.Length);
    // 删除临时文件
    File.Delete("temp.dat");    // 抛出 IOException 异常
}

对于 Delete 方法,绝大多数时候都会抛出IOException异常,因为此时文件没有关闭。但CLR若凑巧在 Write 和 Delete 之间执行了一次GC,那么FileStream的SafeFileHandle字段的Finalize方法会被调用,会释放FileStream对象占用的本机资源,关闭文件,使Delete操作正常执行。

3.1.1 Dispose说明

使用规范:若类的某个字段是实现了Dispose模式的类型,那么该类本身也应该实现Dispose模式,并在Dispose方法中调用dispose字段的Dispose方法,来彻底释放自身占用的资源;

实现了Dispose模式指实现了IDisposable接口;

通常所说“dispose一个对象”指的是:清理或处置对象以及它所引用的对象中包装的资源,然后等待一次垃圾回收之后回收该对象占用的托管堆内存(此时才释放);
对于Dispose需要注意以下:

  • 并非一定要调用Dispose才能保证本机资源得以清理。本机资源的清理总会发生,调用Dispose方法只是控制这个清理动作的发生时间。
  • Dispose方法不会将托管对象从托管堆删除,只有在垃圾回收之后,托管堆的内存才会得以回收。
    FileStream实现了IDisposable接口,在实现方法中,在SafeFileHandle字段上调用了Dispose方法。在Write方法之后Delete方法之前,调用Dispose释放掉本机资源,则文件可以正常删除。

3.1.2 Dispose的使用

一般不应该在代码中显示调用Dispose(确定需要清理资源时除外,如关闭打开的文件),GC知道一个对象何时不再被访问,且只有到那个时候才会回收对象。而程序员很多时候并不清楚,如A将一个对象的引用传给B,B将该对象的引用保存到自己的根中,而A并不知道对象已经被B保存。此时A并不能明确能否调用该对象的Dispose,若关闭对象后,该对象的资源再被其它代码访问,则会造成抛出 ObjectDisposedException 。

Dispose()方法不是线程安全,也不应该线程安全,代码只有在确定没有别的线程使用对象时,才应调用Dispose。

对于Dispose的调用推荐使用以下写法:

try{
    fs.Write(bytesToWrite, 0, bytesToWrite.Length);
} finally {
    if(fs != null) fs.Dispose();
}

该写法等价于使用using关键字

using (FileStream fs = new FileStream("temp.dat", FileMode.Create)) {
    fs.Write(bytesToWrite, 0, bytesToWrite.Length);
}

using 语句只能用于那些实现了 IDisposable 接口的类型中。

3.2 一个依赖性问题

若没有代码显示调用Dispose方法,则GC会在某个时刻检测到对象时垃圾,并对它进行终结。但GC不保证对象的终结顺序。

FileStream fs = new FileStream("DataFile.dat", FileMode.Create);
StreamWriter sw = new StreamWriter(fs);
sw.Write("Hello World");
sw.Dispose();

StreamWriter对象在写入时,它会将数据缓存在自己的内存缓冲区中。缓冲区满时,StreamWriter对象会将数据写入Stream对象中。
StreamWriter调用Dispose方法,会调用FileStreamDispose方法来关闭FileStream。StreamWriter终结后,会将数据Flush到FileStream中。Dispose工作交给GC来做,GC不能保证对象的终结顺序,若先终结了FileStreamStreamWriter就会试图向已关闭的文件中写入数据,造成异常。

Microsoft对这个依赖问题的解决方案是:

StreamWriter 类型不支持终结,所有永远不会将它的缓冲区中的数据flush到FileStream对象。这意味着若忘记在StreamWriter对象上显式调用Dispose,则数据肯定会丢失。Microsoft希望开发人员注意到这个数据丢失问题,并插入对Dispose的调用来修正代码。

3.3 GC为本机资源提供的其他功能

3.3.1 报告内存压力

本机资源有时会消耗大量内存,但用于包装它的托管对象只占用很少的内存,如位图。
一个位图可能占用几兆字节的本机内存,但托管对象只包含一个 HBITMAP(4字节或8字节)。
对CLR来说,在执行下一次垃圾回收之前可能分配数百个位图(极低内存),但当进程操作他们的时候,内存消耗将猛增。
为了修正这个问题,GC类提供了两个静态方法:

public static void AddMemoryPressure(Int64 bytesAllocated);
public static void RemoveMemoryPressure(Int64 bytesAllocated);

可使用这些方法向垃圾回收器报告包装很大的本机资源实际要消耗的内存。垃圾回收器内部就会监视内存压力,适时进行回收。

static void Main(string[] args) {
    MemoryPressureDemo(0);                  // 0导致不频繁的GC
    MemoryPressureDemo(10 * 1024 * 1024);   // 10MB 导致频繁的GC
}

private static void MemoryPressureDemo(Int32 size)
{
    Console.WriteLine("\r\nMemoryPressureDemo, Size={0}", size);
    // 创建一组对象,并制定它们的逻辑大小
    for (int count = 0; count < 10; count++) {
        new BigNativeResource(size);
    }
    Console.WriteLine("Begin GC................");
    GC.Collect();   // 出于演示目的,强制执行GC
}
// 占用指定内存的本地资源
private sealed class BigNativeResource {
    private Int32 m_size;
    public BigNativeResource(Int32 size) {
        m_size = size;
        // 使垃圾回收期认为对象在物理上比较大
        if (m_size > 0) GC.AddMemoryPressure(m_size);
        Console.WriteLine("BigNativeResource create.({0})", m_size);
    }

    ~BigNativeResource() {
        //使垃圾回收期认为对象释放了更多的内存
        if (m_size > 0) GC.RemoveMemoryPressure(m_size);
        Console.WriteLine("BigNativeResource destroy.({0})",m_size);
    }
}

其可能的一次执行结果如下:

MemoryPressureDemo, Size=0
BigNativeResource create.(0)
BigNativeResource create.(0)
BigNativeResource create.(0)
BigNativeResource create.(0)
BigNativeResource create.(0)
BigNativeResource create.(0)
BigNativeResource create.(0)
BigNativeResource create.(0)
BigNativeResource create.(0)
BigNativeResource create.(0)
Begin GC................

MemoryPressureDemo, Size=10485760
BigNativeResource create.(10485760)
BigNativeResource create.(10485760)
BigNativeResource create.(10485760)
BigNativeResource create.(10485760)
BigNativeResource create.(10485760)
BigNativeResource create.(10485760)
BigNativeResource create.(10485760)
BigNativeResource create.(10485760)
BigNativeResource destroy.(0)
BigNativeResource destroy.(0)
BigNativeResource destroy.(0)
BigNativeResource destroy.(0)
BigNativeResource destroy.(0)
BigNativeResource destroy.(0)
BigNativeResource destroy.(0)
BigNativeResource destroy.(0)
BigNativeResource create.(10485760)
BigNativeResource create.(10485760)
Begin GC................
BigNativeResource destroy.(0)
BigNativeResource destroy.(10485760)
BigNativeResource destroy.(0)
BigNativeResource destroy.(10485760)
BigNativeResource destroy.(10485760)
BigNativeResource destroy.(10485760)
BigNativeResource destroy.(10485760)
BigNativeResource destroy.(10485760)
BigNativeResource destroy.(10485760)
BigNativeResource destroy.(10485760)
BigNativeResource destroy.(10485760)
BigNativeResource destroy.(10485760)

3.3.2 限制允许资源个数

有的本机资源的数量是固定的且数量有限,一旦进程试图使用超过允许数量的资源,通常会导致抛出异常。如以前Windows就限制只能创建5个设备上下文,应用程序能打开的文件数量也必须有限制。
.Net使用了 System.Runtime.InteropServices.HandleCollector 类来解决这个问题:

public sealed class HandleCollector {
    public HandleCollector(String name, Int32 initialThreshold);
    public HandleCollector(String name, Int32 initialThreshold, Int32 maximumThreshold);
    public void Add();
    public void Remove();

    public Int32 Count { get; }
    public Int32 InitialThreshold { get; }
    public Int32 MaximumThreshold { get; }
    public String Name { get; }
}

如果要包装超过 HandleCollector 限制的本机资源,就会被强制执行垃圾回收。其演示代码如下:

static void Main(string[] args) {
    HandleCollectorDemo();
}

private static void HandleCollectorDemo() {
    Console.WriteLine("\r\nHandleCollectorDemo");
    for (int count = 0; count < 10; count++) {
        new LimitedResource();
    }
    GC.Collect();   // 出于演示,强制一切都被清理
}

private sealed class LimitedResource {
    // 创建一个HandleCollector,告诉它当两个或更多这样的对象存在于堆中的时候,就执行回收
    private static readonly HandleCollector s_hc = new HandleCollector("LimitedResource", 2);

    public LimitedResource() {
        s_hc.Add(); // 告诉HandleCollector堆中增加了一个LimitedResource对象
        Console.WriteLine("LimitedResource create.Count={0}", s_hc.Count);
    }

    ~LimitedResource() {
        s_hc.Remove();  // 告诉HandleCollector堆中移除了一个LimitedResource对象
        Console.WriteLine("LimitedResource destroy.Count={0}", s_hc.Count);
    }
}

其可能的一次执行结果如下:
HandleCollectorDemo
LimitedResource create.Count=1
LimitedResource create.Count=2
LimitedResource create.Count=3
LimitedResource destroy.Count=3
LimitedResource destroy.Count=2
LimitedResource destroy.Count=1
LimitedResource create.Count=1
LimitedResource create.Count=2
LimitedResource destroy.Count=2
LimitedResource create.Count=2
LimitedResource create.Count=3
LimitedResource destroy.Count=3
LimitedResource destroy.Count=2
LimitedResource destroy.Count=1
LimitedResource create.Count=1
LimitedResource create.Count=2
LimitedResource destroy.Count=2
LimitedResource create.Count=2
LimitedResource destroy.Count=1
LimitedResource destroy.Count=0

注意,在内部,GC.AddMemoryPressure 和 HandleCollector.Add 方法都会调用 GC.Collect,在第0代超出预算前强制进行GC。这无疑会对性能造成负面影响。但是,性能受损总好过于本地资源用光,程序无法运行。

3.4 终结的内部工作原理

终结表面上很简单,创建对象,当它被回收时,它的Finalize方法得以调用。
首先介绍几个结构:

  • 终结列表:由GC控制的一个内部数据结构,列表中的每一项都指向一个回收内存前需要调用Finalize方法的对象;
  • freachable队列:GC的一种内部数据结构,队列中的每个记录项都引用着托管堆中一个已经准备好调用其Finalize方法的对象;

可终结的对象:从System.Object中继承了Finalize方法,且将其重写了,则认为该对象是“可终结的”,未重写的Finalize方法会被CLR忽略,即便是从System.Object对象继承。

终结的内部过程:

  1. 创建新对象:调用new操作符,从堆中分配内存。若该对象是可终结的,则该类型的实例构造器被调用之前,CLR会将该对象的指针放到终结列表中。
  2. 开始垃圾回收:
    1. 未在终结列表,即非可终结的对象,被确定为垃圾后直接回收;
    2. 在终结列表中的垃圾对象,GC将其从终结列表移除,添加到freachable队列中,此时可终结的垃圾对象依然在堆中(且依照GC算法,可能在当前代,也可能移动到下一代中),因为Finalize方法还没有调用,内存不能回收;

freachable队列增加了对可终结对象的引用,使对象从不可达变得重新可达,使得可终结对象被GC判定为垃圾后又变得不再是垃圾,即“复活”了

  1. 调用Finalize方法:一个专门线程将每一项都从freachable队列中移除,同时调用每个对象的Finalize方法。由于该线程的特殊性,Finalize中的代码不应该对专用线程做出任何假设,如不要在Finalize方法中访问线程的本地存储。

CLR专用线程:特殊的高优先级的专门调用Finalize方法的线程。它可以避免潜在的线程同步问题。freachable队列为空时,该线程睡眠,freachable存在记录时则线程会被唤醒。
目前只有一个专用线程,可能调用代码速度赶不上多核CPU分配可终结对象的速度,从而产生性能和伸缩性方面的问题。CLR未来可能使用多个终结器线程。

终结器的弊端:

  1. freachable对象会对不可达的对象重新标记使其重新可达,标记对象中的引用类型字段所引用的对象也必须复活;
  2. 可终结对象至少要执行两次垃圾回收才能释放内存,若对象被提升到另一代,可能远不止两次。

3.5 手动监视和控制对象的生存期

CLR 为每个 AppDomain 都提供了一个 GC句柄表(GC Handle Table),允许应用程序监视或手动控制对象的生存期。该表在 AppDomain 创建之初是空白的。
表中每个记录项包含两种信息:

  • 对托管堆中的一个对象的引用。
  • 指明如何监视或控制对象。

可使用 System.Runtime.InteropServices.GCHandle 结构体在表中操作记录项:

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);
    // 用于比较两个GCHandle
    public static Boolean operator ==(GCHandle a, GCHandle b);
    public static Boolean operator !=(GCHandle a, GCHandle b);

    public void Free();                 // 用于释放表中的记录项(索引设为0)
    public Object Target { get;set; }   // 用于引用记录项中的对象
    public Boolean IsAllocated { get; } // 若索引不为0就返回true
    public IntPtr AddrOfPinnedObject(); // 对于已固定(pinned)的记录项,返回对象的地址
}

为了监视或控制对象的生命周期,可调用 GCHandle 的静态 Alloc 方法并传递目标对象的引用。还可以传入 GCHandleType 指定向如何监视或控制对象,GCHandleType 枚举类型定义如下:

public enum GCHandleType {
    // 0、1 允许监视对象的生存期,它们都不可达。可检测出垃圾回收器判定该对象不可达的时间
    Weak = 0,                   // 此时对象还在内存中,Finalize方法不确定是否执行。
    WeakTrackResurrection = 1,  // (弱跟踪复活)对象的内存已回收,若存在Finalize方法,则已执行
    // 2、3 允许控制对象的生存期,告诉垃圾回收器,即时该对象没有被变量(根)引用,也必须留在内存中
    Normal = 2,                 // 垃圾回收发生时,该对象的内存可以压缩(移动);
    Pinned = 3                  // 垃圾回收发生时,该对象的内存不可以压缩(移动)。
}

对于 Pinned 值,当需要将内存地址交给本机代码时,这个功能很好用。本机代码知道GC不会移动对象,所以能放心地向托管堆的这个内存写入。

GCHandle 的 Alloc 方法做了以下几件事:

  • 扫描 AppDomain 的 GC 句柄表,查找一个可用的记录项来存储 Alloc 方法中传入对象的引用;
  • 将句柄表中记录项标志设置为 GCHandleType 实参传递的值。
  • 返回一个 GCHandle 实例。

GCHandle 是轻量级的值类型,其中包含一个实例字段(一个IntPtr字段),它引用了句柄表中的记录项的索引。要释放 GC 句柄表中的这个记录时可以获取 GCHandle 实例,并在这个实例上调用 Free 方法。Free 方法将 IntPtr 字段设置为0,使实例变得无效。

当垃圾回收发生时,垃圾回收器对 GC 句柄表的操作如下:

  1. 垃圾回收器标记所有可达对象;
  2. 垃圾回收器扫描句柄表:
    1. 所有 Normal 和 Pinned 对象都被看成是根,并标记这些对象以及它们所引用的对象;
    2. 查找所有 Weak 记录项。
      • 若 Weak 记录项引用了未标记的对象,则该对象就是垃圾。将记录项的引用值更改为null。
  3. 垃圾回收器扫描终结列表;
    • 将不可达对象从终结列表移至 freachable 队列,这是对象会被标记,重新“复活”变成可达;
  4. 垃圾回收器扫描GC句柄表,查找所有 WeakTrackResurrection 记录项。
    • 若 WeakTrackResurrection 记录项引用了未标记的对象,则该对象就是垃圾。将记录项的引用值更改为null。
  5. 垃圾回收器对内存进行压缩。Pinned 对象不会被压缩。

标记即为,垃圾回收器将对象的同步块索引中的标记位设置为1.

Normal 和 Pinned标记
Normal 和 Pinned 通常在和本地代码互操作时使用。
需要将托管代码的指针移交给本机代码时使用 Normal 标记,因为本机代码将来要回调托管代码并传递指针。但不能直接将托管对象的指针交给本机代码,因为如果垃圾回收发生,对象在内存中移动,指针便无效了。
解决方案如下:

  • 调用 GCHandle 的 Alloc 方法,传递对象引用和 Normal 标志。将返回的 GCHandle 实例转型为 IntPtr,再将 IntPtr 传给本机代码。
  • 本机代码回调托管代码时,托管代码将传入的 IntPtr 转型成 GCHandle,查询 Target 属性获得托管对象的引用(当前地址)。
  • 本机代码不再需要这个引用之后,可以调用 GCHandle 的 Free 方法,使垃圾回收器能够释放对象。

这种情况下,本机代码并没有真正使用托管对象本身,它只是通过一种方式引用了对象。
但有时候本机代码需要真正地使用托管对象本身,这时托管对象就必须要固定(Pinned)住,从而阻止垃圾回收器压缩对象。

最常见的例子就是将托管的 String 对象传给某个 Win32 函数。这时 String 对象必须固定。不能将托管对象的引用传给本机代码,若垃圾回收器在内存中移动了对象,本机代码就会向已经不包含 String 对象的内存进行读写,导致应用程序的行为无法预测。

使用 CLR 的 P/Invoke 机制调用方法时,CLR 会自动帮你固定实参,并在本机方法返回时自动解除固定。

大多数时候都不需要使用 GCHandle 来显示固定任何托管对象,只有在将托管对象指针传给本机代码,然后本机函数返回,但本机函数将来仍需要使用该对象时,才需要使用 GCHandle 类型。最常见的例子就是执行异步 I/O 操作。

P/Invoke 的全称是 Platform Invoke(平台调用),实际上是一种函数调用机制,通过 P/Invoke 我们可以调用非托管的 DLL 中的函数。
P/Invoke 依次执行以下操作:

  1. 查找包含该函数的非托管 DLL;
  2. 将该非托管 DLL 加载到内存中;
  3. 查找函数在内存中的地址并将其参数按照函数的调用约定压栈;
  4. 将控制权转移到非托管函数;

GCHandle 实际使用示例:
假定分配了一个字节数组,并准备在其中填充来自一个Socket的数据,应该如下操作:

  1. 调用GCHandle的Allc方法,传递数组对象的引用以及Pinned标志;
  2. 在返回的 GCHandle 上调用 AddrOfPinnedObject 方法,返回已固定的对象在托管堆中的地址 IntPtr;
  3. 将该地址传递给本机函数,该函数立即返回至托管代码;
  4. 数据从 Socket 传来时,由于设置了 Pinned,字节数组缓冲区在内存中不会移动;
  5. 异步I/O操作完成后调用 GCHandle 的 Free 方法,之后垃圾回收器就可以移动缓冲区了;

托管代码应包含一个缓冲区的引用来访问数据,正式由于这个引用的存在,所以才会阻止垃圾回收从内存中彻底释放该缓冲区。

C# 提供了一个 fixed 语句,能够在代码块中固定对象,使用示例如下:

unsafe public static void Go() {
    // 分配一系列立即编程垃圾的对象
    for (Int32 x = 0; x < 1000; x++) new Object();

    IntPtr originalMemoryAddress;
    Byte[] bytes = new Byte[1000];  // 在垃圾对象后分配这个数组

    // 获取 Byte[] 在内存中的地址
    fixed (Byte* pbytes = bytes) { originalMemoryAddress = (IntPtr) pbytes; }

    // 强迫进行一次垃圾回收:垃圾对象会被回收,Byte[] 可能被压缩
    GC.Collect();
    // 获取 Byte[] 当前在内存中的地址,把它同第一个地址比较
    fixed(Byte* pbytes = bytes) {
        Console.WriteLine("The Byte[] did{0} move during the GC", 
                    (originalMemoryAddress == (IntPtr)pbytes) ? "not" : null);
    }
}

使用 fixed 语句比分配一个固定 GC 句柄高效的多。
C# 编译器在 pbytes 局部变量上生成一个特殊的“已固定”标志。垃圾回收期间,GC 检查这个根的内容,如果根不为 null,就知道在压缩期间不要移动变量引用的对象。C#编译器生成IL将 pbytes 局部变量初始化为 fixed 块起始处的对象的地址。在 fixed 块的尾部,编译器还会生成 IL 指令将 pbytes 局部变量设回 null,使变量不引用任何对象。这样一来,下一次垃圾回收发生时,对象就可以移动了。

Weak 和 WeakTrackResurrection 标记
它们既可以用于和本机代码的互操作,也可以只在托管代码的时候使用。

  • Weak:可以知道什么时候一个对象被判定为垃圾;
  • WeakTrackResurrection:可以知道什么时候对象的内存已经被回收(极少使用)。

Weak 可以理解成传统概念上的“弱引用”,其一般使用场景如下:
假定 Object-A 定时在 Object-B 上调用一个方法。但由于 Object-A 持有一个 对 Object-B 的引用,所以 Object-B 不会被垃圾回收。
在极少数情况下,可能有这样的需求:
只要 Object-B 仍存活在托管堆中, Object-A 就能调用 Object-B 中的方法,就需要这样做:

  1. Object-A 要调用 GCHandle 的 Alloc 方法,向方法中传递 Object-B 和 Weak 标志;
  2. Object-A 需要持有 GCHandle 实例,而不是 Object-B 的引用;
    • Object-A 未持有 Object-B 的引用,若 Object-B 没有其他根引用,就可以被回收。
  3. Object-A 想要调用 Object-B 的方法,需要查询 GCHandle 的 Target 只读属性:
    1. 该属性返回 null:Object-B 已被回收,Object-A 要调用 GCHandle 的 Free 方法来释放他。
    2. 该属性不为 null:Object-B 仍存活,将 Target 转换为 Object-B 类型并调用方法。

使用 GCHandle 可以使一个对象“间接引用”另一个对象,但有些繁琐,且要求提升的安全性才能在内存中保持或固定对象。所以,System.WeakReference<T> 类对 GCHandle 使用了面向对象的包装器进行了封装,其基本结构如下:

public sealed class WeakReference<T> : ISerializable where T : class {
    public WeakReference(T target);
    public WeakReference(T target, Boolean trackResurrection);
    public void SetTarget(T target);
    public Boolean TryGetTarget(out T target);
}

该类分析如下:

  • 构造器:调用了 GCHandle 的 Alloc 方法;
  • SetTarget:设置 GCHandle 的 Target 属性;
  • TryGetTarget:查询 GCHandle 的 Target 属性;
  • Finalize:以上未列出,调用了 GCHandle 的 Free 方法。

该类只支持弱引用,不支持 GCHandleType 值为 Normal 或 Pinned 的 GCHandle 实例的行为。WeakReference<T> 缺点在于它的实例必须在堆上分配,所以 WeakReference 类比 GCHandle 实例更“重”;

弱引用在缓存情形中能得到一定的应用。可以若引用一些缓存对象来提升性能。但若对象被垃圾回收掉,再次需要这些对象时需要重新创建,程序的性能反而会收到坏影响。这就需要构建良好的缓存算法来找到内存消耗和速度之间的平衡点。
简单来说,希望缓存保持对自己对象的强引用,一旦内存紧张就开始将强引用转换成弱引用。但目前 CLR 没有提供内存紧张的通知机制。但可以通过定时调用 Win32 GlobalMemoryStatusEx 函数并检查返回的 MEMORYSTATUSEX 结构 dwMemoryLoad 成员值,若该值大于80,内存空间就处于吃紧状态。然后就可以将强引用转换成若引用————可依据的算法包括:

  • 最近最少使用算法(Least-Recently Used algorithm, LRU);
  • 最频繁使用算法(Most-Frequently Used algorithm, MFU);
  • 某个时基算法(Time-Base algorithm);

ConditionalWeakTable<TKey, TValue>
开发人员常需要将一些数据和另一个实体关联,如,数据可以和一个线程或 AppDomain 关联。可用 System.Runtime.CompilerServices.ConditionalWeakTable<Tkey, TValue&gt 类将数据和单独对象关联。
该类使用方式与通常 Dictionary 字典类似,其结构如下:

    public sealed class ConditionalWeakTable<TKey, TValue> where TKey : class 
                                                           where TValue : class
    {
        public ConditionalWeakTable();
        ~ConditionalWeakTable();
        public void Add(TKey key, TValue value);
        public TValue GetOrCreateValue(TKey key);
        public TValue GetValue(TKey key, CreateValueCallback createValueCallback);
        public bool Remove(TKey key);
        public bool TryGetValue(TKey key, out TValue value);

        public delegate TValue CreateValueCallback(TKey key);
    }

该类几点说明如下:

  • 该类是线程安全的,也意味着它的性能并不出众,使用时要确定他的性能是否适合实际生产环境;
  • 任意数据要和一个或多个对象关联,首先要创建该类的实例,调用 Add 方法为 Key 参数传递对象引用,为 value 参数传递想和对象关联的数据。
  • 试图多次添加对同一个对象的引用,Add方法会抛出 ArgumentException 异常;
  • 要修改和对象关联的值,必须先删除 key,再用新值把它添加回来。

ConditionalWeakTable 对象在内存存储了对作为 Key 的对象的弱引用。且还保证,只要 key 所标识的对象在内存中,那么对应的 value 肯定在内存中。这点是 ConditionalWeakTable 的核心功能;
ConditionalWeakTable 类可用于实现 XAML 的依赖属性机制。动态语言也可以在内部利用它将数据和对象动态关联;

以下代码延时了 ConditionalWeakTable 类的使用。它允许在任何对象上调用 GCWatch 扩展方法并传递一些 String 标签(在程序中作为通知消息显示)。在特定对象被垃圾回收时,通过控制台发出通知:

internal static class GCWatcher
{
    private readonly static ConditionalWeakTable<Object, NotifyWhenGCd<String>> s_cwt = new ConditionalWeakTable<object, NotifyWhenGCd<string>>();

    private sealed class NotifyWhenGCd<T>
    {
        private readonly T m_value;
        internal NotifyWhenGCd(T value) { m_value = value; }
        ~NotifyWhenGCd() { Console.WriteLine("GC'd: " + m_value); }
        public override string ToString() { return m_value.ToString(); }
    }

    public static T GCWatch<T>(this T @object, String tag) where T : class
    {
        s_cwt.Add(@object, new NotifyWhenGCd<string>(tag));
        return @object;
    }
}

使用示例如下:

static void Main(string[] args)
{
    Object o = null;
    new Object().GCWatch("My Object created at " + DateTime.Now);
    GC.Collect();       // 此时看不到 GC 通知
    GC.KeepAlive(o);    // 确定 o 引用的对象保持存活
    o = null;
    GC.Collect();       // 此时会看到GC通知
}

控制台打印结果如下:

GC'd: My Object created at 2016/6/20 14:39:00
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335