.NET内存性能分析指南

Posted dotNET跨平台

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了.NET内存性能分析指南相关的知识,希望对你有一定的参考价值。

.NET Memory Performance Analysis

知道什么时候该担心,以及在需要担心的时候该怎么做

译者注

作者信息:Maoni Stephens - 微软架构师,负责.NET Runtime GC设计与实现 博客链接 Github

译者:Bing Translator、INCerry 博客链接:https://incerry.cnblogs.com 联系邮箱:incerry@foxmail.com

本文已获得Maoni大佬授权,另外感谢@晓青、@贾佬、@黑洞、@晓晨、@一线码农 在百忙之中抽出时间校对和提出修改建议。

本文Github仓库:https://github.com/InCerryGit/mem-doc/blob/master/doc/.NETMemoryPerformanceAnalysis.zh-CN.md

原文链接:https://github.com/Maoni0/mem-doc/blob/master/doc/.NETMemoryPerformanceAnalysis.md

本文90%通过机器翻译,另外10%译者按照自己的理解进行翻译,和原文相比有所删减,与原文并不是一一对应,但是意思基本一致。另外文章较长,还没有足够的时间完全校对好,后续还会对一些语句不通顺、模糊和错漏的地方进行补充,请关注文档版本号

文档版本号修订记录修订人修订日期
0.0.1翻译文档创建-2021-12-05
0.0.2人工校对,修复超链接错误-2021-12-16
0.0.3校对信息,修复样式问题,修复描述问题 thx @晓青 @Maoni-2021-12-17

译者水平有限,如果错漏欢迎批评指正

本文档的目的

本文旨在帮助.NET开发者,如何思考内存性能分析,并在需要时找到正确的方法来进行这种分析。在本文档中.NET的包括.NET Framework和.NET Core。为了在垃圾收集器和框架的其他部分获得最新的内存改进,我强烈建议你使用.NET Core,如果你还没有的话,因为那是应该尽快去升级的地方。

本文档的状态

这是一份正在完善的文档。现在,这份文档主要是在Windows上。添加相应的Linux材料肯定会使它更有用。我正计划在未来这样做,但也非常欢迎其他朋友(尤其是对Linux部分)对该文件的贡献。

如何阅读本文档

这是一份很长的文档,但你不需要读完它;你也不需要按顺序阅读各部分。根据你在做性能分析方面的经验,有些章节可以完全跳过。

🔹 如果你对性能分析工作完全陌生,我建议从头开始。

🔹 对于那些已经能自如地进行一般的性能分析工作,但希望增加他们在管理内存相关主题方面的知识的人,他们可以跳过开头,直接进入基础知识部分。

🔹 如果你不是很有经验,并且在做一次性能分析,你可以跳到知道什么时候该担心部分开始阅读,如果需要,再参考基础知识部分的具体内容。

🔹 如果你是一名性能工程师,其工作包括将托管内存分析作为一项常规任务,但又是.NET的新手,我强烈建议你真正阅读并内化GC基础部分,因为它能帮助你更快地关注正确的事情。然而,如果你手头有一个紧急问题,你可以去看看我在本文档中将要使用的工具,熟悉它,然后看看你是否能在GC暂停问题或堆大小问题部分找到相关症状。

🔹 如果你已经有做托管内存性能分析工作的经验,并且有具体的问题,你可以在GC停顿时间长或GC堆太大部分找到它。

注意!

当我在写这篇文档时,我打算根据分析的需要来介绍一些概念,如并发的GC或钉住。所以在你阅读的过程中,你会逐渐接触到它们。如果你已经知道它们是什么,并且正在寻找关于特定概念的解释,这里有它们的链接-

如何看待性能分析工作?

那些在做性能分析方面有经验的人知道,这可能就像侦探工作一样--没有 "如果你按照这10个步骤去做,你就会改善性能或从根本上解决性能问题"的方法。这是为什么呢?因为在运行的东西不仅仅是你的代码 - 还有你使用操作系统、运行时、库(至少是BCL,但通常是许多其他的库)。而运行你的代码的线程需要与同一进程中的其他线程和/或其他进程共享机器/VM/容器。

然而,这并不意味着你需要对我刚才提到的一切有一个彻底的了解。否则我们都不会有任何成就 - 你根本没有时间。但你不需要这样做。你只需要了解足够的基础知识,掌握足够的性能分析技能,这样你就可以专注于自己代码的性能分析。在本文中,我们将讨论这两点。我还会解释事情为什么会这样,这样才有意义,而不是让你背诵那些很容易被翻出来的东西。

这篇文档谈到了你自己可以做什么,以及什么时候是把分析工作交给GC团队的好时机,因为这将是需要在运行时进行的改进。很明显,我们在GC中仍然在做改进的工作(否则我就不会还在这个团队中)。正如我们将看到的,GC的行为是由你的应用行为驱动的,所以你肯定可以改变你的应用行为来影响GC。在你作为性能工程师需要做多少工作和GC自动处理多少工作之间存在着一个平衡。.NET GC的理念是,我们尽量自动处理;当我们需要你的参与时(通过配置),我们会通过有意义的方式要求你的协助,这种方式是从应用程序的角度,并不要求你了解GC的全部细节。当然,GC团队一直在努力让.NET GC处理越来越多的性能场景,这样用户就不需要担心了。但如果你遇到了GC目前不能很好处理的情况,我将指出你可以做什么来解决它。

我对性能分析的目标是使客户需要做的大部分分析自动化。我们在这方面已经走了很长的路,但我们还没有达到所有分析都自动化的程度。在这份文件中,我将告诉你目前做分析的方法,在文件的最后,我将给你一个展望,说明我们正在为实现这个目标做了什么样的改进。

挑选正确的方法来做性能分析

我们都有有限的资源,如何将这些资源花在能够产生最大回报的事情上是关键。这意味着你应该找到哪个部分是最值得优化的,以及如何以最有效的方式优化它们。当你断定你需要优化某些东西,或者你要如何优化某些东西时,应该有一个合理的理由来说明你为什么这样做。

知道你的目标是什么

当人们第一次来找我时,我总是问他们这样一个问题 - 你的性能目标是什么?不同的产品有非常不同的性能要求。在你想出一个数字之前(例如,将某些东西提高X%),你需要知道你要优化的是什么。在最高层次的角度来看,这些是需要优化的方面 -

◼️ 优化内存占用,例如,需要在同一台机器上尽可能多地运行实例。

◼️ 优化吞吐量,例如,需要在一定的时间内处理尽可能多的请求。

◼️针对尾部延迟进行优化,例如,需要满足一定的延迟SLA。

