# 前言
本文包含原书第七章至第十一章,主要为 .NET 垃圾回收过程的详细描述
# 第七章 垃圾回收 - 简介
# 高层视图
.NET GC 的两种模式:
- 工作站模式:最大限度减少 GC 引入的延迟,GC 频率更高
- 非并发:GC 时应用程序所有托管线程都挂起
- 服务器模式:最大限度提高应用程序的吞吐量,GC 频率更低
- 并发:GC 的某些部分在托管现在还在工作时执行
# GC 过程示例
垃圾回收是在特定代的上下文中发生,该代称为被判决的代。
- 可决定同时回收比当前被判决代更小的代
# 第 0 代被判决的垃圾回收:
- 仅检查第 0 代中对象的可达性
- 第 0 代变为空 (只有意保留非常小的空间),最小一代所有对象要么被回收,要么提升到更大的一代
- 第 0 代可达对象提升至第 1 代
- 第 1 代增长
- 第 2 代和 LOH 不变
# 第 1 代被判决的垃圾回收:
- 仅检查第 0 代和第 1 代中对象的可达性
- 第 0 代变为空
- 第 0 代的可达性对象被提升到第 1 代
- 第 1 代的可达性对象被提升到第 2 代
- 第 1 代可能会增长或缩小
- 第 2 代增长
- LOH 不变
从性能上讲,第 1 代被判决的垃圾回会有更多的对象被分析,不过在这两种情况下,GC 都在单个临时段内运行
# 第 2 代被判决 (完全 GC)
与前两种情况相比,这种完全 GC 需要分析更多对象,且标记阶段将分析整个托管堆:第 0、1、2 和 LOH ,并可能跨越多个段
- 所有代和 LOH 的所有对象的可达性都经检查
- 第 0 代变为空
- 第 0、1 代的可达对象相应提升到第 1、2 代
- 第 2 代可达对象继续留在第 2 代中
- LOH 在没有压缩的情况下被回收,空闲空间被 空闲列表记录以便重用
最后,第 2 代在段中增长后,可能大到导致第 0、1 代没有足够空间:
- 当前临时段被更改为只有第 2 代的段
- 创建一个新的临时段,将第 0 代的所有可达性对象都压缩过去 (作为第 1 代对象)
- 如果有一个已存在的且具有较小第 2 代的『只有第 2 代的段』也可作为新的临时段重新使用
- LOH 照常使用清除回收处理
完全垃圾回收与第 0、1 代被判决的临时垃圾回收之间的开销差异可能是几个数量级的,因此尽可能避免完全垃圾回收。
# GC 过程步骤
- 触发垃圾回收
- 挂起托管线程
- 用户线程启动 GC 代码
- 选择要判决的代
- 标记
- 计划
- 清除或压缩
- 恢复托管线程
# 垃圾回收数据
# 静态数据
表示 .NET 运行时初始设置的配置,以后将永远不会更改
- 最小大小:最小分配预算
- 最大大小:最大分配预算
- 碎片限制和碎片比率限制:在决定是否应该压缩时使用
- 限制和最大限制:用于计算代分配预算的增长
- 时间限制:指定开始回收代要达到的时间
- 时间时钟:指定开始回收代要达到的时间,以性能计数
- GC 时钟:指定开始回收代要达到的 GC 数量
注:
- SSIZE_T_MAX:(最大地址限制) 字长的一半
# 动态数据
表示托管堆当前状态,会在 GC 期间更新,计算各种决策所需的数据
- 分配预算:GC 希望在下一次 GC 之前用于新分配的大小
- 代表 GC 希望用于特定代分配的总大小
- 在回收该代的每个 GC 上,分配预算会动态更改,主要基于该代的存活率
- 新分配:在当前分配预算下,在下一次 GC 之前剩余分配空间的大小
- 对于分配器和 GC 的合作至关重要,跟踪一代中相对于分配预算进行了多少分配,如果为负数,则意味已经超出分配预算,将为该代触发垃圾回收
- 碎片:该代中空闲对象消耗的总大小
- 存活的大小:存活对象占用的总大小
- 存活固定的大小:存活固定插头占用的总大小
- 存活率:存活字节数除以总字节数的比率
- 当前大小:GC 发生后所有对象的总大小 (不包括碎片部分)
- GC 时钟:回收这一代 GC 的数量
- 时间时钟:回收这一代的上一次 GC 开始时间
注:代只是被简单创建为空,跟它们默认大小无关,随着对象被分配和提升,它们大小会根据分配预算而增加。
# 回收触发器
# 分配触发器
如果无法为正在创建的对象找到合适的空间,小对象堆分配器和大对象堆分配器都可能触发垃圾回收。
- 小对象分配 (AllocSmall):在对象分配期间,第 0 代预算已用完 (最常见),在第 0 代分配预算超出的情况下触发
- 大对象分配 (AllocLarge):在大对象分配期间,LOH 的预算已经用完
- 慢速路径上的小对象分配 (OutOfSpaceSOH)
- 慢速路径上的大对象分配 (OutOfSpaceLOH)
如果没有分配,则不会发生这种触发。
# 显式触发器
显示调用相关 API:
GC.Collect()
及其重载GC.AddMemoryPresure
(非托管内存)
调用 GC.Collect 很少是合理的,显式调用会干扰内部启发式垃圾回收算法,破坏 GC 的自我调整。
# 低内存级别系统触发器
操作系统发现内存不足,.NET 接收到信号后触发
# 各种内部触发器
- AppDomain 卸载
- 清理表示为死线程的线程对象 (每个线程由一个托管对象表示)
- 在启动 NoGC 区域之前 (15 章)
# EE 挂起
挂起用户代码的所有线程的过程称为『EE 挂起』
- 即执行引擎挂起,意为挂起托管线程
- 从应用程序角度看,整个世界都是在垃圾回收时暂停的
安全点是一个可以检查寄存器和堆栈位置的实时引用的代码位置,安全点的实现并非易事,挂起也必须是非常高效的。
- 如果一个线程被挂起在安全点之外 (这很可能),那么当前堆栈帧的返回地址将被操作到一个特殊的存根,这个根将把它『停』在一个安全点上。
注:非托管线程不会挂起和重启,若创建一个后台本机线程执行,则它将独立于 EE 挂起运行 (当然 P/Invoke 机制肯定会从非托管代码返回托管代码时阻塞)
# 第八章 垃圾回收 - 标记阶段
在非并发 GC 的情况下,一开始所有线程均会挂起,托管堆以此确保自己不会发生任何变化,保持 GC 的独占性,以安全地浏览堆,从中搜寻出所有可达对象。
# 对象的遍历与标记
对于一个特定的根地址,遍历程序将执行以下步骤:
- 将其转换为一个托管对象的确切地址:如果根地址是个内部指针 (表示它并不指向托管对象的开头,而是指向对象内部的某个位置),则需要进行此转换
- 设置固定标记:如果一个对象被固定,则在对象头中设置一个合适的位
- 开始遍历对象的引用:由于有存储于 MethodTable 中的类型信息,GC 可以知道哪些偏移位置 (字段) 代表传出的引用
- GC 以深度优先方式访问所有这些引用并维护到一个集合,称为标记堆栈
- 已访问对象将被跳过
- 尚未访问对象将被标记:通过在 MethodTable 指针中设置一个位来完成 (此操作不会破坏 MT 指针,其至少有两个未被使用的最低位)
- 添加传出引用到标记堆栈集合中
- 当标记堆栈中不再有尚未访问的对象时,遍历操作即完成
- 注:使用自己实现的标记堆栈而不是递归,是为了避免堆栈溢出
- GC 以深度优先方式访问所有这些引用并维护到一个集合,称为标记堆栈
GC 根是 .NET 内存管理中最有用的部分,根包含可达对象的整个图并可能导致:
- 占用大量内存
- 内存泄露
标记机制始于各种不同类型的根并随之逐渐构建出包含所有可达对象的完整对象图。
注:暂存字符串和静态引用数据采用了和其它对象完全一致的标记机制。
# 局部变量根
- 局部变量存储:可以存储在堆栈或 CPU 寄存器中
- 堆栈根:既可能位于堆栈中,也可能位于 CPU 寄存器中
- 词法作用域:定义了变量可见代码区域
- 存活堆栈根与词法作用域:在 Debug 编译模式下,JIT 编译器将所有局部变量的可达性延长至方法结束 (Release 将进行更多优化)
- 带有渐进式根回收的存活堆栈根:Release 模式下 JIT 将使用激进式根回收,不需要通过将局部变量设置为 null 来『通知』GC 对象不再使用,通过激进式根回收,编译器和 JIT 可以完美确定变量的实际使用范围。(编译器会优化掉冗余的 null 赋值语句)
- 由于编译器会尽量最短化局部变量的生存周期,可能会进行方法『其中一部分 (调用了实例本身的语句)』的代码内联化 (不至于影响代码逻辑的情况),以使对象可以更早被回收
- GC.KeepAlive:不包含任何代码,而是使用 MethodImplOptions.NoInLining 选项为方法添加一个特性,使对象不被内联,传入参数将被视为可达
# GC 信息
GC 信息实际上是非常紧密的二进制数据,当前唯一可以查看 GC 信息的工具是带有 SOS 扩展的 WinDbg
# 固定局部变量
固定局部变量是一种特殊类型的局部变量,在 C# 中使用 fixed 关键字可以显式创建
- JIT 编译器会针对固定局部变量生成适当的 GC 信息,有关根本身的信息也被保持为固定状态。
- 这种被固定的根只在很短时间内可见 (仅在包含它的方法执行期间)
# 堆栈根扫描
当所有线程在安全点挂起时,可以从 GC 信息中解码出存在哪些存活插槽,每个这样的插槽 (无论位于堆栈或寄存器中) 都被视为根并从它们开始执行标记遍历操作。
# 终结根
终结器对象第一次被回收时,放入终结器队列,会成为终结器根?
# GC 内部根
例如卡根扫描
# GC 句柄根
句柄有各种不同类型,全都存储在一个全局句柄表映射中。扫描句柄表后,扫描到的一组句柄类型与它们指向的目标都将被视为根。
- 强句柄 (GCHandle.Alloc):类似普通引用
- 固定句柄:强句柄的子类别
# 处理内存泄露
持续增长的内存使用率和内存泄露一定不会是因为垃圾回收器无法正常识别对象是否处于可达状态
- 而很可能是因为有些对象持续持有对其它对象的引用
- 因此关键在于找出哪些根在持续持有应当回收但始终存活的对象
# 第九章 垃圾回收 - 计划阶段
标记阶段之后,所有对象都被标记为可达或不可达,那些可达对象将用一个专用位进行标记,某些被标记对象可能还要用额外一个位标记为固定的。此时,垃圾回收器已经有了启动其工作所需的所有信息。
计划阶段计算与压缩过程结果直接对应的所有信息,这些信息『在侧面』准备,并没有实际去移动对象。并让随后的清理和压缩阶段使用。
- 如进行『虚假的压缩』得到结果并评估是否值得压缩
注:源码中方法为 plan_phase (int),该阶段准备了所有必要数据,后续阶段只是以适当方式使用这些数据。
# 小对象堆
# 插头和间隙
通过将对象组织成 slot 和 gap,可以非常高效获得如下一整套信息:
- 压缩效率
- 如果是清除回收,应在哪里创建空闲列表
- 如果是压缩回收,将把可达对象移到哪里
不过,这些数据存储在哪里?
- .NET 重用了托管对象标头位置
如果我们适当地建立 slot 和 gap,那么每个 slot 在其之前都会具有其对应的 gap,而 gap 的内容可以安全覆盖:它只包含将不再使用的不可达对象。
- 这样的 slot 信息精确占用 24 字节 (32 位 12 字节)
- 它包含相应的间隙大小、slot 重定位偏移量及一些附加数据
最后可以构建一个包含所有插头地址的二叉插头树 (组织成二叉搜索树 - BST)
# 砖表
插头树的根需要存储在某个地方,但为整个托管堆创建一个巨大的插头树不切实际。
- 一种更实际的方法是为连续地址范围构建插头树
- 这样的范围在 CLR 中称为砖,砖大小为 2048B (32 位) 和 4096B (64 位)
- 砖存储在覆盖整个托管堆的砖表中
通过将砖表条目与与每个插头头的左 / 右偏移量组合,可以高效地表示插头树。
# 固定
如果一个对象被固定,则很可能是因为我们想把它的地址传递给非托管代码。
有两种固定源:
- 固定局部变量:fixed 关键字隐式创建的局部变量对象
- 固定句柄:通过固定句柄引用显式固定的对象 (GCHandle.Allocate)
因为要固定,所以实际上可能有三种对象组:
- 插头:表示一组已标记 (可达) 的对象
- 固定插头:表示一组被固定 (并因此标记) 的对象
- 间隙:表示一组未标记 (不可达) 对象
# 代边界
在清除或压缩后,代边界将相应地更改。
在计划阶段,内部分配器计算插头的新地址,也计算新的代边界。
- 所有这些操作都是在实际上没有移动任何对象的情况下完成的。
# 降级
固定对象可能会提升和降级。
# 大对象堆
事实上,LOH 中的计划阶段几乎不需要,因为它基本只是清除 (除非我们明确要求进行压缩)
- 仅在启用压缩时才需要大对象堆的压缩阶段
- LOH 是特殊的,因为它确保只有大对象活在其中,因此有一些简化
- 在每个插头前存储的信息仅包含插头的重定位偏移量
- 由于大对象堆内部也没有代,因此也无需重新计算代边界,也没有降级的可能性
与 SOH 相比,LOH 中的固定并没有区别,因此一样会引入可能碎片化的问题。
# 压缩决策
在计划阶段执行了复杂的计算后,GC 会决定是否值得压缩。大多数情况下,该决策基于碎片化级别。
决定压缩的原因:
- 这是抛出 OutOfMemoryException 之前最后一个完全 GC,GC 尽可能尝试回收内存
- 显式指定压缩 (GC.Collect 参数)
- 用完了临时段中的空间
- 代的碎片化程度较高
- 系统中物理内存负载较高
如上所述,决策中,代的碎片化阈值起到作用最重:
# 第十章 清除和压缩
尽管大部分计算已在之前完成,但从性能开销角度看,清除和压缩仍然是性能消耗最大的一个阶段,因为修改和移动内存中数据是最耗时的操作。
最典型的 GC 组合是:
- 执行 SOH 的压缩和 LOH 的清除,并在 SOH 压缩之前完成 LOH 的清除
# 清除阶段
清除回收很简单:所有不可达对象都被转换成空闲内存空间,即 GC 把所有或某些内存间隙转换成空闲列表项。
# 小对象堆
- 基于内存中的间隙创建空闲列表项
- 将每个尺寸大于两个最小对象的间隙创建一个空闲列表项并组织进一个空闲列表
- 尺寸更小的间隙将被视为未使用空闲空间
- 恢复已保存的前置和后置插头
- 完成其它统计工作以更新终结器队列并提升 (或降级) 适当类型的存活句柄
- 相应地重排段,如移除掉一些不再需要的段
# 大对象堆
清除操作将逐个扫描对象,并简单地在被标记对象之间创建空闲列表项,所有不再需要的 LOH 段将被删除 (或启用段重用后进行缓存)
# 压缩阶段
包含两个主要子步骤:移动 (或复制) 对象并将所有指向被移动对象的引用更新到对象所在的新位置。
注:压缩阶段比清除阶段复杂得多,可能导致大量内存操作
# 小对象堆
- 如果需要,则获取一个新的临时段
- 重定位引用
- 堆栈上的引用
- 存储在跨代记忆集中的对象内的引用
- SOH 和 LOH 中对象内的引用
- SOH:重定位操作大量使用砖和插头树,以快速将当前地址转换为新地址
- LOH:逐个扫描存活下来的 LOH 对象
- 前置和后置插头内的引用
- 某些对象的结尾可能被插头信息覆盖而损坏,其原始内存内容存储在固定插头队列的条目中
- 终结器队列中对象内的引用
- 句柄表中的引用:句柄需要更新其指针
- 压缩对象
- 复制对象:使用计算出的重定位偏移量逐个 slot 完成复制
- 采用滑动压缩,总是首先复制低位内存空间,只要复制的单位足够小,就不会产生覆盖问题
- 恢复前置和后置插头信息:从存储于固定插头队列条目内的副本中恢复对象的损坏部分
- 复制对象:使用计算出的重定位偏移量逐个 slot 完成复制
- 修复代边界
- 若需要,删除或反提交段
- 创建空闲列表项
- 提升根
# 大对象堆
压缩大对象堆与小对象堆类似,不过实现更简单
# 第十一章 GC 风格
# 模式概述
# 工作站与服务器模式
工作站模式
- GC 将会更频繁发生
- 作为上一点副作用:内存使用率将更低
- 只有一个托管堆
- 段更小
服务器模式
- GC 的发生频率将更低
- 作为上一点副作用:内存使用率见更高
- 有多个托管堆
- 默认段大小更大
- 因此,服务器模式将消耗更多内存,但带来更小的 Time in GC 值
# 并发模式与非并非模式
这两种模式在工作站与服务器模式下都支持。
非并发模式
- 在 GC 期间,所有托管用户线程都将被挂起。执行完毕后恢复。
并发模式
- 并发 GC 在普通用户线程工作时运行,用户线程和回收器在工作期间必须进行额外同步。
# 模式配置
# .NET Framework
- ASP.NET WEB 应用程序:web.config
- 控制台应用程序或 Windows 服务:[应用程序名].exe.config
# .NET Core
文件配置方式与 .NET Framework 相似
并引入了 配置旋钮 概念,可通过多种方式提供值。
# GC 停顿和开销
# 模式模式
# 非并发工作站模式
即典型的 GC
- 整个 GC 期间,所有托管线程都将挂起
- GC 代码在触发回收的用户线程上执行 (从分配器内部)
- GC 总是在『停止世界』阶段执行
# 并发工作站模式 (4.0 版本之前)
- 有一个专用于 GC 目的的额外线程 (多数时间挂起等待)
- 临时回收总是非并发
- 完全 GC 可以两种模式执行
- 并发 GC
- 非并发 GC
- 并发完全 GC 附加特性:
- 用户托管线程可能在其工作期间分配对象,此类分配仅限于临时段大小 (如果用完了将挂起,直到 GC 结束)
- 包含两个短的『停止世界』阶段 (开始和中间)
- 从 GC 开始到第二个『停止世界』阶段之前分配的对象将被提升
- 在第二个『停止世界』阶段之后分配的所有事物都将被提升
# 后台工作站模式
.NET Framework4.0 后取代并发工作站模式
主要改进在于:即使在并发 GC 期间,如果需要也可以触发临时 GC
# 并发标记
标记一个对象意味着在 MethodTable 中设置一个位,完成后恢复。
但并发工作时意味着线程可能正在使用它,因此不能这样干。
- 并发标记会将有关标记的信息存储在一个专用的单独的标记数组中,组织结构类似于卡表
# 非并发服务器模式
默认情况下,托管堆的数量与 CPU 逻辑内核一样多
- 有专用于 GC 的线程,默认与托管堆数量相同
- 所有回收都是非并发 GC
- 标记是从多个 GC 线程并行完成
# 后台服务器模式
最复杂的 GC,也是最消耗资源的 GC
- 每个托管堆都有两个专用于 GC 目的的线程
- 服务器 GC 线程
- 后台 GC 线程
- 临时回收是非并发 GC
- 完全 GC 可能以一以下两种模式执行:
- 非并发 GC
- 后台 GC
- 后台完全 GC 还有以下额外特征:
- 用户托管线程能够在其工作期间分配对象,这些分配可以触发临时回收 (前台 GC)
- 在后台 GC 期间,前台 GC 可能会多次发生
- 包含两个短暂的『停止世界』阶段 (GC 开始和中间)
# 延迟模式
- 批处理模式
- 交互式模式
- 持续低延迟模式
- 无 GC 区域模式
- 延迟优化目标
# 选择 GC 风格
注:并发 (后台) 版本 GC 的托管堆会更大,频繁的非压缩后台 GC 会导致更严重的碎片化。