【.Net技术栈梳理】02-核心框架与运行时(GC管理)

发布于:2025-09-09 ⋅ 阅读:(18) ⋅ 点赞:(0)

1 内存管理(垃圾回收GC)

.NET 的垃圾回收器(Garbage Collector, GC)是 .NET 内存管理的核心,理解其工作原理对于编写高性能、高稳定性的应用程序至关重要。

1.1 GC核心目标和基本概念

  • 目标

GC的存在主要是为了自动化内存管理,解决两个手动内存管理的经典难题:

  1. 内存泄露:忘记释放不再使用的内存
  2. 悬空指针:释放了仍在使用的内存
  • 基本假设:代际假说

GC的设计基于一个对软件行为观察得出的强大假说----代际假说

  • 对象越新,其生命周期越短。例如:在方法内部创建的局部变量很可能在方法结束后就无人引用了。
  • 对象越老,其生命周期越长。例如:全局的单例对象、缓存等,可能会从程序开始一直存活到结束。

这个假说经过长期验证,在绝大多数程序中都非常准确,是.NET GC分代收集的基础

  • 关键概念:托管堆

.NET中的对象实例(new关键字创建的引用类型对象)分配在托管堆上。这与C++的malloc或new在原生堆上分配有本质区别。托管堆是由CLR直接管理的一块连续内存区域,GC负责其分配和回收

1.2 GC的工作原理与工作方式

1.分代收集
基于代际假说。.NET GC 将托管堆上的对象分为三代:

  • 第0代:新创建的对象。这一代的大小通常很小(几百KB到及MB),大多数对象在第0代就会被回收。一次专注于第0代的回收速度非常快。

  • 第1代:从第0代回收中“幸存”下来的对象。它可以看作是第0代合第2代之间的缓冲区。其大小比第0代大,但是比第2代小。

  • 第2代:经历多次GC回收仍然存活的长生命周期对象。大型对象(>=85000字节)也会直接分配在大对象堆上,而LOH是按第2代来管理的。

工作流程

  1. 对象分配:新对象分配在第0代。如果第0代已满,则触发一次第0代GC
  2. 第0代GC
    • GC 开始标记:从根对象(静态字段、局部变量、CPU 寄存器等)开始,遍历所有可达的对象图。
    • 存活的对象压缩(移动到第 1 代区域的起始端,消除内存碎片),并更新所有对这些对象的引用地址。
    • 第 0 代被清空,准备接收新对象
  3. 第 1 代 GC:当第 1 代也被填满时,会触发一次 第 1 代 GC。这个过程与第 0 代类似,但会同时回收第 0 代和第 1 代。在第 1 代 GC 中幸存的对象会被提升到第 2 代。
  4. 第 2 代 GC:当第 2 代被填满时,会触发一次 Full GC,即回收所有三代(包括大对象堆)。这是最耗时、最昂贵的操作,因为它需要检查整个托管堆中的所有对象。

这种分代设计极大地优化了性能,因为 GC 大部分时间都在处理小而新的第 0 代,而很少去处理庞大而古老的第 2 代。

2.标记与压缩
这是 GC 回收过程的核心步骤:

  • 标记:GC 遍历所有“根”,找出所有仍然被引用的存活对象,并标记它们。所有未被标记的对象就是“垃圾”。
  • 清除:回收垃圾对象占用的内存。
  • 压缩:(并非所有 GC 都触发) 为了消除内存碎片,GC 会将存活的对象移动到一起,使其在内存中连续排列。这样,下一次分配新对象就可以快速地在空闲内存的末尾进行(通过简单的指针加法)。压缩后,所有对象的引用地址都会被更新。