当然,你可以有多个这样的要求,例如,你可能需要满足一个SLA,但仍然需要在一个时间段内至少处理一定数量的请求。在这种情况下,你需要弄清楚什么是优先考虑的,这将决定你应该把大部分精力放在什么地方。

你要明白GC只是框架的一个部分

GC行为的改变可能是由于GC本身的变化或框架其他部分的变化,当你使用一个新版本时,框架中通常会有很多改动。当你在升级后看到内存行为的变化时,可能是由于GC的变化或框架中的其他东西开始分配更多的内存,并以不同的方式保留内存。此外,如果你升级你的操作系统版本或在不同的虚拟化环境中运行你的产品,你也可以得到不同的行为,因为它们可能导致你的应用程序出现不同的行为。

不要猜测,去测量

测量是你在开始一个产品时绝对应该计划做的事情,而不是在事情发生后才想到的,特别是当你知道你的产品需要在相当高负载的情况下运行时。如果你正在阅读这份文件,那么你很有可能正在从事一些对性能有要求的工作。

对于我所接触的大多数工程师来说,测量并不是一个陌生的概念。然而,如何测量和测量什么是我见过的许多人需要帮助的事情。

◼️ 这意味着你需要有一些方法来真实地测量你的性能。在复杂的服务器应用程序上的一个常见问题是,你很难在你的测试环境中模拟你在生产中实际看到的情况。

◼️ 测量并不仅仅意味着 "我可以测量我的应用程序每秒可以处理多少个请求,因为这是我所关心的",它意味着你也应该有一些东西,当你的测量结果告诉你某些东西没有达到理想的水平时,你可以做有意义的性能分析。能够发现问题是一方面。如果你没有任何东西可以帮助你找出这些问题的原因,那就没有什么帮助了。当然,这需要你知道如何收集数据,我们将在下面谈及。

◼️ 能够衡量来自修复/解决方法的效果。

足够的测量,让你知道应该把精力集中在哪个领域

我一次又一次地听到人们会测量某一个点,并选择只优化这个点,因为他们从朋友或同事那里听说了这个点。这就是了解基本原理真正有帮助的地方,这样你就不会一直关注你听说过的这个点,而这个点可能是也可能不是正确的。

测量那些可能影响你的性能指标的因素

在您知道哪些因素可能对您关心的事情(即您的性能指标)影响最大之后,你应该测量它们的影响,这样你就可以观察它们在你开发产品的过程中贡献是大还是小。一个完美的例子是,服务器应用程序如何改善其P95请求延迟(即第95百分位的请求延迟)。这是一个几乎每个网络服务器都会看的性能指标。当然,很多因素都可以影响这个延迟,但你知道那些可能影响最大的因素。

网络IO只是另一个可能导致你的请求延迟的因素的例子。这里的方框宽度仅仅是为了说明问题。

你每天(或你记录P95的任何时间单位)的P95延迟可能会波动,但你知道大致的数字。比方说,你的平均请求延迟是<3ms,而你的P95大约是70ms。你必须有一些方法来测量每个请求总共需要多长时间(否则你就不会知道你的延迟百分数)。你可以记录你看到GC暂停或网络IO的时间(两者都可以通过事件来测量)。对于那些在P95延迟附近的请求,你可以计算出 "P95的GC影响",即

这些请求观察到的GC暂停总时间/请求总延时

如果这是10%,你应该有其他因素没有计算在内。

通常人们会猜测GC停顿是影响他们P95延迟的原因。当然这是有可能的,但这绝不是唯一可能的因素,也不是对你的P95影响最大的因素。这就是为什么了解影响很重要,它告诉你应该把大部分精力花在什么地方。

而影响你的P95的因素可能与影响你的P99或P99.99的因素非常不同;同样的原则也适用于其他百分位数。

优化框架代码与优化用户代码

虽然这个文档是为每一个关心内存分析的人准备的,但根据你所工作的层次,应该有不同的考虑。

作为一个从事终端产品的人,你有很大的自由空间去优化,因为你可以预测你的产品在什么样的环境下运行,例如,一般来说,你知道你倾向于哪种资源的饱和,CPU,内存或其他东西。你可以控制你的产品在什么样的机器/虚拟机上运行,你使用什么样的库,你如何使用它们。你可以做一些估计,比如 "我们的机器上有128GB的内存,计划在我们最大的进程中拿出20GB的内存缓存"。

从事平台技术或库工作的人无法预测他们的代码将在什么样的环境中运行。这意味着:1)如果希望用户能够在性能关键路径上使用代码,则需要节约内存使用;2)你可能要提供不同的API,在性能和可用性之间做出权衡,并指导你的用户如何做。

内存基础知识

正如我在上面提到的,让一个人对整个技术栈有透彻的了解是完全不现实的。本节列出了任何需要从事内存性能分析工作的人都必须知道的基本知识。

虚拟内存基础知识

我们通过VMM(虚拟内存管理器)使用内存,它为每个进程提供了自己的虚拟地址空间,尽管同一台机器上的所有进程都共享物理内存(如果你有页面文件的话)。如果你在一个虚拟机中运行,虚拟机就有一种在真实机器上运行的错觉。对于应用程序来说,实际上,你很少会直接使用虚拟内存工作。如果你写的是本地代码,通常你会通过一些本地分配器来使用虚拟地址空间,比如CRT堆,或者C++的new/delete关键字 - 这些分配器会代表你分配和释放虚拟内存;如果你写的是托管代码,GC是代表你分配/释放虚拟内存的人。

每个VA(虚拟地址)范围(指虚拟地址的连续范围)可以处于不同的状态 - 空闲 (free)、已保留(reserved) 和已提交(committed)。"空闲"很容易理解,就是空闲的内存。"已保留"和"已提交"之间的区别有时让人困惑。"已保留"是说 "我想让这个区域的内存供我自己使用"。当你"保留"了一个虚拟地址的范围后,这个范围就不能用来满足其他的"保留"请求。在这一点上,你还不能在这个地址范围内存储你的任何数据 - 你必须"提交"它才可以,这意味着系统将不得不用一些物理存储来支持它,以便你可以在其中存储东西。当你通过性能工具查看内存时,要确保你看的是正确的东西。如果你的预留空间用完了,或者"提交"空间用完了,你就会出现内存不足的情况(在本文档中,我主要关注Windows VMM--在Linux中,当你实际接触到内存时,你会出现OOM(Out Of Memory))。

虚拟内存可以是私有或共享的。私有意味着它只被当前进程使用,而共享意味着它可以被其他进程共享。所有与GC相关的内存使用都是私有的。

