C#之垃圾回收机制

2020-12-29 12:19:30 浏览数 (1)

首先说bai下C#中的变量类型吧,duC#中有2个变量类zhi型,一种是值类型,一dao种是引用类型,值类型是zhuan在栈上创建shu,这一类型用不到GC,引用类型是在堆中创建,GC主要是在这里管理对象。GC对每个对象有个引用计数,所有说只要有变量在引用它,计数器就不为了,一个变量不再引用这个对象,对象的计数器就减一,知道计数器为0时,对象就成为内存垃圾了(没有变量引用它),但是此时垃圾并没有回收。那什么时候回收呢,是在内存占用超过一定限度是,GC才启动,释放垃圾资源,说白了就是delete这些对象,将空间归还给系统。但是这还没完,空间释放后,内存空间就不连续了,所有GC还要赶一件事,就是将空间整理下,将占用的空间连续话,具体说就是将空间向上推,就是想高地值转存,这样空间就连续了,使用也方便了,然后GC就改变应用那些对象的变量里地地址,让他们指向正确的位置,所以说C#中的引用类型就是一种指针,一种动态改变值的指针。

什么是GC

GC(Garbage Collector)就是垃圾收集器,这里仅就内存而言。以应用程序的root为基础,遍历应用程序在Heap上动态分配的所有对象,通过识别它们是否被引用来确定哪些对象是已经死亡的、哪些仍需要被使用。已经不再被应用程序的root或者别的对象所引用的对象就是已经死亡的对象,即所谓的垃圾,需要被回收。这就是GC工作的原理。

为了实现这个原理,GC有多种算法。比较常见的算法有Reference Counting,Mark Sweep,Copy Collection等等。目前主流的虚拟系统.NET CLR,Java VM都是采用的Mark Sweep算法。

算法工作原理

垃圾收集器的本质,就是跟踪所有被引用到的对象,整理不再被引用的对象,回收相应的内存。

这听起来类似于一种叫做“引用计数(Reference Counting)”的算法,然而这种算法需要遍历所有对象,并维护它们的引用情况,所以效率较低些,并且在出现“环引用”时很容易造成内存泄露。

所以.Net中采用了一种叫做“标记与清除(Mark Sweep)”算法来完成上述任务。 “标记与清除”算法,顾名思义,这种算法有两个本领:

  • “标记”本领——垃圾的识别:从应用程序的root出发,利用相互引用关系,遍历其在Heap上动态分配的所有对象,没有被引用的对象不被标记,即成为垃圾;存活的对象被标记,即维护成了一张“根-对象可达图”。其实,CLR会把对象关系看做“树图”,这样会加快遍历对象的速度。 .Net中利用栈来完成检测并标记对象引用,在不断的入栈与出栈中完成检测:先在树图中选择一个需要检测的对象,将该对象的所有引用压栈,如此反复直到栈变空为止。栈变空意味着已经遍历了这个局部根能够到达的所有对象。树图节点范围包括局部变量、寄存器、静态变量,这些元素都要重复这个操作。一旦完成,便逐个对象地检查内存,没有标记的对象变成了垃圾。
  • “清除”本领——回收内存:启用压缩(Compact)算法,对内存中存活的对象进行移动,修改它们的指针,使之在内存中连续,这样空闲的内存也就连续了,这就解决了内存碎片问题,当再次为新对象分配内存时,CLR不必在充满碎片的内存中寻找适合新对象的内存空间,所以分配速度会大大提高。但是大对象(large object heap)除外,GC不会移动一个内存中巨无霸,因为它知道现在的CPU不便宜。通常,大对象具有很长的生存期,当一个大对象在.NET托管堆中产生时,它被分配在堆的一个特殊部分中,移动大对象所带来的开销超过了整理这部分堆所能提高的性能。

Compact算法除了会提高再次分配内存的速度,如果新分配的对象在堆中位置很紧凑的话,高速缓存的性能将会得到提高,因为一起分配的对象经常被一起使用(程序的局部性原理),所以为程序提供一段连续空白的内存空间是很重要的。

简单地把.NET的GC算法看作Mark-Sweep 算法。 阶段1: Mark-Sweep 标记清除阶段,先假设heap中所有对象都可以回收,然后找出不能回收的对象,给这些对象打上标记,最后heap中没有打标记的对象都是可以被回收的; 阶段2: Compact 压缩阶段,对象回收之后heap内存空间变得不连续,在heap中移动这些对象,使他们重新从heap基地址开始连续排列,类似于磁盘空间的碎片整理。

Mark-Sweep 算法.png

Reachable objects:指根据对象引用关系,从roots出发可以到达的对象。例如当前执行函数的局部变量对象A是一个root object,他的成员变量引用了对象B,则B是一个reachable object。从roots出发可以创建reachable objects graph,剩余对象即为unreachable,可以被回收 。

GC按什么规则收集垃圾对象--Generational 分代算法?

.NET将heap分成3个代龄区域: Gen 0、Gen 1、Gen 2;heap分配的对象是连续的,关联度较强有利于提高CPU cache的命中率。

Generational 分代算法.png

Heap分为3个代龄区域,相应的GC有3种方式: # Gen 0 collections, # Gen 1 collections, #Gen 2 collections。