3.GC 模式
.NET GC 提供了不同的模式来适应不同特点的应用(如客户端GUI应用 vs. 服务器后端服务)。

  • 工作站 GC

    • 设计目标:低延迟,与用户交互的应用程序(如 WPF, WinForms)。保证 GC 不会“冻结”UI 过长时间。
    • 特点:GC 在触发它的用户线程上运行,默认是并发的(后台进行一个并发的第 2 代回收,尽量减少对主线程的阻塞)。
  • 服务器 GC

    • 设计目标:高吞吐量,服务器端应用程序(如 ASP.NET Core Web API)。
    • ** 特点**:
      • 为每个逻辑 CPU 核心创建一个独立的托管堆和一个专门的 GC 线程。
      • 所有 GC 线程并行工作,可以更快地完成一次完整的 GC。
      • 消耗更多内存(每个堆都需要预留空间),但吞吐量极高。
    • 通常在 *.csproj 文件或 runtimeconfig.json 中显式配置:

    <PropertyGroup> <ServerGarbageCollection>true</ServerGarbageCollection> </PropertyGroup>

  • 后台 GC:

    • 这是工作站 GC 的增强特性(.NET Framework 4.0+, .NET Core 始终启用)。
    • 允许在进行第 2 代 GC 的同时,仍然可以处理第 0 代和第 1 代的 GC 请求。这极大地减少了长时间的 Full GC 造成的阻塞。

1.3 如何更好地使用 GC

理解 GC 原理的最终目的是为了写出对 GC 更“友好”的代码,避免不必要的性能开销。

  1. 基本准则:减少压力

GC工作的频率和时长直接取决于你分配新对象的数量和频率。

  • 核心目标:减少不必要的对象分配,尤其是短命的、在第0代就会死亡的对象
  1. 具体实践
    1. 对象池化

      • 场景:对于创建成本高、生命周期短且频繁创建/销毁的对象(如 HttpClient? 实际上 HttpClient 应重用,但这里是另一个例子如数据库连接、特定自定义对象)。
      • 做法:使用 Microsoft.Extensions.ObjectPool 或自定义池。不从池中 new 新对象,而是从池中借用一个已存在的对象,用完后归还。这完全避免了 GC 开销。
      • 实例:ArrayPool< T> 用于租赁和归还数组,是池化的经典应用
    2. 使用值类型

      • 场景: small, short-lived data structures.
      • 做法:使用 struct 而不是 class。值类型分配在栈上(或作为引用类型的一部分内联在堆上),方法结束时栈自动清理,无需 GC 介入。
      • 注意:不要滥用。值类型有装箱拆箱开销和复制语义,适用于小尺寸(通常小于 16 字节)、不可变、生命周期很短的情况。
    3. 避免大对象

      • 任何 >= 85,000 字节的对象会直接进入大对象堆,由第 2 代管理。LOH 的回收成本高且不压缩,容易产生碎片。应尽量避免创建大型对象(如大数组、大字符串)。
    4. 及时释放非托管资源

      • GC 只管理托管内存。对于文件句柄、数据库连接、网络套接字等非托管资源,GC 无能为力。
      • 必须实现 IDisposable 接口,并在 using 语句或 try/finally 块中调用 Dispose() 方法以确保资源被及时释放。
      • 模式:Dispose Pattern。
    5. 避免不必要的终结器

      • 终结器是 GC 在回收对象时,如果该对象有终结器,则不会立即回收,而是将其放入一个队列,由另一个线程调用其终结器,这会导致对象晋升到下一代,并延迟实际的内存回收,极大地增加 GC 压力。
      • 只有在你直接持有非托管资源时才需要实现终结器,作为一道安全网(如果开发者忘了调用 Dispose)。对于绝大多数包装了非托管资源的类,应使用 SafeHandle 的派生类,它已经为你正确地实现了终结器。
    6. 谨慎处理事件和委托

      • 事件处理程序会形成强引用。如果一个长生命周期对象订阅了一个短生命周期对象的事件,会导致短生命周期对象无法被回收(相当于被长生命周期对象引用着)。
      • 记得在不需要时取消订阅,否则会造成内存泄漏。
    7. 使用合适的集合和容量

      • 像 List< T>, Dictionary<TKey, TValue> 这样的集合在内部使用数组。如果预先知道大致容量,应在构造函数中指定初始容量,避免内部数组频繁扩容和复制,从而减少垃圾产生。
    8. 使用性能分析工具

      • Visual Studio Diagnostic Tools:其中的内存分析器可以帮你查看内存分配、存活的对象、找出内存泄漏的根源
      • PerfView:强大的底层性能分析工具,可以深入分析 GC 事件、停顿时间、各代回收频率等
      • dotTrace / JetBrains Rider:提供出色的内存和性能分析功能。