虚拟地址空间可能被分割--换句话说,地址空间中可能有 "缺口"(空闲块)。当你请求保留一大块虚拟内存时,虚拟机管理器需要在虚拟地址范围内找到一个足够大的空闲块来满足该请求--如果你只有几个空闲块,其总和足够大,那就无法工作。这意味着即使你有2GB,你也不一定能看到所有的2GB被使用。当大多数应用程序作为32位进程运行时,这是一个严重的问题。今天,我们在64位有一个充足的虚拟地址范围,所以物理内存是主要的关注点。当你提交内存时,VMM确保你有足够的物理存储空间,如果你真的想使用该内存。当你实际写入数据时,VMM将在物理内存中找到一个页面(4KB)来存储这些数据。这个页面现在是你进程工作集的一部分。当你启动你的进程时,这是一个非常正常的操作。

当机器上的进程使用的内存总量超过机器所拥有的内存时,一些页面将需要被写入页面文件(如果有的话,大多数情况下是这样的)。这是一个非常缓慢的操作,所以通常的做法是尽量避免进入分页。我在简化这个问题--实际的细节与这个讨论没有关系。当进程处于稳定状态时,通常你希望看到你正在使用的页面被保留在你的工作集中,这样我们就不需要支付任何成本来把它们带回来。在下一节中,我们将讨论GC是如何避免分页的。

我故意把这一节写得很短,因为GC才是需要代表你与虚拟内存互动的人,但了解一点基本情况有助于解释性能工具的结果。

GC基础

垃圾收集器提供了内存安全的巨大好处,使开发人员不必手动释放内存,并节省了可能是几个月或几年的调试堆损坏的时间。如果你不得不调试堆损坏,你就会知道这有多难。但是它也给内存性能分析带来了挑战,因为GC不会在每个对象死亡后运行(这将是令人难以置信的低效),而且GC越复杂,如果你需要做内存分析,你就必须考虑得越多(你可能会,也可能不会,我们将在下一节讨论这个问题)。本节是为了建立一些基本概念,帮助你对.NET GC有足够的了解,以便在面对内存调查时知道什么是正确的方法。

了解GC堆内存的使用与进程/机器内存的使用情况

  • GC堆只是你进程中的一种内存使用情况

    在每个进程中,每个使用内存的组件都是相互共存的。在任何一个.NET进程中,总有一些非GC堆的内存使用,例如,在你的进程中总是有一些模块被加载,需要消耗内存。但可以说,对于大多数的.NET应用程序来说,这意味着GC堆占用大部分的内存。

    如果一个进程的私有提交字节总数(如上所述,GC堆总是在私有内存中)与你的GC堆的提交字节数相当接近,你就知道大部分是由于GC堆本身造成的,所以这就是你应该关注的地方。如果你观察到一个明显的差异,这时你应该开始担心查看进程中的其他内存使用情况。

  • GC是按进程进行的,但它知道机器上的物理内存负载

    GC是一个以进程为维度的组件(自从CLR诞生以来一直如此)。大多数GC的启发式方法都是基于每个进程的测量,但GC也知道机器上的全局物理内存负载。我们这样做是因为我们想避免陷入分页的情况。GC将一定的内存负载百分比识别为 "高内存负载情况"。当内存负载百分比超过这个百分比时,GC就会进入一个更积极的模式,也就是说,如果它认为有成效的话,它会选择做更多的完全阻塞的GC,因为它想减少堆的大小。

    目前,在较小的机器上(即内存小于80GiB),默认情况下GC将90%视为高内存负荷。在有更多内存的机器上,这是在90%到97%之间。这个阈值可以通过COMPlus_GCHighMemPercent环境变量(或者从.NET 5开始在runtimeconfig.json中配置System.GC.HighMemoryPercent)来调整。你想调整这个的主要原因是为了控制堆的大小。例如,在一台有64GB内存的机器上,对于主要的主导进程,当有10%的内存可用时,GC开始反应是合理的。但是对于较小的进程(例如,如果一个进程只消耗1GB的内存),GC可以在<10%的可用内存下舒适地运行,所以你可能想对这些进程设置得更高。另一方面,如果你想让较大的进程拥有较小的堆大小(即使机器上有大量可用的物理内存),把这个值调低将是一个有效的方法,让GC更快地做出反应,压缩堆的大小。

    对于在容器中运行的进程,GC会根据容器的限制来考虑物理内存。

    本节描述了如何找出每个GC观察到的内存负载。

了解GC是如何被触发的

到目前为止,我们用GC来指代组件。下面我将用GC来指代组件,或者指代一个或多个在堆上进行内存回收的集合行为,即GC或GCs。

  • 触发GC的主要原因是分配

    由于GC是用来管理内存分配的,自然触发GC的最主要因素是由于分配。随着进程的运行和分配的发生,GC将不断被触发。我们有一个 "分配预算 "的概念,它是决定何时触发GC的主导因素。我们将在下面非常详细地讨论分配预算

  • 触发GC的其他因素

    GC也可以由于机器运行到高物理内存压力而被触发,或者如果用户通过调用GC.Collect而自己诱发GC。

了解分配内存的成本

由于大多数GC是由于分配而触发的,所以值得了解分配的成本。首先,当分配没有触发GC时,它是否有成本?答案是绝对的。有一些代码需要运行来提供分配--只要你必须运行代码来做一些事情,就会有成本。这只是一个多少的问题。

分配中开销最大的部分(没有触发GC)是内存清除。GC有一个契约,即它所有分配的内存会用零填充。我们这样做是为了安全、保障和可靠性的原因。

我们经常听到人们谈论测量GC成本,但却不怎么谈论测量分配成本。一个明显的原因是由于GC干扰了你的线程。还有一种情况是,监测GC发生的时间是非常轻量的 - 我们提供了轻量级的工具,可以告诉你这个。但是分配一直在发生,而且很难在每次分配发生时都进行监控 - 会占用很多性能资源,很可能使你的进程不再以有意义的状态运行。我们可以通过以下适当的方式来测量分配成本,在工具部分,我们将看到如何用各种工具技术来做这些事情--

监控内存分配的3种方法

1)我们还可以测量GC的发生频率,这告诉我们发生了多少分配。毕竟,大多数GC是由于分配而被触发的。

2)对非常频繁发生的事情进行分析的方法之一是抽样。

3)当你有了CPU使用信息,你可以在GC方法名称查看内存清除的成本。实际上,通过GC方法名称来查找东西显然是非常内部且专业的,并受制于实现的变化。但由于本文档的目标是大众,包括有经验的性能工程师,我将提到几个具体的方法(其名称往往不会有太大的变化),作为进行性能测量的一种方式。

如何正确看待GC堆的大小

