1. 基础知识
内存的基础知识:
- 每个进程都有自己的虚拟地址空间,开发者与虚拟地址打交道
- 默认情况下,32 位计算机中每个进程的虚拟地址空间为 2GB
- 虚拟内存可能存在以下 3 种状态:
- Free: 没有对这块内存的引用,此内存块可以被分配
- Reserved:内存块可以被你使用,但是不能接受其他分配请求了。在 commit 之后,你可以真正在此内存块存储数据
- Committed:内存块被分配了物理内存
- 虚拟内存空间会存在碎片
1.1 内存分配
初始化进程时,运行时会为进程保留一段连续的地址空间。这段保留的地址空间成为 managed heap。Managed heap 维护一个指针,该指针指向可在堆中分配的下一个对象的地址。一开始,这个指针指向堆的 base address。所有的引用类型都会被分配到 managed heap。当应用创建第一个引用类型时,对象分配到 base address。当应用创建下一个对象时,GC 紧随第一个对象在地址空间为这个新对象分配内存。只要地址空间还足够,GC 就会像这样依次分配内存空间。
在 managed heap 中分配内存比在 unmanaged memory 中分配速度更快。这是因为,运行时为对象分配内存时仅仅是在指针上加一个数字,这几乎和在栈上分配内存一样快。并且,由于连续分配的对象在 managed heap 上连续存储,所以程序可以快速访问这些对象。
1.2 内存释放
垃圾回收器的优化引擎根据所执行的分配决定执行回收的最佳时间。 垃圾回收器在执行回收时,会释放应用程序不再使用的对象的内存。 它通过检查应用程序的 root 来确定不再使用的对象。 应用程序的 root 包含线程堆栈上的静态字段、局部变量、CPU 寄存器、GC 句柄和终结队列 (finalize queue)。 每个 root 要么引用托管堆中的对象,要么被设置为空。 垃圾回收器可以为这些 root 请求其余运行时。 垃圾回收器使用此列表创建一个图,其中包含所有可从这些 root 中访问的对象。
不在 graph 中的对象是不可达对象,GC 将不可达对象视为垃圾并释放这部分内存。在垃圾回收期间,GC 会检测 managed heap 找到被不可达对象占用的地址空间,然后使用内存复制函数将可达对象的空间进行压缩,从而释放不可达对象占用的空间。此过程中,需要更新指向可达对象的指针为新的位置。同时,更新 managed heap 指针为当前内存中最后一个可达对象之后的地址。
只有在回收发现大量的无法访问的对象时,才会压缩内存。 如果托管堆中的所有对象均未被回收,则不需要压缩内存。
为了提高性能,runtime 将大对象在单独的堆上分配。垃圾回收器会自动释放大型对象的内存。 但是,为了避免移动内存中的大型对象,通常不会压缩此内存。
1.3 垃圾回收的触发条件
- 系统物理内存不足。
- 由
managed heap上已分配的对象使用的内存超出了可接受的阈值。 随着进程的运行,此阈值会不断地进行调整。 - 调用
GC.Collect方法。 一般来说几乎不会遇到需要调用此方法的情况,因为垃圾回收器会持续运行。 此方法主要用于特殊情况和测试。
1.4 The Managed Heap
在垃圾回收器被 CLR 初始化之后,它会分配一个内存段(segment of memory)来管理和保存对象。这部分内存被称为 managed heap,与 OS 的 native heap 相对。
每个进程都有一个 managed heap,一个进程的所有线程在该进程的同一个堆上分配对象。
Managed heap 主要由 large object heap 和 small object heap 组成。Large object heap 详细介绍参见下文相关内容。
1.5 Genarations
垃圾回收算法的实现基于以下几点考虑:
- 压缩 managed heap 的部分内存比压缩 managed heap 的所有内存更快。
- 较新对象的生存时间更短;较老对象接下来一般会存活更长的时间。
- 较新的对象趋向于相互关联,并且这些关联的对象倾向于几乎同时被应用程序访问。
垃圾回收主要发生在回收短期对象的时候。为了优化垃圾回收器性能,managed heap 被分为 generation 0,1,2 三代,于是短期存活对象和长期存活对象可以被区别对待。垃圾回收器在 generation 0 存储新分配的对象。在应用程序生存期的早期创建的对象如果未被回收,则被升级并存储在 generation 1 和 generation 2 中。 因为压缩 managed heap 的部分内存比压缩 managed heap 的所有内存更快,所以此方案允许垃圾回收器在每次执行回收时释放特定 generation 的内存,而不是整个托管堆的内存。
Geneartion 0。包含短期存活对象。新分配的对象构成新一代对象,并隐式成为 generation 0 的集合。如果是大型对象,会在 LOH(Large Object Heap) 上分配,有时这也被称为 generation3 (和 generation 2 一起执行垃圾回收)。
如果在 generation 0 已满时尝试分配新对象,垃圾回收器会对 generation 0 进行垃圾回收,在此过程中存活的对象将被提升至 generation 1。值得注意的是,大多数对象会在 generation 被回收,而不会存活到下一代。单独回收 generation 0 通常可以回收足够的内存,这样,应用程序便可以继续创建新对象。如果内存依然不足,可以依次回收 generation 1 和 generation2。
Generation 1。包含短期存活对象,该代作为 generation 0 和 generation 2 的缓存。对 generation 1 进行垃圾回收存活下来的对象将被提升至 generation 2。
Generation 2。包含长期存活对象。对 generation 2 进行垃圾回收存活下来的对象依然在 generation 2。
当条件满足时,垃圾回收将在特定代上发生。 回收某个代意味着回收此代中的对象及其所有更年轻的代。 第 2 代垃圾回收也称为完整垃圾回收(Full GC),因为它回收所有代中的对象(即,托管堆中的所有对象)。
当垃圾回收器检测到某代中的对象存活率很高时,它会增加该代的分配阈值。 下次回收将获得更大的内存。 CLR 在以下两个要素之间进行权衡:
- 不允许通过延迟垃圾回收,让进程的取过大内存
- 垃圾回收不要过于频繁地行
因为第 0 代和第 1 代中的对象的生存期较短,因此,这些代被称为“暂时代” (Ephemeral Generations)。
暂时代在称为“暂时段”(Ephemeral Segment)的内存段中进行分配。 垃圾回收器获取的每个新段将成为新的暂时段,并包含在第 0 代垃圾回收中幸存的对象。 旧的暂时段将成为新的第 2 代段。
根据系统为 32 位还是 64 位以及它正在哪种类型的垃圾回收器(工作站或服务器 GC)上运行,暂时段的大小发生相应变化。 下表显示了暂时段的默认大小。
| 工作站/服务器 GC | 32 位 | 64 位 |
|---|---|---|
| 工作站 GC | 16 MB | 256 MB |
| 服务器 GC | 64 MB | 4 GB |
| 服务器 GC(具有 4 个以上的逻辑 CPU) | 32 MB | 2 GB |
| 服务器 GC(具有 8 个以上的逻辑 CPU) | 16 MB | 1 GB |
暂时段可以包含第 2 代对象。 第 2 代对象可使用多个段(只要进程请求段且被OS允许)。
从暂时垃圾回收中释放的内存量限制为暂时段的大小。 释放的内存量与死对象占用的空间成比例。
1.6 垃圾回收过程中发生了什么?
垃圾回收包含以下步骤:
标记阶段(Marking phase),找到并创建所有可达对象的列表。
重定位阶段(Relocating phase),用于更新对将要压缩的对象的引用。
压缩阶段(Compacting phase),用于回收由不可达对象占用的空间,并压缩存活下来对象。压缩阶段将垃圾回收中幸存下来的对象移至段中时间较早的一端。
因为第 2 代回收可以占用多个段,所以可以将已提升到第 2 代中的对象移动到时间较早的段中。 可以将第 1 代存活对象和第 2 代存活对象都移动到不同的段,因为它们已被提升到第 2 代。
通常,由于复制大型对象会造成性能代偿,因此不会压缩大型对象堆 (LOH)。 但是,在 .NET Core 和 .NET Framework 4.5.1 及更高版本中,可以根据需要使用 GCSettings.LargeObjectHeapCompactionMode 属性按需压缩大型对象堆。 此外,当通过指定以下任一项设置硬限制时,将自动压缩 LOH:- 针对容器的内存限制。
- GCHeapHardLimit 或 GCHeapHardLimitPercent 运行时配置选项。
垃圾回收器通过一下信息决定对象是否可达:
- Stack root。
- GC handles。
- Static data。
2. Large Object Heap on Windows
.net GC 将对象分为 small 和 large 两种。压缩大对象——从内存的一处复制到另一处代价是很昂贵的,因此大型对象被放在 Large Object Heap (LOH) 上。
2.1 LOH 上的对象
CLR 被载入时,GC 分配两个初始的 heap segments :SOH 和 LOH。如果对象的大小大于或等于 85,000 字节,将被视为大型对象。 这个阈值可以根绝调优需要进行调整。 对象分配请求为 85,000 字节或更大时,runtime 会将其分配到LOH。
用户代码只能在 generation 0 和 LOH 中分配对象,只有 GC 可以为 generation 1&2 分配对象(通过提升存活对象实现)。
.NET Core 和 .NET Framework(从 .NET Framework 4.5.1 开始)包括 GCSettings.LargeObjectHeapCompactionMode 属性,该属性可让用户指定在下一 full GC 的时候压缩 LOH。
2.2 LOH 对程序性能的意义
LOH 在以下 3 个方面影响程序性能:
- 分配成本 :CLR 确保为每个对象分配的内存都会被清除。这意味着大型对象的分配成本主要由内存清理主导。清除一个字节需要 2 个周期,那么清除一个最小的大型对象(8500字节)就需要 17000 个周期。由此计算,在一台 2GHz 的机器上清除 16MB 对象需要 16ms,如果没有 LOH,这是一项巨大的成本。
- 回收成本 :由于 LOH 和 generation 2 一起回收,所以二者任一个的阈值被超过都会触发 GC。如果 generation 2 是由于 LOH 而被触发 GC,generation 2 在 GC 完成后不一定会小很多(也就是说,不一定有很很多对象能被回收);这种情况下,如果 generation 2 比较小,影响不大,但是如果此时 generation 2 很大,就会花很多时间在 generation 2 的 GC 上,而这种情况下 GC 实际上是 LOH 触发的,在 generation 2 的 GC 可能会做很多无用功。另外,当很多大型对象同时需要分配时,如果没有 LOH,那么你将会有一个巨大的 SOH,因此会有很多时间浪费在 GC 上。除此之外,如果连续分配并且释放大型对象,那么分配成本可能会增加。
- 具有引用类型的数组元素 :LOH 上的特大型对象通常是数组(很少会有非常大的实例对象)。 如果数组的元素有丰富的引用,则可能产生更大的成本;如果元素没有丰富的引用,将不会产生此类成本。 比如,在数组中存储树的结点,每个节点都包含子节点的引用,这种开销有时会比较大。
3. 小结
本文主要介绍了 .net GC 的基础知识,后续更多内容持续更新中。