如果Gen 0 heap内存达到阀值,则触发0代GC,0代GC后Gen 0中幸存的对象进入Gen1。如果Gen 1的内存达到阀值,则进行1代GC,1代GC将Gen 0 heap和Gen 1 heap一起进行回收,幸存的对象进入Gen2。2代GC将Gen 0 heap、Gen 1 heap和Gen 2 heap一起回收。如果GC跑过了,内存空间依然不够用,那么就抛出了OutOfMemoryException异常。

Gen 0和Gen 1比较小,这两个代龄加起来总是保持在16M左右;Gen2的大小由应用程序确定,可能达到几G,因此0代和1代GC的成本非常低,2代GC称为full GC,通常成本很高。

粗略的计算0代和1代GC应当能在几毫秒到几十毫秒之间完成,Gen 2 heap比较大时,full GC可能需要花费几秒时间。大致上来讲.NET应用运行期间,2代、1代和0代GC的频率应当大致为1:10:100。

该如何释放非托管资源呢?

既然有了垃圾收集器,为什么还要Dispose方法和析构函数? 因为CLR的缘故,GC只能释放托管资源,不能释放非托管资源(数据库链接、文件流等)。所以对于非托管资源一般我们会选择为类实现IDispose接口,写一个Dispose方法。让调用者手动调用这个类的Dispose方法(或者用using语句块来自动调用Dispose方法),Dispose执行时,析构函数和垃圾收集器都还没有开始处理这个对象的释放工作。

如果我们不想为一个类实现Dispose方法,而是想让它自动的释放非托管资源,那么就要用到析构函数了。析构函数是由GC调用的。你无法预测析构函数何时会被调用,所以尽量不要在这里操作可能被回收的托管资源,析构函数只用来释放非托管资源。GC释放包含析构函数的对象,需要垃圾处理器调用俩次,CLR会先让析构函数执行,再收集它占用的内存。 关于如何释放非托管资源详情,可以看一下另一篇文章《C#之托管与非托管资源》

什么场景下手动执行垃圾收集?

GC什么时候执行垃圾收集是一个非常复杂的算法(策略),大概可以描述成这样:如果GC发现上一次收集了很多对象,释放了很大的内存,那么它就会尽快执行第二次回收,如果它频繁的回收,但释放的内存不多,那么它就会减慢回收的频率。所以,尽量不要调用GC.Collect()这样会破坏GC现有的执行策略。除非你对你的应用程序内存使用情况非常了解,你知道何时会产生大量的垃圾,那么你可以手动干预垃圾收集器的工作,例如我有一个大对象,我担心GC要过很久才会收集他。

GC.Collect() 方法

作用:强制进行垃圾回收。

名称

说明

Collect()

强制对所有代进行即时垃圾回收。

Collect(Int32)

强制对零代到指定代进行即时垃圾回收

Collect(Int32, GCCollectionMode)

强制在 GCCollectionMode 值所指定的时间对零代到指定代进行垃圾回收

GC注意事项:

  1. 只管理内存,非托管资源,如文件句柄,GDI资源,数据库连接等还需要用户去管理。
  2. 循环引用,网状结构等的实现会变得简单。GC的标志-压缩算法能有效的检测这些关系,并将不再被引用的网状结构整体删除。
  3. GC通过从程序的根对象开始遍历来检测一个对象是否可被其他对象访问,而不是用类似于COM中的引用计数方法。
  4. GC在一个独立的线程中运行来删除不再被引用的内存。
  5. GC每次运行时会压缩托管堆。
  6. 你必须对非托管资源的释放负责。可以通过在类型中定义Finalizer来保证资源得到释放。
  7. 对象的Finalizer被执行的时间是在对象不再被引用后的某个不确定的时间。注意并非和C 中一样在对象超出声明周期时立即执行析构函数
  8. Finalizer的使用有性能上的代价。需要Finalization的对象不会立即被清除,而需要先执行Finalizer.Finalizer,不是在GC执行的线程被调用。GC把每一个需要执行Finalizer的对象放到一个队列中去,然后启动另一个线程来执行所有这些Finalizer,而GC线程继续去删除其他待回收的对象。在下一个GC周期,这些执行完Finalizer的对象的内存才会被回收。
  9. .NET GC使用"代"(generations)的概念来优化性能。代帮助GC更迅速的识别那些最可能成为垃圾的对象。在上次执行完垃圾回收后新创建的对象为第0代对象。经历了一次GC周期的对象为第1代对象。经历了两次或更多的GC周期的对象为第2代对象。代的作用是为了区分局部变量和需要在应用程序生存周期中一直存活的对象。大部分第0代对象是局部变量。成员变量和全局变量很快变成第1代对象并最终成为第2代对象。
  10. GC对不同代的对象执行不同的检查策略以优化性能。每个GC周期都会检查第0代对象。大约1/10的GC周期检查第0代和第1代对象。大约1/100的GC周期检查所有的对象。重新思考Finalization的代价:需要Finalization的对象可能比不需要Finalization在内存中停留额外9个GC周期。如果此时它还没有被Finalize,就变成第2代对象,从而在内存中停留更长时间。

为什么要使用GC呢?

  1. 提高了软件开发的抽象度;
  2. 程序员可以将精力集中在实际的问题上而不用分心来管理内存的问题;
  3. 可以使模块的接口更加的清晰,减小模块间的偶合;
  4. 大大减少了内存人为管理不当所带来的Bug;
  5. 使内存管理更加高效。

总的说来GC可以使程序员可以从复杂的内存问题中摆脱出来,从而提高了软件开发的速度、质量和安全性。

0 人点赞