这听起来是一个简单的问题。通过测量,对吗?是的,但是当你测量GC堆的时候,就很重要了。

  • 看一下GC堆的大小与GC发生的时间关系

    这到底是什么意思?假设我们不考虑GC发生的时间,只是每秒钟测量一次堆的大小。请看下面这个(编造的)例子

    表格 1

    动作这一秒过后的堆大小
    1分配 1 GB1 GB
    2分配 2 GB3 GB
    3分配 0 GB3 GB
    4GC发生(500M存活),然后分配1GB1.5 GB
    5分配 3 GB4.5 GB

    我们可以说,是的,有一个GC发生在第4秒,因为堆的大小比第3秒小。但我们再看看另一种可能性-

    表格2

    动作这一秒过后的堆大小
    1分配 1 GB1 GB
    2分配 2 GB3 GB
    3GC发生(1GB存活),然后分配2GB3 GB
    4分配 1 GB4 GB

    如果我们只有堆的大小数据,我们就不能说GC是否已经发生。

    这就是为什么测量GC发生时的堆大小是很重要的。自然,GC本身提供的部分性能测量数据正是如此 - 每次GC前后的堆大小,也就是说,每次GC的开始和结束(以及其他大量的数据,我们将在本文的后面看到)。不幸的是,许多内存工具,或者我经常看到人们采取的诊断方法,都没有考虑到这一点。他们做内存诊断的方式是 "让我给你看看在你碰巧问起的时候堆是什么样子的"。这通常是没有帮助的,有时甚至是完全误导的。这并不是说像这样的工具完全没有帮助 - 当问题很简单的时候,它们可能会有帮助。如果你有一个已经持续了一段时间的非常大的内存泄漏,并且你使用了一个工具来显示你在那个时候的堆(要么通过采取进程转储和使用SoS,要么通过另一个工具来转储堆),那找到什么东西在泄露内存就真的很容了。这是性能分析中的一个常见模式 - 问题越严重,就越容易找出问题。但是,当你遇到的性能问题不是这种显而易见的情况时,这些工具就显得不足了。

  • 分配预算

    看完上一段,思考分配预算的一个简单方法是上一次GC退出时的堆大小和这次GC进入时的堆大小之间的差异。因此,分配预算是指在触发下一次GC之前,GC允许多少分配。在表1和表2中,分配预算是一样的 - 3GB。

    然而,由于.NET GC支持钉住对象(防止GC移动被钉住的对象)以及钉住的复杂情况,分配预算往往不是2个堆大小之间的区别。然而,预算是 "在触发下一次GC之前的分配量 "的想法仍然成立。我们将在本文档的后面讨论更多关于钉住的问题( 后面的内容.)。

    当试图提高内存性能时,我看到人们经常做的一件事(或只做一件事)是减少分配。如果你真的可以在性能关键路径开始之前预先分配所有的东西,我说你更有更多的权利!但是,这有时是非常不实际的。例如,如果你使用的是库,你并不能完全控制它们的分配(当然,你可以尝试找到一种无分配的方式来调用API,但并不保证有这样的方式,而且它们的实现可能会改变)。

    那么,减少分配是一件好事吗?是的,只要它确实会对你的应用程序的性能产生影响,并且不会使你的代码中的逻辑变得非常笨拙或复杂,从而使它成为一个值得的优化。减少分配实际上会降低性能吗?这完全取决于你是如何减少分配的。你是在消除分配还是用其他东西来代替它们?因为用其他东西代替分配可能不会减少GC所要做的工作。

  • 分代GC的影响

    .NET的GC是分代的,有3代,IOW,GC堆中的对象被分为3代;gen0是最年轻的一代,gen2是老一代。gen1作为一个缓冲区,通常是为了在触发GC时仍在请求中的数据(所以我们希望在我们做gen1时,这些数据不会被你的代码所引用)。

    根据设计,分代GC不会在每次触发GC时收集整个堆。他们尝试做年轻一代的GC,比老一代的GC更频繁。老一代的GC通常成本更高,因为它们收集的堆更多。

    你很可能曾经听说过 "GC暂停 "这个术语。GC暂停是指GC以STW(Stop-The-World)的方式执行其工作时。对于并发GC来说,它与用户线程同时进行大部分的GC工作,GC暂停的时间并不长,但是GC仍然需要花费CPU周期来完成它的工作。年轻的gen GCs,即gen0和gen1 GC,被称为短暂的GC,而老的gen GC,即gen2 GC,也被称为full GC,因为它们收集整个堆。当genX GC发生时,它收集了genX和它所有的年轻世代。因此,gen1 GC同时收集了堆中的gen0和gen1部分。

    这也使得看堆变得更加复杂,因为如果你刚从一个老一代的GC中出来,特别是一个正在整理的GC,你的堆的大小显然比你在该GC被触发之前要小得多;但如果你看一下年轻一代的GC,它们可能正在被整理,但堆的大小差异没有那么大,这就是设计。

    上面提到的分配预算概念实际上是每一代的,所以gen0、gen1和gen2都有自己的分配预算。用户的分配将发生在gen0,并消耗gen0的分配预算。当分配消耗了gen0的所有预算时,GC将被触发,gen0的幸存者将消耗gen1的分配预算。同样地,gen1的幸存者将消耗gen2的预算。

    图1 - 经过不同代GC的对象

