技术探究 – 垃圾回收机制 via C#

在探讨这个技术之前,我们先看一段代码:

1 . 这段代码就是窗体加载时利用File静态类实现创建一个txt文件

2 . 随后点击窗体上的button创建一个针对以上创建txt文件的文件流对象

我们来运行看看,首先弹出Demo窗体,这是可以看到桌面上新建了一个text.txt的文本文件,随后我们点击窗体上的button1的按钮之后

这时有经验的小伙伴可能要说了,File打开了没有释放资源,要先释放资源。

如果我在原先代码中增加如下代码之后呢?

运行之后发现,Demo可以正常运行,并没有报任何异常了。

接下来,我们不增加任何代码,只是更改下Test()在代码中的位置


运行之后,报了同样的异常:

我的天啊!这是咋回事?

接下来就引出今天的技术主题,希望通过本章的探究之后,小伙伴们可以明白这是怎么回事儿……

一. 何为垃圾回收 (GC-Garbage Collector)

物理内存:存放应用程序运行期间所产生的也是必须的信息资源,也即是二进制信息的集合。内存是宝贵的资源,好东西当然要用到刀刃上,经不起浪费,如果不处理好垃圾回收,就会时常遇到OutOfMemory的报错-内存溢出,这对操作系统以及应用程序的使用是极大的伤害,所以及时回收垃圾内存是必不可少的关键机制。

二. 垃圾回收机制

内存资源分配
  1. 托管资源(栈,托管堆CLR-GC自动回收,是由操作系统决定回收时机)

  2. 非托管资源(非托管堆 Follow C/C++ 的手动释放)

托管资源回收机制

.Net中80%都是托管资源,比如为我们所熟知的值类型与引用类型,而值类型是直接分配在内存的栈区域,这块区域的内存是用完即弹出的,所以不需要任何额外的工作去参与回收,而引用类型是分配在内存的托管堆区域,这块区域是由CLR负责分配与回收的。这里就要让大名鼎鼎的GC(垃圾回收器)出场了,首先GC是系统级的一个线程,即它是由系统来调用,那么系统何时回去调用这个GC呢?一定是有某种机制来保证这个功能的运作,对的,那就是GC首先会去扫描托管堆内存中的所有对象引用,只要某对象不可达(即没有任何root引用到该对象),那么这个对象就会被标记(标记为垃圾回收的目标),在这个GC机制中有一个很重要的概念那就是GC的代(就是等级的意思),这个代的机制的引入主要是为了提高性能,以避免每次回收整个托管堆造成的性能损失,这里具体就不介绍了。最后总结一下GC的特性:

1) GC只会自动管理托管内存资源的回收,它是不能够自动管理释放非托管资源的;

2) GC并不是实时性的,这将会造成系统性能上的拼瓶颈与不确定性。

非托管资源回收机制

其他资源比如窗口句柄,数据库连接,字节流,文件流,GDI+相关对象,COM对象,Pen等等是属于非托管资源,这里需要注意的是,为啥数据库连接我没说SqlConnection,文件流我没说FileStream,字节流我没说BinaryStream等等,其实严格意义上来说,SqlConnection,FileStream,BinaryStream之类的并不能称之为非托管资源,其实他们是托管类,但是这些托管类当中却使用了非托管资源,所以就资源来说,就是数据库连接,文件流,字节流。而SqlConnection,FileStream,BinaryStream就是使用了非托管资源的托管类。在我们实际开发过程中,更多遇到的就是这些使用非托管资源的托管类(所以后续所谈的关于非托管资源回收也就是针对这种情况)。单纯纯粹的使用那些非托管资源是很少的,这些非托管资源是分配在非托管内存中,而不是前面所说的托管内存中,所以非托管资源的回收GC是无法插手的,那这就得有程序自己去做好回收处理了。那么牛逼的.Net Framework有没有提供给我们释放非托管资源的方式呢,很显然,MS是不会让我们失望的,她提供了2种方式,一种就是类型自带Finalize()方法,另一种就是实现IDisposable接口的Dispose方法,相较于GC来说,非托管资源的回收权掌握在我们自己手里,那么我们可就要好好捣鼓捣鼓,要不然没强大的GC给我们擦屁股,我们自己是很容易犯错的,动不动你可能就会遇到你的应用程序内存暴涨,性能低下甚至程序无故崩溃的恶心后果。所以接下来我们就来分析分析这两种方式的使用:

