试着把.net的GC讲清楚(1)
什么是GC?
GC(garbage collection)是对内存管理中回收已经不用的内存的一种机制,我们熟知的java和.net都有自己的GC机制,是内存管理的一部分。
为什么会有GC呢?是因为动态的内存分配和分布操作系统是不管的,得各类语言自己实现,例如c和c++自己需要手动管理分配的内存资源,如果不手动释放,那么会造成已经无用的内存不能被操作系统识别使用,也就是所谓的内存泄漏。
.net的GC都是发生在堆(heap),因为这个动态的内存是在堆上分配的。为什么.net 没有像c++一样提供手动管理内存的操作?因为手工管理内存非常容易出问题,开发人员不应花费时间在这个上面,避免人为问题,就找一个管理内存的“人”来,处理这些事情,于是GC就出现了(说一句话,以前自己是c、c++出身,真的一个语言影响一个人的知识广度和深度),大家都不用考虑这些事情,集中在重要的事情上,就像自己找了个管家,而且是专业的,不会出错的那种,省心。
GC有哪些分类?
我了解到的有:Reference Count、Mark and Sweep(升级版Mark and Compact)、Copy and Collection
现在java和.net 使用的是Mark and Compact算法,这个算法是从Mark and Sweep算法演变过来的,下来就讲讲Mark and Sweep。
Mark and Sweep:分为两个阶段,第一阶段标记所有现在还可以使用的对象,第二阶段清除标记的对象之外的内存。
在.net中,GC管理了一组root(由全局对象组成),通过遍历所有的root机器引用的子对象,进行内存中的存活对象的标记,之后就清除未标记为存活的对象。这就是Mark and Sweep算法,但是这个造成了一个问题,就是回收后的内存是成了筛子了,这个时候如果来一个大的对象需要分配内存,那么空余内存总额大于分配对象的大小,但是找不到一个连续的可以容下这个对象大小的内存,这个时候怎么办?其实模拟操作系统,再做一个内存管理的机制就行,在逻辑上看着连续就行了。当然这个不是本次讨论的对象,Mark and Compact解决了内存不连续的问题,因为它把内存做了一次整理(把不相邻的内存移动到一块,看着就连续了)
Mark and Compact:在Mark and Sweep基础上做了一次内存整理,因为内存做整理的时候,对象的引用是不能被使用的,引用地址会变,所以啊,GC的时候,使用到这些对象的线程什么的是会被挂起等待的,也不能经常回收内存,不然性能堪忧,就是因为回收导致挂起了。
啥是0代、1代、2代对象?
要解释这个问题,还得从内存回收时间说起,这里有个假设(其实也是规则)回收内存中所有对象的时间大于回收部分对象的时间,于是就把内存中对象分成了几代,0代对象指最新分配内存的对象,一次类推。其实多少代,这个由GC决定,.net中GC中代数是3代(这个值暂时不能确定能不能改)。
GC怎么管理代对象呢?一般情况下,分配的对象都是0代对象,在分配对象内存时,如果0代对象的内存已经不能容纳新对象了(超过0代对象内存的上限),在gc回收一次0代后,这个还存活的对象代数加1(GC.Collect();GC.GetGeneration(obj)
代码验证过,现在不清楚自动触发是不是回收一次加1),同理如果1代对象超过了1代内存的上限,也会触发gc回收1代对象。那么这个内存回收是定是这样的么?不一定,毕竟微软提供了手动触发gc的功能,就是GC.Collect()
,有兴趣可以翻翻这个方法。
代数的大小,查了很多资料之后,只发现一篇文章说到,.net中0代和1代之和为16MB,2代内存上限非常的大,具体有framework版本和其他一些因素决定的。
//验证回收一次,对象就升一代
Object obj=new Object();
Console.WriteLine(GC.GetGeneration(obj));
GC.Collect();
Console.WriteLine(GC.GetGeneration(obj));
GC.Collect();
Console.WriteLine(GC.GetGeneration(obj));
Finalize、Dispose是啥?如何理解?
.net中有托管资源和非托管资源的分类,托管资源.net自己就可以管理,非托管资源,需要特殊的方法,也就是托管资源在GC的时候,.net可以自己识别,但是非托管资源,GC是自动释放不了的。
什么是非托管资源?这让我想起之前用mfc写windows程序的时候,什么画刷、画笔、com之类的,就是非托管资源,还有数据库连接、文件、套接字之类的也是,哦,还有流之类的都是。
这些非托管资源,一般都需要自己释放资源,.net提供了IDsiposable的接口,实现这个接口的方法,在里面进行资源释放,使用using语句来简化这个非托管资源的释放工作。
Finalize:这个也能用来释放非托管资源,与IDsiposable接口区别是,它的调用时机是不定的,因为它是由GC调用的,GC调用真的不定的,因为调用一次GC调用会降低程序性能(前面说的,内存压缩导致引用需要变化,而因为线程挂起),下面来说说为什么它是由GC调用的。
在创建对象的时候,会把还有析构函数(编译之后,就是Finalize方法,与c++中的析构函数不同)的对象引用存到一个叫做Finalizer Queue的list中,在GC的时候,如果一个对象是无用的,而且在Finalizer Queue里面有引用,此次并不回收,并且会把引用从Finalizer Queue移到Freachable Queue的list中,Freachable Queue的list有内容之后会启动一个线程,然后执行里面的引用的对象的析构函数,执行完毕后把对象的引用删除,等待下次GC的时候,才进行回收此对象。
所以Finalize的特点就是:
- 啥时候调用不定
- 这类对象,需要至少两次GC才能回收。
为什么至少两次,而不是两次,因为.net为我们提供了一个把对象引用放回Finalizer Queue的方法,GC.ReRegisterForFinalize(),如果在Finalize中调用了这个代码,那么就死不了了。
微软不建议使用Finalize方法,就像其他博客中提到的,我们可以把它留作后手,万一那个非托管资源该释放没有释放,可以在Finalize方法中做为最后的保险(算是避免人为原因)。
我确实释放完了非托管资源,就是不想执行Finalize方法,微软也提供了方法了:GC.SuppressFinalize(this),这个方法执行了之后就把这个对象的引用中Finalizer Queue移除了。
LOH是什么?
LOH(large object heap)是为了大对象而专门设计的一个堆,多大的对象会分配到这个堆里面?超过85000个字节的就会。其实这个loh产生原因大对象移动非常的耗时,还不如不移动,例如,3个对象ABC,AB对象大约占个80个字节,C对象占个10000个字节,假设AB对象被回收,那么在移动阶段,就要把10000个字节,往前移动80字节,还不如不移动性能高。这个85000字节也是一个经验值。
既然loh不能移动,那么肯定不能用Mark and Compact中的移动了(使用什么算法现在还不清楚,猜测是Mark and Sweep,或许是特例),并且在只有2代对象回收的时候才进行回收。
GC模式?
- workstation mode:用于单处理器的系统中,频繁回收,从而阻止一次长时间的回收对程序的挂起时间。
- server mode:用于多处理器的系统中,为每个处理器都创建一个GC Heap,该模式特点是,分配内存较大,能不回收就不回收,回收时候耗时太长。
其中有Concurrent GC 工作方式,其中workstation mode和server mode都可以配置,在单处理器上设置为true也不生效,主要用于用户线程在gc时候可以大部分时间和gc线程并发,详细可以参考:https://blogs.msdn.microsoft.com/seteplia/2017/01/05/understanding-different-gc-modes-with-concurrency-visualizer/
啥时候需要手动gc?
资源特别紧张的时候,例如之前面试的一家公司,系统是在azure上,内存什么的特别贵,这个时候手动gc可能其中一种手段了。
最后
其实gc的最耗时还是在算法的选择上,比如Mark and Compact中的把内存合并成连续的,这个才是耗时的,如果内存足够多,根本就不需要考虑移动内存。
或者像我之前提的,再在内存上面做一次内存映射的管理,也可以避免内存不连续的问题,当然肯定会遇到各种各样的问题。