一个对象 "死了 "和它被清理掉之间的区别可能会让人困惑。我收到的一个常见问题是:"我不再保留我的对象了,而且我看到GC正在发生,为什么我的对象还在那里?"。请注意,一个对象不再被用户代码持有的事实(在本文中,用户代码包括框架/库代码,即不是GC代码)需要被GC扫描到。要记住的一个重要规则是:"如果一个对象在genX中,这意味着它只可能在genX GC发生时被回收",因为这时GC会真正去检查genX中的对象是否还活着。如果一个对象在gen2中,不管发生了多少次短暂的GC(即0代和1代GC),这个对象仍然会在那里,因为GC根本没有收集gen2。另一种思考方式是,一个对象所处的代数越高,GC需要收集的工作就越多。

  • 大对象堆

    现在是谈论大对象的好时机,也就是LOH(大对象堆)。到目前为止,我们已经提到了gen0、gen1和gen2,以及用户代码总是在gen0中分配对象。实际上,如果对象太大,这并不正确 - 它们会被分配到堆的另一个部分,即LOH。而gen0、gen1和gen2组成了SOH(小对象堆)。

    在某种程度上,你可以认为LOH是一种阻止用户不小心分配大对象的方式,因为大对象比小对象更容易引入性能挑战。例如,当运行时默认发放一个对象时,它保证内存被清空。内存清空是一个昂贵的操作,如果我们需要清空更多的内存,它的成本会更高。也更难找到空间来容纳一个更大的对象。

    LOH在内部是作为gen3被跟踪的,但在逻辑上它是gen2的一部分,这意味着LOH只在gen2的GC中被收集。这意味着,如果你代码经常会使用LOH,你就会经常触发gen2的GC,如果你的gen2也很大,这意味着GC将不得不做大量的工作来执行gen2的GC。

    和其他gen一样,LOH也有它的分配预算,当它用完时,与gen0不同,gen2 GC将被触发,因为LOH只在gen2 GC期间被清理。

    默认情况下,一个对象进入LOH的阈值是>=85000字节。这可以通过使用GCLOHThreshold配置来调整更高。LOH也默认不压缩,除非它在有内存限制的容器中运行(容器行为在.NET Core 3.0中引入)。

  • 碎片化(自由对象)是堆大小的一部分

    另一个常见问题是 "我看到gen2有很多自由空间,为什么GC没有使用这些空间?"。

    答案是,GC正在使用这个空间。我们必须再次回到何时测量堆的大小,但现在我们需要增加另一个维度 - 整理GC vs 清扫GC。

    .NET GC可以执行整理或清扫GC。整理是开销更大的操作,因为GC会移动对象(会发生内存复制),这意味着它必须更新堆上这些对象的所有引用,但整理可以大大减少堆的大小。清扫GC不进行压缩,而是将相邻的死对象凝聚成一个空闲对象,并将这些空闲对象穿到该代的空闲列表中。空闲列表占据的大小,我们称之为碎片,也是gen的一部分,因此在我们报告gen和堆的大小时也包括在内。虽然在这种情况下,堆的大小并没有什么变化,但重要的是要明白这个空闲列表是用来容纳年轻一代的幸存者的,所以我们要使用空闲空间。

    这里我们将介绍GC的另一个概念 - 并发的GC与阻塞的GC。

    并发GC/后台GC

    我们知道,如果我们以停止托管线程的方式进行GC,可能需要很长的时间,也就是我们所说的完全阻塞式GC。我们不想让用户线程暂停那么久,所以大多数时候,一个完整的GC是并发进行的,这意味着GC线程与用户线程同时运行,在GC的大部分时间里(一个并发的GC仍然需要暂停用户线程,但只是短暂的暂停)。目前.NET中的并发GC风格被称为后台GC,或简称BGC。BGC只进行清扫。也就是说,BGC的工作是建立一个第二代自由列表来容纳第一代的幸存者。短暂的GC总是作为阻塞的GC来做,因为它们足够短。

    现在我们再来思考一下 "何时测量 "的问题。当我们做一个BGC时,在该GC结束时,一个新的自由列表被建立起来。随着第一代GC的运行,他们将使用这个自由列表的一部分来容纳他们的幸存者,所以列表的大小将变得越来越小。因此,当你说 "我看到gen2有很多空闲空间 "时,如果那是在BGC刚刚发生的时候,或者刚刚发生不久的时候,那是正常的。如果到了我们做下一次BGC的时候,gen2中总是有很多空闲空间,这意味着我们做了那么多工作来建立一个空闲列表,但它并没有被使用多少,这就是一个真正的性能问题。我已经在一些场景中看到了这种情况,我们正在建立一个解决方案,使我们能够进行最佳的BGC触发。

    Pinning 再次增加了碎片的复杂性,我们将在钉住章节中谈及。

  • GC堆的物理表示

    我们一直在讨论如何正确地测量GC堆的大小,但是GC堆在内存中到底是什么样子的,也就是说,GC堆是如何物理组织的?

    GC像其他Win32程序一样通过VirtualAllocVirtualFreeAPI来获取和释放虚拟内存(在Linux上通过mmap/munmap完成)。GC对虚拟内存进行的操作有以下几点

    当GC堆被初始化时,它为SOH保留了一个初始段,为LOH保留了另一个初始段,并且只在每个段的开头提交几个页面来存储一些初始信息。

    当分配发生在这个段上时,内存会根据需要被提交。对于SOH来说,由于只有一个段,gen0、gen1和gen2此时都在这个段上。要记住的一个不变因素是,两个短暂的gen,即gen0和gen1,总是生活在同一个段上,这个段被称为短暂段,这意味着合并的短暂gen永远不会比一个段大。如果SOH的增长超过了一个段的容量,在GC期间将获得一个新的段。gen0和gen1所在的段是新的短暂段,另一个段现在变成了gen2段。这是在GC期间完成的。LOH是不同的,因为用户的分配会进入LOH,新的段是在分配时间内获得的。因此,GC堆可能看起来像这样(在段的末尾可能有未使用的空间,用白色空间表示):

    图. 2 - GC堆的段

    随着GC的发生和内存回收,当段上没有发现活对象时,段就会被释放;段空间的末端(即段上最后一个活对象的末端,直到段的末端)被取消提交,除了短暂的段。

对短暂段的特殊处理

对于短暂段,我们保留GC后提交的最后一个实时对象之后的空间,因为我们知道gen0分配将立即使用这个空间。因为我们要分配的内存量是gen0的预算,所以提交的空间量就是gen0的预算。这回答了另一个常见问题 - "为什么GC提交的内存比堆的大小多?"。这是因为提交的字节包括gen0预算部分,而如果你碰巧在GC发生后不久看一下堆,它还没有消耗大部分的空间。特别是当你有服务器GC时,它可能有相当大的gen0预算;这意味着这个差异可能很大,例如,如果有32个堆,每个堆有50MB的gen0预算,你在GC后马上看堆的大小,你看到的大小会比提交的字节少(32 * 50 = 1.6 GB)。

请注意,在.NET 5中,取消提交的行为发生了变化,我们可以留下更多的内存,因为我们想把gen1也纳入GC的考虑。另外,服务器GC的取消提交现在是在GC暂停之外完成的,所以GC结束时报告的部分内容可能会被取消提交。这是一个实现细节--使用gen0的预算通常仍然是一个非常好的近似值,可以确定投入的部分是多少。

按照上面的例子,在gen2 GC之后,堆可能看起来是这样的(注意这只是一个例子说明)。

图3 - gen2 GC后的GC堆段

在gen0的GC之后,由于它只能收集gen0的空间,我们可能会看到这个:

图4 - gen0 GC后的GC堆段