总结
.NET 的 GC 是一个高度工程化的复杂系统,它通过分代收集、标记-压缩和多种工作模式,在自动化、性能和延迟之间取得了出色的平衡。

  • 理解其原理:知道分代、堆的结构、GC 触发时机。
  • 减少其工作量:核心是减少不必要的、尤其是短命的托管对象分配(池化、值类型、控制集合大小)。
  • 管理好非托管资源:严格遵循 IDisposable 模式。
  • 使用工具验证:不要猜测,用性能分析工具来定位真实的内存问题。

遵循这些原则,就能写出对 GC 友好、内存高效且性能卓越的 .NET 应用程序。


.NET 垃圾回收器中各代的大小并非固定不变,而是一个复杂的、由CLR动态调整的参数。它的界定方式是其高性能设计的核心所在

第 0 代、第 1 代和第 2 代的大小不是固定的。它们由 CLR 的垃圾回收器根据应用程序的分配行为、负载和运行模式(工作站 vs. 服务器)动态调整

由于大小是动态的,我们无法通过代码获取一个精确的、永恒不变的值。但我们可以通过诊断工具在特定时刻实时观察它们。

  • 方法 1:使用 Visual Studio Diagnostic Tools
  1. 在 Visual Studio 中运行你的应用程序。
  2. 点击 Debug > Windows > Show Diagnostic Tools。
  3. 在 Diagnostic Tools 窗口中,选择 Memory Usage 标签页。
  4. 点击 Take Snapshot 按钮捕获一个内存快照。
  5. 在快照详情中,你可以看到 Heap Size,它会详细列出第 0/1/2 代和大对象堆的当前大小。
  • 方法 2:使用 GC.GetGCMemoryInfo API (.NET 5+)
    这是一个编程接口,可以获取当前GC的内存信息,包括各代的大小
GCMemoryInfo gcInfo = GC.GetGCMemoryInfo();

// 注意:这里获取的是‘Generation’的大小,不是‘代’的预算大小,但紧密相关。
// 它反映了上次GC后,该代中存活对象占用的空间,可以近似代表代的大小。
long gen0Size = gcInfo.GenerationInfo[0].SizeAfterBytes;
long gen1Size = gcInfo.GenerationInfo[1].SizeAfterBytes;
long gen2Size = gcInfo.GenerationInfo[2].SizeAfterBytes;
long lohSize = gcInfo.GenerationInfo[3].SizeAfterBytes; // LOH 是 generation 3

Console.WriteLine($"Gen0: {gen0Size / 1024} KB");
Console.WriteLine($"Gen1: {gen1Size / 1024} KB");
Console.WriteLine($"Gen2: {gen2Size / 1024} KB");
Console.WriteLine($"LOH: {lohSize / 1024} KB");

注意:这个API返回的是更底层的细节,SizeAfterBytes 表示的是上次GC后该代中存活对象的大小,这个值会非常接近GC为该代设定的“预算”大小。

  • 方法 3:使用 PerfView 等高级分析器
    PerfView 可以捕获GC事件的所有细节,包括每次GC前后各代的大小变化,是进行深度GC性能分析的终极工具。

思考各代大小时,应记住它们是动态的性能调优参数,而不是静态的配置值。GC 的智能之处就在于它替你完成了绝大部分复杂的内存调整工作。开发者的任务则是通过减少不必要的分配(尤其是短命对象)来配合 GC 的工作。



网站公告

今日签到

点亮在社区的每一天
去签到