类型语法、内存管理和垃圾回收基础

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;
        }
    }

复制代码

  真正做释放工作的只是受保护的虚方法Dispose,它接收一个bool参数,主要用于区分调用者是类型的使用者还是.NET的GC机制。两者的区别在于通过Finalize方法释放资源时不能再释放或使用对象中的托管资源,这是因为这时的对象已经处于不被使用的状态,很有可能其中的托管资源已经被释放掉了。在Dispose方法中GC.SuppressFinalize(this)告诉GC此对象在被回收时不需要调用Finalize方法,这一句是改善性能的关键,记住实现Dispose方法的本质目的就在于避免所有释放工作在Finalize方法中进行

2.5 GC中代(Generation)是什么,分为几代?

  在.NET的GC执行垃圾回收时,并不是每次都扫描托管堆内的所有对象实例,这样做太耗费时间而且没有必要。相反,GC会把所有托管堆内的对象按照其已经不再被使用的可能性分为三类,并且从最有可能不被使用的类别开始扫描,.NET对这样的分类类别有一个称呼:代(Generation)。

  GC会把所有的托管堆内的对象分为0代、1代和2代:

  第0代,新近分配在堆上的对象,从来没有被垃圾收集过。任何一个新对象,当它第一次被分配在托管堆上时,就是第0代。  

  第1代,经历过一次垃圾回收后,依然保留在堆上的对象。  

  第2代,经历过两次或以上垃圾回收后,依然保留在堆上的对象。如果第2代对象在进行完垃圾回收后空间仍然不够用,则会抛出OutOfMemoryException异常

  对于这三代,我们需要知道的是并不是每次垃圾回收都会同时回收3个代的所有对象,越小的代拥有着越多被释放的机会

  CLR对于代的基本算法是:每执行N次0代的回收,才会执行一次1代的回收,而每执行N次1代的回收,才会执行一次2代的回收。当某个对象实例在GC执行时被发现仍然在被使用,它将被移动到下一个代中上,下图简单展示了GC对三个代的回收操作。

  根据.NET的垃圾回收机制,0代、1代和2代的初始分配空间分别为256KB、2M和10M。说完分代的垃圾回收设计,也许我们会有疑问,为什么要这样弄?其实分代并不是空穴来风的设计,而是参考了这样一个事实:

一个对象实例存活的时间越长,那么它就具有更大的机率去存活更长的时间。换句话说,最有可能马上就不被使用的对象实例,往往是那些刚刚被分配的对象实例,而且新分配的对象实例通常都会被马上大量地使用。这也解释了为什么0代对象拥有最多被释放的机会,并且.NET也只为0代分配了一块只有256KB的小块逻辑内存,以使得0代对象有机会被全部放入处理器的缓存中去,这样做的结果就是使用频率最高并且最有可能马上可以被释放的对象实例拥有了最高的使用效率和最快的释放速度。

  因为一次GC回收之后仍然被使用的对象会被移动到更高的代上,因此我们需要避免保留已经不再被使用的对象引用将对象的引用置为null是告诉.NET该对象不需要再使用的最直接的方法。

  在前面我们提到Finalize方法会大幅影响性能,通过结合对代的理解,我们可以知道:在带有Finalize方法的对象被回收时,该对象会被视为正在被使用从而被留在托管堆中,且至少要等一个GC循环才能被释放(为什么是至少一个?因为这取决于执行Finalize方法的线程的执行速度)。很明显,需要执行Finalize方法的那些对象实例,被真正释放时最乐观的情况下也已经位于1代的位置上了,而如果它们是在1代上才开始释放或者执行Finalize方法的线程运行得慢了一点,那该对象就在第2代上才被释放,相对于0代,这样的对象实例在堆中存留的时间将长很多。

2.6 GC机制中如何判断一个对象仍然在被使用?

  在.NET中引用类型对象实例通常通过引用来访问,而GC判断堆中的对象是否仍然在被使用的依据也是引用。简单地说:当没有任何引用指向堆中的某个对象实例时,这个对象就被视为不再使用

  在GC执行垃圾回收时,会把引用分为以下两类:

  (1)根引用:往往指那些静态字段的引用,或者存活的局部变量的引用;

  (2)非根引用:指那些不属于根引用的引用,往往是对象实例中的字段。

  垃圾回收时,GC从所有仍在被使用的根引用出发遍历所有的对象实例,那些不能被遍历到的对象将被视为不再被使用而进行回收。我们可以通过下面的一段代码来直观地理解根引用和非根引用:

复制代码

    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.ReadKey();
            Console.WriteLine(a);
        }
    }

    public class Employee
    {
        public Employee boss;

        public override string ToString()
        {
            if(boss == null)
            {
                return "No boss";
            }

            return "One boss";
        }
    }