1) Finalize方法

在.Net的基类System.Object中,定义了名为Finalize()的一个虚方法,这个方法默认啥都不做。



顾名思义Finalize: 终结的意思,即指工作收尾,清场的意思。所以很显然这个函数就是提供我们清理资源的一个入口,那么这个方法是谁去调用的呢?是系统有个机制去调用亦或是我们程序自己去调用呢?很开心的告诉你,是系统去调用,小伙伴们听到后很开心有木有,终于又可以省下一笔时间好好的喝喝茶看看报了顺带玩把跳一跳了 _。(凡事都有两面性哦,正因为是操作系统做,那就不敢保证实时性与确定性喽,.Net大大很给力的,后面又提供了另外一种方式,对啦,就是后面我们将要讲的IDisposable)

言归正传,那系统又是如何调用Finalize方法的呢,所以下面我们来谈谈Finalize的工作机制:

i) CLR在托管堆上分配对象空间的时候,会自动确定该对象是否提供一个自定义的Finalize方法,如果检测到有的话,那么这个对象就会被标记为可终结的,同时一个指向这个对象的指针就会被保存到一个名字为终结队列的内部队列中,终结队列是有GC维护的一张表(小伙伴们是不是很亲切啊,对的,看到GC啦),这种表指向每一个在从堆上删除之前必须终结的对象。

ii) 当GC确定要从内存中释放某个对象的时候,它会检查终结队列上的每一项,并将对象放到一个队列中(从终结队列移到foreachable队列)中去,然后启动另外一个独立线程(我们称之为Finalizer线程)而不是GC线程来执行这些Finalizer(下个GC周期时),GC线程会继续删除其他待回收的对象,而是在下一个GC周期,Finalizer线程才去回收这些对象,由此可见,实现了Finalize方法的对象必须等待两次GC才能被完全释放,所以这些对象某种意义上是会在GC中自动“延长”生存周期。从上面可以看出,Finalize方法的调用是蛮耗费资源的,Finalize方法的作用是保证.Net对象能够在垃圾回收时清理非托管资源,如果创建了一个不使用非托管资源的类型,实现终结器是没有任何意义的,所以没有特殊的需求应该要避免重写Finalize方法。

看到这,是不是有一些好学的小伙伴屁颠屁颠的跑去VS上给某个使用了非托管资源的类型重新Finalize方法,一编译,卧槽,编译失败


其实,当我们想重写Finalize方法时,C#为我们为我们提供了析构函数这种语法来重写该方法,为毛要这样曲折呢,感兴趣的朋友可以研究研究(也可以在文章结尾处多注意注意哈_),析构函数语法跟构造函数类似,但析构函数有个前缀~,并且不能加任何访问修饰符,不能加任何参数,不能重载,所以一个类只能有一个析构函数,也叫终结器。

2) IDisposable接口

记性好的小伙伴们应该还记得上文有提到过这个茬,那就是通过垃圾回收是可以利用对象的终结器来释放非托管资源。然后,很多非托管资源非常宝贵,比如数据库连接以及文件句柄,所以他们应该尽可能快的被回收资源,而不能依靠垃圾回收来被动处理,为了更及时的对这些非托管资源进行回收,进而.Net提供了另外一种方式—IDisposable接口,跟垃圾回收的被动处理不同,此接口是提供给了我们主动回收的方式,这样就能如我们所愿主动及时的去回收那些非托管资源了,哈哈,我又要说那句富有哲理的老话啦,凡事都有两面性的,小伙伴们是不是都有想打我的冲动啦,Are you kidding us???小伙伴们稍安勿躁,人生在世,切忌浮躁哦,人生就是这样,凡事都有好有坏,世事无常,找到一个合适的平衡点对于人生是很重要的……扯远啦,回到正题来,为什么说这种方式也具有两面性呢,因为这种方式是我们自己显式去调用,是人那就会犯错,所以丢掉忘记那是很有可能的事,可能会漏掉Dispose的调用也有可能是在调用Dispose之前出现了异常,那么有些资源可能就一直留在内存中了,除非你通过工具手动清除或重启电脑,为最大程度的避免这种疏忽,我们可以使用try catch finally这种方式保证Dispose确实会被调用到,但每次套个try catch finally会觉得很麻烦,故此C#为我们提供了using关键字来简化Dispose的调用,其实实质上就是try catch finally的模式,只不过C#做了语法糖,让我们写起来更简洁,所以任何实现了IDisposable接口的类型,都可以用using语句,没有的话,那直接就会编译报错啦。

从前面的介绍了解到,Finalize可以通过垃圾回收进行自动的调用,而Dispose需要被代码显示的调用,所以,为了保险起见,对于一些非托管资源,还是有必要实现终结器的。也就是说,如果我们忘记了显示的调用Dispose,那么垃圾回收也会调用Finalize,从而保证非托管资源的回收。

其实,MSDN上给我们提供了一种很好的模式来实现IDisposable接口来结合Dispose和Finalize,例如下面的代码:

  class  MyResourceWrapper : IDisposable
  {
       private  bool IsDisposed = false;
       public  void Dispose()
         {
             Dispose(true);
              //tell GC not invoke Finalize method
              GC.SuppressFinalize(this);
         }

    protected  void Dispose(bool Disposing)
       {
            if (!IsDisposed)
             {
                if (Disposing)
                   {
                        //clear managed resources
                   }
              //clear unmanaged resources
             }
           IsDisposed = true;
        }

   ~MyResourceWrapper()
    {
          Dispose(false);
    }
 }

在这个模式中,void Dispose(bool Disposing)函数通过一个Disposing参数来区别当前是否是被Dispose()调用。如果是被Dispose()调用,那么需要同时释放托管和非托管的资源。如果是被终结器调用了,那么只需要释放非托管的资源即可。Dispose()函数是被其它代码显式调用并要求释放资源的,而Finalize是被GC调用的。

另外,由于在Dispose()中已经释放了托管和非托管的资源,因此在对象被GC回收时再次调用Finalize是没有必要的,所以在Dispose()中调用GC.SuppressFinalize(this)避免重复调用Finalize。同样,因为IsDisposed变量的存在,资源只会被释放一次,多余的调用会被忽略。

所以这个模式的优点可以总结为:

如果没有显示的调用Dispose(),未释放托管和非托管资源,那么在垃圾回收时,还会执行Finalize(),释放非托管资源,同时GC会释放托管资源

如果调用了Dispose(),就能及时释放了托管和非托管资源,那么该对象被垃圾回收时,就不会执行Finalize(),提高了非托管资源的使用效率并提升了系统性能

通过以上的探究,现在回到文章一开始遇到的那个问题,我们就可以知道实际上File.Create方法返回的是一个FileStream实例:



然而这个实例其实就是一个使用了非托管资源的托管类,而文章一开始的例子当中,在创建完File之后并没有及时的去回收掉这个FileStream实例,所以他只能等待GC的自动回收,然后GC的回收机制是不实时和不确定的,所以当我们紧接着去针对这个文件创建一个文件流的时候GC此时还并没有去回收她,所以就会出现占用的异常,而增加Test()这个方法主要是为了故意增加内存的使用,逼迫系统进行一次垃圾回收(当然我们也可以通过GC.Collect()来做),所以之后就不会再出现这个异常,而为什么将Test()代码位置变动一下之后也会出现异常呢,这个就是上面提到的GC代的概念,有兴趣的朋友可以自行了解下。正如上面总结的那样,对于使用了非托管资源的类型,我们需要及时手动的进行回收动作。


末尾彩蛋:之所以C#只支持析构方式进行Finalize方法的重写,是因为C#编译器会为Finalize方法隐式地加入一些必需的基础代码。下面就是我们通过ILSpy查看到了IL代码,Finalize方法作用域内的代码被放在了一个try块中,然后不管在try块中是否遇到异常,finally块保证了Finalize方法总是能够被执行。


/**以上仅为个人学习总结,转载请标注原处,如有不足之处请指正
搞技术,我们是认真的。*****/

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值