大多数时候,你不必关心GC堆被组织成段的事实,除了在32位上,因为虚拟地址空间很小(总共2-4GB),而且可能是碎片化的,甚至当你要求分配一个小对象时,你可能得到一个OOM,因为我们需要保留一个新的段。在64位平台上,也就是我们大多数客户现在使用的平台上,有大量的虚拟地址空间,所以预留空间不是一个问题。而且在64位平台上,段的大小要大得多。

  • GC自己的记账

    很明显,GC也需要做自己的记账工作,这就需要消耗内存 - 这大约是GC堆大小的1%。最大的消耗是由于启用了并行GC,这是默认的。准确地说,并发的GC记账与堆的储备大小成正比,但其余的记账实际上与堆的范围成正比。由于这是1%,你需要关心它的可能性极低。

  • 什么时候GC会抛出一个OOM异常?

    几乎所有人都听说过或遇到过OOM异常。GC究竟什么时候会抛出一个OOM异常呢?在抛出OOM之前,GC确实非常努力。因为GC大多做短暂的GC,这意味着堆的大小往往不是最小的,这是设计上的。然而,GC通常会尝试一个完全阻塞的GC,并在抛出OOM之前验证它是否仍然不能满足分配请求。但也有一个例外,那就是GC有一个调整启发式,说它不会继续尝试完全阻塞的GC,如果它们不能有效地缩小堆的大小。它将尝试一些gen1 GCs和完全阻塞的GCs混合在一起。所以你可能会看到一个OOM抛出,但抛出它的GC并不是一个完全阻塞的GC。

了解GC暂停,即何时触发GC以及GC持续多长时间

当人们研究 GC 暂停问题时,我总是问他们是否关心总暂停和/或单个暂停。总暂停是由 "GC中的%暂停时间 "来表示的,每次GC被触发,暂停都会被加到总暂停中。通常情况下,你关心这个是出于吞吐量的原因,因为你不希望GC过多地暂停你的代码,以至于把吞吐量降低到可接受的程度。单个暂停表示单个GC持续的时间。除了作为总暂停的一部分,你关心单个暂停的一个原因通常是为了请求的尾部延迟--你想减少长的GC以消除或减少它们对尾部延迟的影响。

  • 单个GC的持续时间

    .NET的GC是一个引用追踪式GC,这意味着GC需要通过各种根(例如,堆栈定位,GC处理表)去追踪,以找出哪些对象应该是活的。因此,GC的工作量与有多少对象在内存中存活成正比。一个GC持续的时间与GC的工作量大致成正比。我们将在本文档的后面更多地讨论根的问题。

    对于阻塞式GC来说,由于它们在整个GC期间暂停用户线程,所以GC持续的时间与GC暂停的时间相同。对于BGC,它们可以持续相当长的时间,但暂停时间要小得多,因为GC主要是以并发的方式工作。

    注意,我说过GC的持续时间与GC的工作量大致成正比。为什么是大致?GC需要像其他东西一样分享机器上的核心。对于阻塞式GC,当我们说 "GC暂停用户线程 "时,我们实际上是指 "执行托管代码的线程"。执行本地代码的线程可以自由运行(尽管需要等待GC结束,如果它们需要在GC仍在进行的时候返回到托管代码)。最后,不要忘了,在线程运行时,其他进程由于GC的原因暂停了你的进程。

    这就是我们引入的另一个概念,即GC的不同主要类型--工作站GC vs 服务器GC(简称WKS GC vs SVR GC)。

    服务器GC

    顾名思义,它们分别用于工作站(即客户端)和服务器的工作负载。工作站工作负载意味着你与许多其他进程共享机器,而服务器工作负载通常意味着它是机器上的主导进程,并倾向于有许多用户线程在这个进程中工作。这两种GC的主要区别在于,WKS GC只有一个堆,SVR GC有多少个堆取决于机器上有多少逻辑核心,也就有和逻辑核心相同数量的GC线程进行GC工作。到目前为止,我们介绍的所有概念都适用于每个堆,例如,分配预算现在是每代每堆,所以每个堆都有自己的gen0预算。当任何一个堆的gen0分配预算用完后,就会触发GC。上图中的GC堆段将在每个堆上重复出现(尽管它们可能包含不同数量的内存)。

    由于2种工作负载的性质不同,SVR GC有2个明显不同的属性,而WKS GC则没有。

  1. SVR GC线程的优先级被设置为 "THREAD_PRIORITY_HIGHEST",这意味着如果其他线程的优先级较低,它就会抢占这些线程,而大多数线程都是如此。相比之下,WKS GC在触发GC的用户线程上运行GC工作,所以它的优先级是该线程运行的任何优先级,通常是正常的优先级。

  2. SVR GC线程与逻辑核心硬性绑定。

    参见MSDN文档中关于SVR GC的图解。既然我们现在谈到了服务器和并发/后台GC,你可能会问服务器GC也有并发的吗?答案是肯定的。我再次向你推荐MSDN doc,因为它对Background WKS GC与Background SVR GC有一个明确的说明。

    我们这样做的原因是,当SVR GC发生时,我们希望它能够尽可能快地完成它的工作。虽然这在大多数情况下确实达到了这个目标,但是它可能会带来一个你应该注意的复杂情况 - 如果在SVR GC发生的同时,有其他线程也以THREAD_PRIORITY_HIGHEST或更高的速度运行,它们会导致SVR GC花费更长的时间,因为每个GC线程只在其各自的核心上运行(我们将在后面的章节)看到如何诊断长GC的问题。而这种情况通常非常罕见,但是有一个注意事项,那就是当你在同一台机器上有多个使用SVR GC的进程时。在运行时的早期,这种情况很少见,但是随着这种情况越来越少,我们创建了一些配置,允许你为使用SVR GC的进程指定更少的GC堆/线程。这些配置的解释是这里。

    我见过一些人故意把一个大的服务器进程分成多个小的进程,这样每个进程都会有一个较小的堆,通过使用堆数较少的服务器GC。他们用这种方式取得了更好的效果(更小的堆意味着更短的暂停时间,如果它确实需要做完全阻塞的GC的话)。这是一个有效的方法,但当然只能在有意义的情况下使用它 - 对于某些应用来说,将一个进程分成多个进程是非常尴尬的。

多长时间触发一次GC?

如前所述,当gen0的分配预算用完时,就会触发GC。当一个GC被触发时,发生的第一步是我们决定这个GC将是哪一代。在工具那一章节,我们将看到哪些原因会导致GC从gen0升级到可能的gen1或gen2,但其中的一个主要因素是gen1和gen2的分配预算。如果我们检测到gen2的分配预算已经用完,我们就会把这个GC升级到完全的GC。

因此,"多长时间触发一次GC "的答案是由gen0/LOH预算耗尽的频率决定的,而gen1或gen2的GC被触发的频率主要由gen1和gen2的预算耗尽的频率决定。你自然会问 "那么预算是如何计算的?"。预算主要是根据我们看到的那一代的存活率来计算的。存活率越高,预算就越大。如果GC收集了一代对象并发现大多数对象都存活了,那么这么快再收集它就没有意义了,因为GC的目标是回收内存。如果GC做了所有这些工作,而能回收的内存却很少,那么它的效率就会非常低。

