一, 基础
首先, 为了深入了解垃圾回收(GC), 我们要了解一些基础知识:
CLR:Common Language Runtime, 即公共语言运行时, 是一个可由多种面向 CLR 的编程语言使用的 "运行时", 包括内存管理, 程序集加载, 安全性, 异常处理和线程同步等核心功能.
托管进程中的两种内存堆:
托管堆: CLR 维护的用于管理引用类型对象的堆, 在进程初始化时, 由 CLR 划出一个地址空间区域作为托管堆. 当区域被非垃圾对象填满后, CLR 会分配更多的区域, 直到整个进程地址空间 (受进程的虚拟地址空间限制, 32 位进程最多分配 1.5GB, 而 64 位最多可分配 8TB) 被填满.
本机堆: 由名为 VirtualAlloc 的 Windows API 分配的, 用于非托管代码所需的内存.
NextObjPtr:CLR 维护的一个指针, 指向下一个对象在堆中的分配位置. 初始为地址空间区域的基地址.
CLR 将对象分为大对象和小对象, 两者分配的地址空间区域不同. 我们下方的讲解更关注小对象.
大对象: 大于等于 85000 字节的对象."85000" 并非常数, 未来可能会更改.
小对象: 小于 85000 字节 的对象.
然后明确几个前提:
CLR 要求所有引用类型对象都从托管堆分配.
- C# 是运行于 CLR 之上的.
- C#new 一个新对象时, CLR 会执行以下操作:
计算类型的字段 (包括从基类继承的字段) 所需的字节数.
加上对象开销所需的字节数. 每个对象都有两个开销字段: 类型对象指针和同步块索引, 32 位程序为 8 字节, 64 位程序为 16 字节.
CLR 检查托管堆是否有足够的可用空间, 如果有, 则将对象放入 NextObjPtr 指向的地址, 并将对象分配的字节清零. 接着调用构造器, 对象引用返回之前, NextObjPtr 加上对象真正占用的字节数得到下一个对象的分配位置.
弄清楚以上知识点后, 我们继续来了解 CLR 是如何进行 "垃圾回收" 的.
二, 垃圾回收的流程
我们先来看垃圾回收的算法与主要流程:
算法: 引用跟踪算法. 因为只有引用类型的变量才能引用堆上的对象, 所以该算法只关心引用类型的变量, 我们将所有引用类型的变量称为根.
主要流程:
1. 首先, CLR 暂停进程中的所有线程. 防止线程在 CLR 检查期间访问对象并更改其状态.
2. 然后, CLR 进入 GC 的标记阶段.
a. CLR 遍历堆中的对象(实际上是某些代的对象, 这里可以先认为是所有对象), 将同步块索引字段中的一位设为 0, 表示对象是不可达的, 要被删除.
b. CLR 遍历所有根, 将所引用对象的同步块索引位设为 1, 表示对象是可达的, 要保留.
3. 接着, CLR 进入 GC 的碎片整理阶段.
a. 将可达对象压缩到连续的内存空间(大对象堆的对象不会被压缩)
b. 重新计算根所引用对象的地址.
4. 最后, NextObjPtr 指针指向最后一个可达对象之后的位置, 恢复应用程序的所有线程.
三, 垃圾回收的具体细节
CLR 的 GC 是基于代的垃圾回收器, 它假设:
对象越新, 生存期越短
对象越老, 生存期越长
回收堆的一部分, 速度快于回收整个堆
托管堆最多支持三代对象:
第 0 代对象: 新构造的未被 GC 检查过的对象
第 1 代对象: 被 GC 检查过 1 次且保留下来的对象
第 2 代对象: 被 GC 检查大于等于 2 次且保留下来的对象
第 0 代回收只会回收第 0 代对象, 第 1 代回收则会回收第 0 代和第 1 代对象, 而第 2 代回收表示完全回收, 会回收所有对象.
CLR 初始化时, 会为第 0 代和第 1 代对象选择一个预算容量(单位: KB). 如下图, CLR 为 ABCD 四个第 0 代对象分配了空间, 如果创建一个新的对象导致第 0 代容量超过预算时, CLR 会进行 GC.
A0 | B0 | C0 (不可达) | D0 |
---|
GC 后的堆如下图, ABD 三个对象提升为第 1 代对象, 此时无第 0 代对象
A1 | B1 | D1 |
---|
假设程序继续执行到某个时刻时, 托管堆如下, 其中 FGHIJ 为第 0 代对象
A1 | B1 | D1 (不可达) | F0 | G0 (不可达) | H0 | I0 | J0 |
---|
根据 GC 假设的前两条可知, 它会优先检查第 0 代对象, 那么 GC 第 0 代回收后的托管堆如下, FHIJ 提升为第 1 代对象
A1 | B1 | D1 (不可达) | F1 | H1 | I1 | J1 |
---|
随着第 1 代的增加, GC 会发现其占用了太多内存, 所以会同时检查第 0 代和第 1 代对象, 如某个时刻的托管堆如下, 其中 K 为第 0 代对象
A1 | B1 | D1 (不可达) | F1 | H1 (不可达) | I1 | J1 | K0 |
---|
GC 第 1 代回收后的托管堆如下, 其中 ABFIJ 都为第 2 代对象, K 为第 1 代对象.
A2 | B2 | F2 | I2 | J2 | K1 |
---|
还有一些额外的规则需要注意:
在进行第 1 代回收之前, 一般都已经对第 0 代对象回收了好几次了.
如果对象提升到了第 2 代, 它会长期保持存活, 基本上只有当 GC 进行完全垃圾回收 (包括 0,1,2 代的对象) 时才会进行回收.
如果 GC 回收第 0 代时发现回收了大量内存, 则会缩减第 0 代的预算, 这意味着 GC 更频繁, 但做的事情也减少了; 反之, 如果发现没有多少内存被回收, 就会增大第 0 代的预算, 这意味着 GC 次数更少, 但每次回收的内存相对要多. 对于第 1 代和第 2 代对象来说, 也是如此.
如果回收后发现仍然没有得到足够的内存且无法增大预算, GC 就会执行一次完全垃圾回收, 如果还不够, 就会抛出
OutOfMemoryException
异常.
四, 何时进行垃圾回收
应用程序 new 一个对象时, CLR 发现没有足够的第 0 代对象预算来分配该对象时
代码显式调用
System.GC.Collect()
方法时. 注意不要滥用该方法
Windows 报告低内存情况时
CLR 正在卸载 AppDomain 时. 会回收该 AppDomain 的所有代对象
CLR 正在关闭时. CLR 在进程正常终止 (而不是通过任务管理器等外部终止) 时关闭, 会回收进程中的所有对象.
五, 垃圾回收模式
CLR 启动时, 会选择一个 GC 主模式, 该模式不会更改, 直到进程终止.
工作站: 默认的, 针对客户端应用程序进行优化. GC 造成的时延很低, 不会导致 UI 线程出现明显的假死状态
服务器: 针对服务器端应用程序进行优化, 主要是优化吞吐量和资源利用.
可以在配置文件中告诉 CLR 使用服务器回收模式:
- <configuration>
- <runtime>
- <gcServer enabled="true"/>
- </runtime>
- </configuration>
另外, GC 还支持两种子模式: 并发 (默认) 和非并发. 主要区别在于并发模式中 GC 有一个额外的后台线程, 它能在应用程序运行时并发标记对象. 可以在配置文件中告诉 CLR 不要使用并发回收模式:
- <configuration>
- <runtime>
- <gcConcurrent enabled="false"/>
- </runtime>
- </configuration>
当然, 你也可以通过 GCSetting 类的 GCLatencyMode 属性对垃圾回收进行某些控制(在你没有完全了解影响的情况下, 强烈建议不要更改):
模式 | 说明 |
---|---|
Batch | 关闭并发 GC,.net framework 版本服务器模式默认值 |
Interactive | 打开并发 GC,工作站模式与 .net core 版本服务器模式的默认值 |
LowLatency | 在短期的、时间敏感的操作中(如动画绘制)使用这个低延迟模式,该模式会尽力阻止第 2 代垃圾回收,因为花费时间较多,只有当内存过低时才会回收第 2 代。 |
SustainedLowLatency | 这个低延迟模式不会导致长时间的 GC 暂停,该模式会尽力阻止非并发 GC 线程对第 2 代垃圾回收(但是允许后台 GC 线程对其的回收),只有当内存过低时才会阻塞回收第 2 代,适用于需要迅速响应的应用程序(如股票等)。 |
另外, 还有一个模式叫做 NoGCRegion, 用于在程序执行关键路径时将 GC 线程挂起. 但是你不能将该值直接赋值给 GCLatencyMode 属性, 要通过调用 System.GC.TryStartGCRegion 方法才可以, 并调用 System.GC.EndGCRegion 方法结束.
六, 注意事项
静态字段引用的对象会一直存在, 直到用于加载类型的 AppDomain 卸载为止
由于碎片整理的开销相对较大, 因此 GC 在划算时才会进行碎片整理, 并非每次都会执行.
大对象始终为第 2 代, 而且目前版本 GC 不会压缩大对象, 因为移动代价过高.
第 0 代和第 1 代总是位于同一个内存段, 而第 2 代可能跨越多个内存段.
七, 特殊的 Finalize(终结器)
包含本机资源的类型被 GC 时, GC 会回收对象在托管堆中使用的内存. 但这样会造成本机资源的泄漏, 为了处理这种情况, CLR 提供了称为终结的机制 -- 允许对象在判定为垃圾之后, 但在对象内存被回收前执行一些代码. 在 C# 中的表示如下:
- class SomeType
- {
- // 这是一个 Finalize 方法
- ~SomeType() { }
- }
其生成的 IL 代码为:
可以看到, C# 编译器实际是在模块的元数据中生成了名为 Finalize 的 protected override 方法, 并且方法主体的代码被放置在 try 块中, 并在 finally 块中调用 base.Finalize(本例调用了 Object 的终结器).
那么, 终结的内部是如何工作的呢?
new 新对象时, 如果该对象的类型定义了 Finalize 方法, 那么在该类型的实例构造器被调用之前, 会将指向该对象的指针放到一个终结列表中, 该列表由 GC 内部控制.
当可终结对象被回收时, 会将引用从终结列表移动到 freachable 队列中, 该队列由 GC 内部控制.
CLR 会启用一个特殊的高优先级线程来专门调用 Finalze 方法. freachable 队列为空时, 该线程将睡眠; 但一旦队列中有记录项出现, 线程就会被唤醒, 将每一项都从 freachable 队列中移除, 并调用每个对象的 Finalize 方法.
如果类型的 Finalize 方法是从 System.Object 继承的, CLR 就不认为该对象是 "可终结" 的, 只有当类型重写了 Object 的 Finalize 方法时, 才会将类型及其派生类型的对象视为 "可终结" 的.
注意, 除非有必要, 否则应尽量避免定义终结器. 原因如下:
可终结对象在回收时, 必须保证存活, 这就可能导致其被提升为另一代, 生存期延长, 导致内存无法及时回收. 另外, 其内部引用的所有对象也必须保证都存活, 一些被认为是垃圾的对象在可终结对象回收后也无法直接回收, 直到下一次(甚至多次)GC 时才会被回收.
Finalize 方法在 GC 完成后才会执行, 而 GC 的执行时机无法控制, 也就导致该方法的执行时间也无法控制.
Finalize 方法中不要访问其他可终结对象, 因为 CLR 无法保证多个 Finalize 方法的执行顺序. 如果访问了已终结的对象, Finalize 方法抛出未处理的异常, 导致进程终止, 无法捕捉异常.
在实际项目开发中, 想要避免释放本机资源基本不可能, 但是我们可以通过规范代码来规避异常, 这就需要用到 IDisposable 接口了. 示例代码如下:
- public class MyResourceHog : IDisposable
- {
- // 标识资源是否已被释放
- private bool _hasDisposed = false;
- public void Dispose()
- {
- Dispose(true);
- // 阻止 GC 调用 Finalize
- GC.SuppressFinalize(this);
- }
- /// <summary>
- /// 如果类本身包含非托管资源, 才需要实现 Finalize
- /// </summary>
- ~MyResourceHog()
- {
- Dispose(false);
- }
- protected virtual void Dispose(bool isDisposing)
- {
- if (_hasDisposed) return;
- // 表明由 Dispose 调用
- if (isDisposing)
- {
- // 释放托管资源
- }
- // 释放非托管资源. 无论 Dispose 还是 Finalize 调用, 都应该释放非托管资源
- _hasDisposed = true;
- }
- }
- public class DerivedResourceHog : MyResourceHog
- {
- // 基类与继承类应该使用各自的标识, 防止子类设置为 true 时无法执行基类
- private bool _hasDisposed = false;
- protected override void Dispose(bool isDisposing)
- {
- if (_hasDisposed) return;
- if (isDisposing)
- {
- // 释放托管资源
- }
- // 释放非托管资源
- base.Dispose(isDisposing);
- _hasDisposed = true;
- }
- }
来源: https://www.cnblogs.com/xiaoxiaotank/p/11193745.html