复制代码

  上述代码中一共有两个局部变量和一个静态变量,这些引用都是根引用。而其中一个局部变量 a 拥有一个成员实例对象,这个引用就是一个非跟引用。下图展示了代码执行到Console.ReadKey()这行代码时运行垃圾回收时的情况。

  从上图中可以看出,在执行到Console.ReadKey()时,存活的根引用有staticEmployee和a,前者因为它是一个公共静态变量,而后者则因为后续代码还会使用到a。通过这两个存活的根引用,GC会找到一个非跟引用staticEmployee.boss,并且发现三个仍然存活的对象。而b的对象则将被视为不再使用从而被释放。(更简单地确保b对象不再被视为在被使用的方法时把b的引用置为null,即b=null;)

  此外,当一个从根引用触发的遍历抵达一个已经被视为在使用的对象时,将结束这一个分支的遍历,这样做可以避免陷入死循环。

2.7 .NET中的托管堆中是否可能出现内存泄露的现象?

  首先,必须明确一点:即使在拥有垃圾回收机制的.NET托管堆上,仍然是有可能发生内存泄露现象的

  其次,什么是内存泄露?内存泄露是指内存空间上产生了不再被实际使用却又不能被分配的内存空间,其意义很广泛,像内存碎片、不彻底的对象释放等都属于内存泄露现象。内存泄露将导致主机的内存随着程序的运行而逐渐减少,无论其表现形式怎样,它的危害是很大的,因此我们需要努力地避免。

  按照内存泄露的定义,我们可以知道在大部分的时候.NET中的托管堆中存在着短暂的内存泄露情况,因为对象一旦不再被使用,需要等到下一个GC时才会被释放。这里列举几个在.NET中常见的几种对系统危害较大的内存泄露情况,我们在实际开发中需要极力避免:

  (1)大对象的分配

  .NET中所有的大对象(这里主要是指对象的大小超过指定数值[85000字节])将分配在托管堆内一个特殊的区域内,暂且将其称为“大对象堆”(这也算是CLR对于GC的一个优化策略)。大对象堆中最重要的一个特点就是:没有代级的概念,所有对象都被视为第2代回收大对象堆内的对象时,其他的大对象不会被移动,这是考虑到大规模地移动对象需要耗费过多的资源。这样,在程序过多地分配和释放大对象之后,就会产生很多内存碎片。下图解释了这一过程:

  如图所示可以看出,随着对象的分配和释放不断进行,在不进行对象移动的大对象堆内,将不可避免地产生小的内存碎片。我们所需要做的就是尽量减少大对象的分配次数,尤其是那些作为局部变量的,将被大规模分配和释放的大对象,典型的例子就是String类型。

  (2)不恰当地保存根引用

  最简单的一个错误例子就是不恰当地把一个对象申明为公共静态变量,一个公共的静态变量将一直被GC视为一个在使用的根引用。更糟糕的是:当这个对象内部还包含更多的对象引用时,这些对象同样不会被释放。例如下面一段代码:

复制代码

    public class Program
    {
        // 公共静态大对象
        public static RefRoot bigObject = new RefRoot("test");

        public static void Main(string[] args)
        {
            
            Console.ReadKey();
        }
    }

    public class RefRoot
    {
        // 这是一个占用大量内存的成员
        public string[] BigMember;

        public RefRoot(string content)
        {
            // 初始化大对象
            BigMember = new string[1000];
            for (int i = 0; i < 1000; i++)
            {
                BigMember[i] = content;
            }
        }
    }

复制代码

  在代码中,定义了一个公共静态的大对象,这个对象将直到程序运行结束后才会被GC释放掉。如果在整个程序中各个类型不断地使用这个静态成员,那这样的设计有助于减少大对象堆内的内存碎片,但是如果整个程序极少地甚至只有一次使用了这个成员,那考虑到它占用的内存会影响整体系统性能,设计时则应该考虑设计成实例变量,以便GC能够及时释放它。

  (3)不正确的Finalize方法

  前面已经介绍了Finalize方法时由GC的一个专用的线程进行调用,抛开Microsoft怎样实现的这个具体的调度算法,有一点可以肯定的是:不正确的Finalize方法将导致Finalize方法不能被正确执行。如果系统中所有的Finalize方法不能被正确执行,包含它们的对象也只能驻留在托管堆内不能被释放,这样的情况将会导致严重的后果。

  那么,什么是不正确的Finalize方法?Finalize方法应该只致力于快速而简单地释放非托管资源,并且尽可能快地返回。相反,不正确的Finalize方法则可能包含以下这样的一些代码:

  ①没有保护地写文件日志;

  ②访问数据库;

  ③访问网络;

  ④把当前对象赋给某个存活的引用;

  例如,当Finalize方法试图访问文件系统、数据库或者网络时,将会有资源争用和等待的潜在危险。试想一个不断尝试访问离线数据库的Finalize方法,将会在长时间内不会返回,这不仅影响了对象的释放,也使得排在Finalize方法队列中的所有后续对象得不到释放,这个连锁反应将会导致很快地造成内存耗尽。此外,如果在Finalize方法中把对象自身又赋给了另外一个存活的引用,这时对象内的一部分资源已经被释放掉了,而另外一部分还没有,当这样一个对象被激活后,将导致不可预知的后果。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值