这如何转化为触发GC的频率是,如果一个代被频繁地使用(即,它的存活率很低),它将被更频繁地收集。这就解释了为什么我们最频繁地收集gen0,因为gen0是用于非常临时的对象,其存活率非常低。根据代际假说,对象要么活得很久,要么很临时,gen2持有长寿的对象,所以它们被收集的次数最少。

如前所述,在高内存负载情况下,我们会更积极地触发gen2阻塞式GC。当内存负载很高的时候,我们倾向于做完全阻塞的GC,这样我们就可以进行整理。虽然BGC对暂停时间有好处,但它对缩小堆没有好处,而当GC认为它的内存不足时,缩小堆就更重要了。

当内存负载不高时,我们做完全阻塞的GC的另一个原因是当gen2碎片非常高时,GC认为大幅减少堆的大小是有成效的。如果这对你来说是不必要的(即你有足够的可用内存),而且你宁愿避免长时间的停顿,你可以将延迟模式设置为SustainedLowLatency,告诉GC只在必须的时候做全阻塞的GC。

要记住的一条规则

那是很多材料,但如果我们把它总结为一条规则,这就是我在谈论GC被触发的频率和单个GC持续的时间时总是告诉人们的事情。

存活的对象数量通常决定了GC需要做多少工作;不存活的对象数量通常决定了GC被触发的频率

下面是一些极端的例子,当我们应用这一规则时-

情况1 - gen0根本没有任何存活对象。这意味着gen0的GC被频繁地触发。但是单次gen0的暂停时间非常短,因为基本上没有工作要做。

情况2 - 大部分gen2对象都存活。这意味着gen2的GC被触发的频率很低。对于单个gen2的暂停,如果GC作为阻塞GC进行,那暂停时间会非常长;如果作为BGC进行,会持续很长时间(但暂停时间仍然很短)。

你不能处于分配率和生存率都很高的情况下 - 你会很快耗尽内存。

是什么使一个对象得以存活

从GC的角度来看,它被各种运行时组件告知哪些对象应该存活。它并不关心这些对象是什么类型;它只关心有多少内存可以存活,以及这些对象是否有引用,因为它需要通过这些引用来追踪那些也应该存活的子对象。我们一直在对GC本身进行改进,以改善GC暂停,但作为一个写托管代码的人,知道是什么让对象存活下来是一个重要的方法,你可以通过它来改善你这边的个别GC暂停。

1. 分代方面

我们已经谈到了分代GC的效果,所以第一条规则是
当一个代没有被回收,这意味着该代的所有对象都是活的。

因此,如果我们正在收集gen2,代数方面是不相关的,因为所有的代数都会被收集。我收到的一个常见问题是:"我已经多次调用GC.Collect()了,对象还在那里,为什么GC不把它处理掉呢?"。这是因为当你诱导一个完全阻塞的GC时,GC并不参与决定哪些对象应该是活的 - 它只会由我们将在下面讨论的用户根(堆栈/GC句柄/等等)告知是否存活,我们将在下面谈论。因此,这意味着无论什么东西还活着,都是因为它需要活着,而GC根本无法回收它。

不幸的是,很少有性能工具会强调生成效应,尽管这是.NET GC的一个基石。许多性能工具会给你一个堆转储--有些会告诉你哪些堆栈变量或哪些GC句柄持有对象。你可以摆脱很大比例的GC句柄,但你的GC暂停时间几乎没有改善。为什么呢?如果你的大部分GC暂停是由于gen0的GC被gen2中的一些对象持有而造成的,那么如果你设法摆脱一些gen2的对象,而这些对象并不持有这些gen0的对象,那也是没有用的。是的,这将减少gen2的工作,但是如果gen2的GC发生的频率很低,那就不会有太大的区别,如果你的目标是减少gen2的GC的数量,你就不会有什么进展。

2. 用户根

你最有可能听到的常见类型的根是指向对象的堆栈变量、GC句柄和终结器队列。我把这些称为用户根,因为它们来自用户代码。由于这些是用户代码可以直接影响的东西,所以我将详细地讨论它们。

  • 堆栈变量

    堆栈变量,特别是对于C#程序来说,实际上并没有被谈及很多。原因是JIT也能很好地意识到堆栈变量何时不再被使用。当一个方法完成后,堆栈根保证会消失。但即使在这之前,JIT也能知道什么时候不再需要一个堆栈变量,所以不会向GC报告,即使GC发生在一个方法的中间。请注意,在DEBUG构建中不是这种情况。

  • GC句柄

    GC句柄是一种方式,用户代码可以持有一个对象,或者检查一个对象而不持有它。前者被称为强柄,后者被称为弱柄。强句柄需要被释放,以使它不再保留一个对象,也就是说,你需要在句柄上调用Free。有一些人给我看了!gcroot(SoS调试器的一个扩展命令,可以显示一个对象的根部)的输出,说有一个强句柄指向一个对象,问我为什么GC还没有回收这个对象。根据设计,这个句柄告诉GC这个对象需要是活的,所以GC不能回收它。目前,以下用户暴露的句柄类型是强句柄。Strong和Pinned;而弱柄是Weak和WeakTrackResurrection。但是如果你看过SoS的 !gchandles输出,Pinned句柄也可以包括AsyncPinned。

钉住

我在上面提到过几次钉住。大多数人都知道钉住是什么 - 它向GC表示一个对象不能被移动。但从GC的角度来看,钉住的意义是什么呢?由于GC不能移动这些被钉住的对象,它使被钉住的对象之前的死角变成了一个自由对象,这个自由对象可以用来容纳年轻一代的生存者。但这里有一个问题 - 正如我们从上面的代际讨论中看到的,如果我们简单地将这些被钉住的对象提升到老一代,就意味着这些自由空间也是老一代的一部分,要用它们来容纳年轻一代的幸存者,唯一的办法就是我们真的对年轻一代做一次GC(否则我们甚至没有 "年轻一代的幸存者")。然而,如果我们能在gen0中保留这些自由空间,它们就可以被用户分配使用。这就是为什么GC有一个叫做降代的功能,我们将把这些被钉住的对象降代到gen0,这意味着它们之间的空闲空间将是gen0的一部分,当用户代码分配时,我们可以立即使用它们。

图5 - 降代(我从一张旧的幻灯片上取下来的,所以这看起来与之前的片段图片有些不同。)

由于gen0分配可以发生在这些自由空间中,这意味着它们将消耗gen0预算而不增加gen0的大小(除非自由空间不能满足所有的gen0预算,在这种情况下它将需要增长gen0)。

然而,GC 不会无条件地降代,因为我们不想在 gen0 中留下许多固定对象,这意味着我们必须在每次 GC 中再次查看它们,可能会有很多次 GC(因为它们是 gen0 的一部分,每当我们执行 gen0 GC 我们需要查看它们)。这意味着如果您遇到严重的固定情况,它仍然会导致 gen2 中的碎片问题。同样,GC 确实有机制来应对这些情况。但是如果你想对 GC 施加更少的压力,你可以从用户的 POV 中遵循这个规则—

早点钉住对象,分批钉住对象

我们的想法是,如果你把对象钉在已经整理的那部分堆里,意味着这些对象已经不需要移动了,所以碎片化就不是问题。如果你以后确实需要钉住,通常的做法是分配一批缓冲区,然后把它们钉在一起,而不是每次都分配一个并钉住它。在.NET 5中,我们引入了一个名为POH(Pinned Object Heap(固定堆))的新特性,允许你告诉GC在分配时将钉住的对象放在一个特定的堆上。因此,如果你有这样的控制权,在POH上分配它们将有助于缓解碎片化问题,因为它们不再散落在普通堆上。

  • 终结器

终结队列是另一个根来源。如果你已经写了一段时间的.NET应用程序,你有可能听说过终结器是你需要避免的东西。然而,有时终结器并不是你的代码,而是来自你所使用的库。由于这是一个非常面向用户的特性,我们来详细了解一下。下面是终结器的基本性能含义 -

分配

· 如果你分配了一个可终结的对象(意味着它的类型有一个终结器),就在GC返回到VM端的分配助手之前,它将把这个对象的地址记录在终结队列中。

· 有一个终结者意味着你不能再使用快速分配器进行分配,因为每个可终结的对象的分配都要到GC去注册。

然而,这种成本通常是不明显的,因为你不太可能分配大部分可终结的对象。更重要的成本通常来自于GC实际发生的时间,以及在GC期间如何处理可终结的对象。

回收

当GC发生时,它将发现那些仍然活着的对象,并对它们升代。然后它将检查终结队列中的对象,看它们是否被升代 - 如果一个对象没有被升代,就意味着它已经死了,尽管它不能被回收(见下一段的原因)。如果你在被收集的几代中有成吨的可终结的对象,仅这一成本就可能是明显的。比方说,你有一大堆被提升到gen2的可终结对象(只是因为它们一直在存活),而你正在做大量的gen2 GC,在每个gen2 GC中,我们需要花时间来扫描所有的可终结对象。如果你很不频繁地做gen2 GC,你就不需要支付这个成本。

这里就是你听到 "终结器不好 "的原因了 - 为了运行GC已经发现的这个对象的终结器,这个对象需要是存活的。由于我们的GC是一代一代的,这意味着它将被提升到更高的一代,正如我们上面所谈到的,这反过来意味着它将需要一个更高的一代GC,也就是说,一个更昂贵的GC来收集这个对象。因此,如果一个可终结的对象在第一代GC中被发现死亡,它将需要等到下一次做第二代GC时才会被收集,而这可能是相当长的一段时间。也就是说,这个对象的内存的回收可能会被推迟很多。

然而,如果你用GC.SuppressFinalize来抑制终结器,你告诉GC的是你不需要运行这个对象的终结器。所以GC就没有理由去提升(升代)它。当GC发现它死亡时,它将被回收。

运行终结器

这是由终结器线程处理的。在GC发现死的、可终结的对象(然后被升代)后,它将其移至终结队列的一部分,告诉终结者线程何时向GC请求运行终结者,并向终结者线程发出信号,表示有工作要做。在GC完成后,终结器线程将运行这些终结器。被移到终结队列这一部分的对象被说成是 "准备好终结了"。你可能已经看到各种工具提到了这个术语,例如,sos的 !finalizequeue命令告诉你finalize队列的哪一部分储存了准备好的对象,像这样:

Ready for finalization 0 objects (000002E092FD9920->000002E092FD9920)

您经常会看到这是 0,因为终结器线程以高优先级运行,因此终结器将快速运行(除非它们被某些东西阻塞)。

下图说明了2个对象以及可最终确定的对象F是如何演变的。正如你所看到的,在它被提升到gen1之后,如果有一个gen0的GC,F仍然是活的,因为gen1没有被收集;只有当我们做一个gen1的GC时,F才能真正成为死的,我们看一下F所处的代。

图 6 - O 是不可终结的,F 是可终结的

3. 托管内存泄漏

现在我们了解了不同类别的根,我们可以谈谈管理性内存泄漏的定义了

托管内存泄漏意味着你至少有一个用户根,随着进程的运行,直接或间接地引用了越来越多的对象。这是一个泄漏,因为根据定义,GC不能回收这些对象的内存,所以即使GC尽了最大努力(即做一个全堆阻塞的GC),堆的大小最终还是会增长。

所以最简单的方法,如果可行的话,识别你是否有托管内存泄漏,就是在你知道你应该有相同的内存使用量的时候,简单地诱导全阻塞GC(例如,在每个请求结束时),并验证堆的大小没有增长。显然,这只是一种帮助调查内存泄漏的方法--当你在生产中运行你的应用程序时,你通常不希望诱发全阻塞的GCs。

  • “主线GC场景” vs “非主线”

如果你有一个程序只是使用堆栈并创建一些对象来使用,GC已经优化了很多年了。基本上是 "扫描堆栈以获得根部,并从那里处理对象"。这就是许多GC论文所假设的主线GC方案,也是唯一的方案。当然,作为一个已经存在了几十年的商业产品,并且必须满足各种客户的要求,我们还有一堆其他的东西,比如GC句柄和终结器。需要了解的是,虽然多年来我们也对这些东西进行了优化,但我们的操作是基于 "这些东西不多 "的假设,这显然不是对每个人都是如此。

以上是关于.NET内存性能分析指南的主要内容,如果未能解决你的问题,请参考以下文章

(转)《linux性能及调优指南》 3.3 内存瓶颈

markdown 打字稿...编码说明,提示,作弊,指南,代码片段和教程文章

14.VisualVM使用详解15.VisualVM堆查看器使用的内存不足19.class文件--文件结构--魔数20.文件结构--常量池21.文件结构访问标志(2个字节)22.类加载机制概(代码片段

C++ 性能/内存优化指南

方法与对象内存分析

Vue3官网-高级指南(十七)响应式计算`computed`和侦听`watchEffect`(onTrackonTriggeronInvalidate副作用的刷新时机`watch` pre)(代码片段