文章目录
前言
C#的垃圾回收网上有很多博客进行讲解,这里摘录一部分较好的讲解,同时建议直接使用微软官方文档,万变不离其宗一、垃圾回收是什么
.NET 的垃圾收集器管理应用程序的内存分配和释放。每次创建新对象时,公共语言运行时都会从托管堆中为该对象分配内存。只要托管堆中有可用的地址空间,运行时就会继续为新对象分配空间。然而,内存并不是无限的。最终垃圾收集器必须执行收集以释放一些内存。垃圾收集器的优化引擎根据进行的分配确定执行收集的最佳时间。当垃圾收集器执行收集时,它会检查托管堆中应用程序不再使用的对象,并执行必要的操作来回收它们的内存。
在公共语言运行时 (CLR) 中,垃圾收集器 (GC) 充当自动内存管理器。垃圾收集器管理应用程序的内存分配和释放。对于使用托管代码的开发人员来说,这意味着不必编写代码来执行内存管理任务。自动内存管理可以消除常见问题,例如忘记释放对象并导致内存泄漏或尝试访问已释放对象的内存。
Garbage Collector(垃圾收集器,在不至于混淆的情况下也成为GC)以应用程序的root为基础,遍历应用程序在Heap上动态分配的所有对象[2],通过识别它们是否被引用来确定哪些对象是已经死亡的、哪些仍需要被使用。已经不再被应用程序的root或者别的对象所引用的对象就是已经死亡的对象,即所谓的垃圾,需要被回收。这就是GC工作的原理。为了实现这个原理,GC有多种算法。比较常见的算法有Reference Counting,Mark Sweep,Copy Collection等等。目前主流的虚拟系统.NET CLR,Java VM和Rotor都是采用的Mark Sweep算法。
二、好处
垃圾收集器提供以下好处:
- 使开发人员不必手动释放内存。
- 有效地在托管堆上分配对象。
- 回收不再使用的对象,清除它们的内存,并使内存可用于将来的分配。托管对象会自动获得干净的内容,因此它们的构造函数不必初始化个数据字段。
- 通过确保一个对象不能为自己使用分配给另一个对象的内存来提供内存安全。
总的说来就是GC可以使程序员可以从复杂的内存问题中摆脱出来,从而提高了软件开发的速度、质量和安全性。
三、GC过程
1.GC条件
- If the system has low physical memory, then garbage collection is necessary.(系统内存过低时执行)
- If the memory allocated to various objects in the heap memory exceeds a pre-set threshold, then garbage collection occurs.(分配给各个对象的内存超过预先设定阈值)
- If the GC.Collect method is called, then garbage collection occurs. However, this method is only called under unusual situations as normally garbage collector runs automatically.(手动调用GC.Collect)
2.GC步骤
GC总体可以分为三个步骤:
- 标记(Mark)。从Root开始进行引用标记,未被标记到的为不可达内存,不可达内存为GC对象。
- 重新分配地址(Relocate)。更新所有活动对象列表中的所有对象的引用,以便它们指向对象将在压缩阶段重定位到的新位置。
- 压缩(Compact)。当部分内存被清除后,原本的内存空间变得不连续,因此剩余的存活对象需要按照原始顺序从基址开始重新排列。
3.Mark-Compact 标记压缩算法
简单地把.NET的GC算法看作Mark-Compact算法。
阶段1: Mark-Sweep 标记清除阶段,先假设heap中所有对象都可以回收,然后找出不能回收的对象,给这些对象打上标记,最后heap中没有打标记的对象都是可以被回收的;
阶段2: Compact 压缩阶段,对象回收之后heap内存空间变得不连续,在heap中移动这些对象,使他们重新从heap基地址开始连续排列,类似于磁盘空间的碎片整理。Heap内存经过回收、压缩之后,可以继续采用前面的heap内存分配方法,即仅用一个指针记录heap分配的起始地址就可以。
主要处理步骤:将线程挂起→确定roots→创建reachable objects graph→对象回收→heap压缩→指针修复。可以这样理解roots:heap中对象的引用关系错综复杂(交叉引用、循环引用),形成复杂的graph,roots是CLR在heap之外可以找到的各种入口点。
GC搜索roots的地方包括全局对象、静态变量、局部对象、函数调用参数、当前CPU寄存器中的对象指针(还有finalization queue)等。主要可以归为2种类型:已经初始化了的静态变量、线程仍在使用的对象(stack+CPU register) 。
Reachable objects:指根据对象引用关系,从roots出发可以到达的对象。例如当前执行函数的局部变量对象A是一个root object,他的成员变量引用了对象B,则B是一个reachable object。从roots出发可以创建reachable objects graph,剩余对象即为unreachable,可以被回收 。
指针修复是因为compact过程移动了heap对象,对象地址发生变化,需要修复所有引用指针,包括stack、CPU register中的指针以及heap中其他对象的引用指针。
Debug和release执行模式之间稍有区别,release模式下后续代码没有引用的对象是unreachable的,而debug模式下需要等到当前函数执行完毕,这些对象才会成为unreachable,目的是为了调试时跟踪局部对象的内容。传给了COM+的托管对象也会成为root,并且具有一个引用计数器以兼容COM+的内存管理机制,引用计数器为0时,这些对象才可能成为被回收对象。Pinned objects指分配之后不能移动位置的对象,例如传递给非托管代码的对象(或者使用了fixed关键字),GC在指针修复时无法修改非托管代码中的引用指针,因此将这些对象移动将发生异常。pinned objects会导致heap出现碎片,但大部分情况来说传给非托管代码的对象应当在GC时能够被回收掉。
4.Generational 分代算法
GC算法的设计考虑到了几个因素:
- 对于较大内存的对象,频繁的进行GC将耗费大量的资源,成本很高且效果较差
- 大量新创建的对象生命周期都较短,老对象的生命周期都较长
- 小部分的进行GC比大块的进行GC效率更高,消耗更少
- 新创建的对象在内存分配上多为连续,且关联程度较强,关联度较强有利于CPU Cache命中。
基于此,按照寿命长短,托管堆被分为了三个年龄层,分别是Generation 0,Generation 1, Generation 2。垃圾收集器在第 0 代存储新对象。在应用程序生命周期早期创建的在收集过程中幸存下来的对象被提升并存储在第 1 代和第 2 代中。因为压缩托管堆的一部分比压缩整个堆要快,因此该方案允许垃圾收集器在特定代中释放内存,而不是在每次执行收集时释放整个托管堆的内存。
- 第 0 代。这是最年轻的一代,包含生命周期很短的对象。短期对象的一个例子是临时变量。垃圾收集在这一代发生得最频繁。新分配的对象形成了第0代的对象,并且是隐式的第 0 代集合。但是,对象很大,它们将进入大对象堆 (LOH),有时也称为第3 代。第3 代可以理解为物理代,作为第二代的衍生。 大多数对象在第 0 代被回收用于垃圾收集,并且不会存活到下一代。
如果应用程序在第 0 代已满时尝试创建新对象,垃圾收集器将执行收集以尝试释放对象的地址空间。垃圾收集器首先检查第 0代中的对象,而不是托管堆中的所有对象。单独的第 0 代集合通常会回收足够的内存,使应用程序能够继续创建新对象。 - 第 1 代。这一代包含短期对象,并作为短期对象和长期对象之间的缓冲区。在垃圾收集器执行第 0代的收集后,它会压缩可访问对象的内存并将它们提升到第 1代。因为在收集中幸存下来的对象往往具有更长的生命周期,所以将它们提升到更高的代是有意义的。垃圾收集器不必在每次执行第 0代收集时重新检查第 1 代和第 2 代中的对象。 如果第 0 代的集合没有为应用程序回收足够的内存来创建新对象,则垃圾收集器可以执行第1 代的收集,然后是第 2 代。第 1 代中在集合中幸存下来的对象将被提升到第 2 代。
- 第 2 代。这一代包含长期存在的对象。长寿命对象的一个示例是服务器应用程序中的对象,其中包含在进程持续期间有效的静态数据。在集合中存活的第 2 代对象将保留在第 2 代中,直到它们被确定在未来的集合中不可访问。 大对象堆(有时称为第3 代)上的对象也在第 2代中收集。
当条件允许时,垃圾收集发生在特定的世代。收集一代意味着收集该一代及其所有年轻一代的对象。第 2 代垃圾回收也称为完整垃圾回收,因为它回收所有代中的对象(即托管堆中的所有对象)。
当垃圾收集器检测到某一代存活率较高时,会增加该代的分配阈值。 下一个集合获得大量回收内存。 CLR 不断平衡两个优先级:不让应用程序的工作集因延迟垃圾收集而变得太大,以及不让垃圾收集运行得太频繁。
5.Finalization Queue和Freachable Queue
这两个队列和.NET对象所提供的Finalize方法有关。这两个队列并不用于存储真正的对象,而是存储一组指向对象的指针。当程序中使用了new操作符在Managed Heap上分配空间时,GC会对其进行分析,如果该对象含有Finalize方法则在Finalization Queue中添加一个指向该对象的指针。
在GC被启动以后,经过Mark阶段分辨出哪些是垃圾。再在垃圾中搜索,如果发现垃圾中有被Finalization Queue中的指针所指向的对象,则将这个对象从垃圾中分离出来,并将指向它的指针移动到Freachable Queue中。这个过程被称为是对象的复生(Resurrection),本来死去的对象就这样被救活了。为什么要救活它呢?因为这个对象的Finalize方法还没有被执行,所以不能让它死去。Freachable Queue平时不做什么事,但是一旦里面被添加了指针之后,它就会去触发所指对象的Finalize方法执行,之后将这个指针从队列中剔除,这是对象就可以安静的死去了。
**.NET Framework的System.GC类提供了控制Finalize的两个方法,ReRegisterForFinalize和SuppressFinalize。**前者是请求系统完成对象的Finalize方法,后者是请求系统不要完成对象的Finalize方法。ReRegisterForFinalize方法其实就是将指向对象的指针重新添加到Finalization Queue中。这就出现了一个很有趣的现象,因为在Finalization Queue中的对象可以复生,如果在对象的Finalize方法中调用ReRegisterForFinalize方法,这样就形成了一个在堆上永远不会死去的对象,像凤凰涅槃一样每次死的时候都可以复生。
代码如下(示例):
四、托管和非托管资源
1.托管资源
.NET中的所有类型都是(直接或间接)从System.Object类型派生的。
通用类型系统(CTS)区分两种基本类型:值类型和引用类型。它们之间的根本区别在于它们在内存中的存储方式。.NET使用两种不同的物理内存快来存储数据------栈和托管堆:
值类型在栈里,先进后出,值类型变量的生命有先后顺序,这个确保了值类型变量在退出作用域以前会释放资源。比引用类型更简单和高效。堆栈是从高地址往低地址分配内存。
引用类型分配在托管堆(Managed Heap)上,声明一个变量在栈上保存,当使用new创建对象时,会把对象的地址存储在这个变量里。托管堆相反,从低地址往高地址分配内存,如图:
2.非托管资源
ApplicationContext, Brush, Component, ComponentDesigner, Container, Context, Cursor, FileStream, Font, Icon, Image, Matrix, Object, OdbcDataReader, OleDBDataReader, Pen, Regex, Socket, StreamWriter, Timer, Tooltip, 文件句柄, GDI资源, 数据库连接等等资源。
五、GC注意事项
- 只管理内存,非托管资源,如文件句柄,GDI资源,数据库连接等还需要用户去管理。
- 循环引用,网状结构等的实现会变得简单。GC的标志-压缩算法能有效的检测这些关系,并将不再被引用的网状结构整体删除。
- GC通过从程序的根对象开始遍历来检测一个对象是否可被其他对象访问,而不是用类似于COM中的引用计数方法。
- GC在一个独立的线程中运行来删除不再被引用的内存。
- GC每次运行时会压缩托管堆。
- 你必须对非托管资源的释放负责。可以通过在类型中定义Finalizer来保证资源得到释放。
- 对象的Finalizer被执行的时间是在对象不再被引用后的某个不确定的时间。注意并非和C++中一样在对象超出声明周期时立即执行析构函数
- Finalizer的使用有性能上的代价。需要Finalization的对象不会立即被清除,而需要先执行Finalizer.Finalizer,不是在GC执行的线程被调用。GC把每一个需要执行Finalizer的对象放到一个队列中去,然后启动另一个线程来执行所有这些Finalizer,而GC线程继续去删除其他待回收的对象。在下一个GC周期,这些执行完Finalizer的对象的内存才会被回收。
- NET GC使用"代"(generations)的概念来优化性能。代帮助GC更迅速的识别那些最可能成为垃圾的对象。在上次执行完垃圾回收后新创建的对象为第0代对象。经历了一次GC周期的对象为第1代对象。经历了两次或更多的GC周期的对象为第2代对象。代的作用是为了区分局部变量和需要在应用程序生存周期中一直存活的对象。大部分第0代对象是局部变量。成员变量和全局变量很快变成第1代对象并最终成为第2代对象。
- GC对不同代的对象执行不同的检查策略以优化性能。每个GC周期都会检查第0代对象。大约1/10的GC周期检查第0代和第1代对象。大约1/100的GC周期检查所有的对象。重新思考Finalization的代价:需要Finalization的对象可能比不需要Finalization在内存中停留额外9个GC周期。如果此时它还没有被Finalize,就变成第2代对象,从而在内存中停留更长时间。
参考
https://www.cnblogs.com/nele/p/5673215.html
https://www.cnblogs.com/zhijianliutang/archive/2011/12/07/2278735.html
https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals