长沙市建设工程集团网站,html网站列表怎么做,外贸自己建网站,做商城网站系统.NET Memory Performance Analysis知道什么时候该担心#xff0c;以及在需要担心的时候该怎么做译者注作者信息#xff1a;Maoni Stephens - 微软架构师#xff0c;负责.NET Runtime GC设计与实现 博客链接 Github译者#xff1a;Bing Translator、INCerry 博客链接#x… .NET Memory Performance Analysis知道什么时候该担心以及在需要担心的时候该怎么做译者注作者信息Maoni Stephens - 微软架构师负责.NET Runtime GC设计与实现 博客链接 Github译者Bing Translator、INCerry 博客链接https://incerry.cnblogs.com 联系邮箱incerryfoxmail.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-050.0.2人工校对修复超链接错误-2021-12-160.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中当你实际接触到内存时你会出现OOMOut 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 GB2分配 2 GB3 GB3分配 0 GB3 GB4GC发生500M存活然后分配1GB1.5 GB5分配 3 GB4.5 GB我们可以说是的有一个GC发生在第4秒因为堆的大小比第3秒小。但我们再看看另一种可能性-表格2秒动作这一秒过后的堆大小1分配 1 GB1 GB2分配 2 GB3 GB3GC发生1GB存活然后分配2GB3 GB4分配 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代IOWGC堆中的对象被分为3代gen0是最年轻的一代gen2是老一代。gen1作为一个缓冲区通常是为了在触发GC时仍在请求中的数据所以我们希望在我们做gen1时这些数据不会被你的代码所引用。根据设计分代GC不会在每次触发GC时收集整个堆。他们尝试做年轻一代的GC比老一代的GC更频繁。老一代的GC通常成本更高因为它们收集的堆更多。你很可能曾经听说过 GC暂停 这个术语。GC暂停是指GC以STWStop-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程序一样通过VirtualAlloc和VirtualFreeAPI来获取和释放虚拟内存在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则没有。SVR GC线程的优先级被设置为 THREAD_PRIORITY_HIGHEST这意味着如果其他线程的优先级较低它就会抢占这些线程而大多数线程都是如此。相比之下WKS GC在触发GC的用户线程上运行GC工作所以它的优先级是该线程运行的任何优先级通常是正常的优先级。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。有一些人给我看了!gcrootSoS调试器的一个扩展命令可以显示一个对象的根部的输出说有一个强句柄指向一个对象问我为什么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中我们引入了一个名为POHPinned 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的GCF仍然是活的因为gen1没有被收集只有当我们做一个gen1的GC时F才能真正成为死的我们看一下F所处的代。图 6 - O 是不可终结的F 是可终结的3. 托管内存泄漏现在我们了解了不同类别的根我们可以谈谈管理性内存泄漏的定义了托管内存泄漏意味着你至少有一个用户根随着进程的运行直接或间接地引用了越来越多的对象。这是一个泄漏因为根据定义GC不能回收这些对象的内存所以即使GC尽了最大努力即做一个全堆阻塞的GC堆的大小最终还是会增长。所以最简单的方法如果可行的话识别你是否有托管内存泄漏就是在你知道你应该有相同的内存使用量的时候简单地诱导全阻塞GC例如在每个请求结束时并验证堆的大小没有增长。显然这只是一种帮助调查内存泄漏的方法--当你在生产中运行你的应用程序时你通常不希望诱发全阻塞的GCs。“主线GC场景” vs “非主线”如果你有一个程序只是使用堆栈并创建一些对象来使用GC已经优化了很多年了。基本上是 扫描堆栈以获得根部并从那里处理对象。这就是许多GC论文所假设的主线GC方案也是唯一的方案。当然作为一个已经存在了几十年的商业产品并且必须满足各种客户的要求我们还有一堆其他的东西比如GC句柄和终结器。需要了解的是虽然多年来我们也对这些东西进行了优化但我们的操作是基于 这些东西不多 的假设这显然不是对每个人都是如此。因此如果你确实有很多这样的东西那么如果你在诊断内存问题时就值得关注了。换句话说如果你没有任何内存问题你不需要关心但如果你有例如在GC时间百分比高它们是值得怀疑的好东西。**完全不做 GC 的部分 GC 暂停—线程挂起 **我们没有提到的GC暂停的最后一个部分是根本不做GC工作的部分--我指的是运行时中的线程暂停机制。GC调用暂停机制让进程中的线程在GC工作开始前停止。我们调用这个机制是为了让线程到达它们的安全点。因为GC可能会移动对象所以线程不能在随机的点上停止它们需要在运行时知道如何向GC报告对GC堆对象的引用的点上停止这样GC才能在必要时更新它们。这是一个常见的误解认为GC在做暂停工作--GC只是调用暂停机制来让你的线程停止。然而暂停被报告为GC暂停的一部分因为GC是使用它的主要组件。我们谈到了并发与阻塞的GC所以我们知道阻塞的GC会让你的线程在GC期间保持暂停状态而BGC并发的味道会让它们在短时间内暂停并在用户线程运行时做大部分的GC工作。不太常见的是让线程进入暂停状态可能需要一段时间。大多数情况下这是非常快的但是缓慢的暂停是一类与管理内存相关的性能问题我们将专门讨论如何诊断这些问题。注意在GC的暂停部分只有运行托管代码的线程被暂停。运行本地代码的线程可以自由运行。然而如果它们需要在这样的暂停部分返回到托管代码它们将需要等待直到暂停部分结束。知道什么时候该担心与任何性能调查一样首要的是弄清楚你是否应该担心这个问题。顶层应用指标如上所述关键是要有性能目标 - 这些应该由一个或多个顶级应用指标来表示。它们是应用指标因为它们直接告诉你应用的性能方面的数据例如你处理的并发请求数平均、最大和/或P95请求延迟。使用顶级应用指标来表明你在开发产品时是否有性能退步或改进这是相对容易理解的所以我们不会在这里花太多时间。但有一点值得指出的是有时要让这些指标稳定到有一个月到一个月的趋势甚至一天到一天的趋势并不容易原因很简单因为工作负载并不是每天都保持不变特别是对尾部延迟的测量。我们如何解决这个问题呢· 这正是衡量能影响它们的因素的重要原因之一。当然你很可能在前期不知道所有的因素。当你知道得越多你就可以把它们加入到你要测量的东西的范围内。· 有一些顶级的组件指标帮助你决定工作负载中有多少变化。对于内存一个简单的指标是做了多少分配。如果在今天的高峰时段你的分配量是昨天的两倍你就知道这表明今天的工作负荷也许给GC带来了更大的压力分配量绝对不是影响GC暂停的唯一因素见上面的GC暂停一节。然而有一个原因使得这成为一个受欢迎的追踪对象因为它与用户代码直接相关--你可以在一行代码中看到分配何时发生而将GC与一行代码关联起来则比较困难。顶层的GC指标既然你在阅读本文档显然你关心的组件之一就是GC。那么你应该跟踪哪些顶层的GC指标以及如何决定何时应该担心我们提供了许多不同的GC指标你可以测量 - 显然你不需要关心所有的指标。事实上要确定你是否/何时应该开始担心GC你只需要一到两个顶级的GC指标。表3列出了哪些顶级GC指标是基于你的性能目标相关的。如何收集这些指标将在[后面的章节]中描述#如何收集顶层的GC指标。表格3Application perf goal 应用性能目标Relevant GC metrics 相关的GC指标Throughput 吞吐量% Pause time in GC (maybe also % CPU time in GC) 在GC中暂停时间的百分比也许还有GC中CPU时间的百分比Tail latency 尾部延时Individual GC pauses 个别的GC停顿Memory footprint 内存占用率GC heap size histogram GC堆大小直方图何时应担心GC如果你理解了GC基本原理那么GC行为是由应用行为驱动的这一点应该是非常明显的。顶层的应用指标应该告诉你什么时候出现了性能问题。而GC指标可以帮助你对这些性能问题进行调查。例如如果你知道你的工作负载在一天中长时间处于休眠状态那么你看一天中 GC中暂停时间百分比 指标的平均值是没有意义的因为 GC中暂停时间百分比 的平均值会非常小。看这些GC指标的一个更合理的方法是我们在X点左右发生了故障让我们看一下那段时间的GC指标看看GC是否可能是故障的原因。当相关的GC指标显示GC的影响很小的时候把你的精力放在其他地方会更有成效。如果它们表明GC确实有很大的影响这时你应该开始担心如何进行内存管理分析这就是本文档的大部分内容。让我们详细看看每个目标以了解为什么你应该看他们相应的GC指标 -吞吐量为了提高你的吞吐量你希望GC尽可能少地干扰你的线程。GC会在以下两个方面进行干扰· GC可以暂停你的线程 - 阻塞的GC会在整个GC期间暂停它们BGC会暂停一小段时间。这种暂停由 GC中的%暂停时间% Pause time in GC来表示。· GC线程会消耗CPU来完成工作虽然BGC不会让你的线程暂停太多但它确实会与你的线程竞争CPU。所以还有一个指标叫做 GC花费的CPU时间%% CPU time in GC。这两个数字可能有很大差别。GC中的暂停时间百分比 的计算方法是线程被GC暂停时的耗时/进程的总耗时因此如果从进程开始到现在已经10s了线程由于GC而暂停了1s那么GC中的暂停时间百分比就是10%。即使BGC不在其中GC中的CPU时间百分比也可能多于或少于GC中的暂停时间百分比因为这取决于CPU在进程中被其他事物使用的情况。当GC正在进行时我们希望看到它尽可能快地完成所以我们希望看到它在执行期间有尽可能高的CPU使用率。这曾经是一个非常令人困惑的概念但现在似乎发生得更少了。我曾经收到过一些担心的人的报告说 当我看到一个服务器GC时它使用了100%的CPU! 我需要减少这个。我向他们解释说这实际上正是我们希望看到的--当GC暂停了你的线程时我们希望能使用所有的CPU这样我们就能更快地完成GC工作。假设GC的暂停时间为10%在GC暂停期间CPU使用率为100%例如如果你有8个核心GC会完全使用所有8个核心在GC之外你的线程的CPU使用率为50%并且没有BGC发生意味着GC只在你的线程暂停时做工作那么GC的CPU时间将为(100% * 10%) / (100% * 10% 50% * 90%) 18%我建议首先监测GC中的%暂停时间因为它的监测开销很低而且是一个很好的衡量标准可以确定你是否应该把GC作为一个最高级别的指标来关注。监测GC中的CPU时间百分比的成本较高需要实际收集CPU样本而且通常没有那么关键除非你的应用程序正在做大量的BGC而且CPU真的饱和了。通常情况下一个行为良好的应用程序在GC中的暂停时间小于5%而它正在积极处理工作负载。如果你的应用程序的暂停时间是3%那么你把精力放在GC上就没有什么成效了--即使你能去掉一半的暂停时间这很困难你也不会使总的性能提高多少。尾部延时之前我们讨论了如何考虑测量导致你的尾部延迟的因素。如果尾部延迟是你的目标除了其他因素外GC或最长的GC可能发生在那些最长的请求中。因此测量这些单独的GC暂停是很重要的看看它们是否/在多大程度上导致了你的延迟。有一些轻量级的方法可以知道一个单独的GC暂停何时开始和结束我们会看到在本文档后面。内存占用率如果你还没有正确阅读GC堆只是你进程中的一种内存使用情况以及如何测量GC heap size我强烈建议你现在就去做。实际上一个被管理的进程在GC堆之外还有明显的甚至是大量的内存使用这并不罕见所以了解是否是这样的情况很重要。如果GC堆在整个进程的内存使用中只占很小的比例那么你专注于减少GC堆的大小就没有意义了。挑选正确的工具和解释数据性能工具一览我怎么强调挑选正确工具的重要性都不为过。我经常看到一些人花了很多时间有时是几个月试图弄清一个问题因为他们没有发现正确的工具和/或如何使用它。这并不是说即使有了正确的工具你也不需要花费一些精力--有时只需要一分钟但其他时候可能需要许多分钟或几个小时。挑选合适的工具的另一个挑战是除非你在做一个基本的分析否则根本没有很多工具可以选择。也就是说有更多的工具能够解决简单的问题所以如果你正在解决其中的一个问题你选择哪一个并不那么重要。例如如果你有一个严重的托管内存泄漏你可以在你的开发环境中重现你将能够很容易地找到一个能够比较堆快照的工具这样你就可以看到哪些对象存活了而不应该。你很有可能通过这种方式来解决你的问题。你不需要关心像何时测量堆的大小这样的事情就像我们在如何正确测量GC堆的大小 部分广泛谈论的那样。我们使用的工具以及它是如何完成工作的运行时团队制作的、我经常使用的工具是PerfView - 你们中的很多人可能都听说过它。但我还没有看到很多人充分使用它。PerfView的核心是使用TraceEvent这是一个解码ETWEvent Tracing for Windows事件的库它来自运行时提供者、内核提供者和其他一些提供者。如果你以前没有接触过ETW事件你可以把它们看作是各种组件随着时间的推移所发出的数据。它们具有相同的格式所以来自不同组件的事件可以被那些知道如何解释ETW事件的工具放在一起看。这对香水调查来说是一个非常强大的东西。在ETW术语中事件按提供者例如运行时提供者、关键字例如GC和粗略程度例如通常意味着轻量级的Informational和通常更重的Verbose进行分类。使用ETW的成本与你所收集的事件量成正比。在GC信息级别开销非常小如果你需要你可以一直收集这些事件。由于.NET Core是跨平台的而ETW在Linux上并不存在我们有其他的事件机制旨在使这个过程透明如LTTng或EventPipe。因此如果你使用TraceEvent库你可以在Linux上获得这些事件的信息就像你在Windows上使用ETW事件那样。然而有不同的工具可以在Linux上收集这些事件。PerfView中的另一个功能我不太经常使用但作为GC的用户你可能更经常使用那就是堆快照即显示堆上有哪些对象它们之间是如何连接的。我不经常使用它的原因是GC并不关心对象的类型。你可能也用过我们的调试器扩展SoS。我很少用SoS来分析性能因为它更像是一个调试工具而不是剖析工具。它也不怎么看GCs主要是看堆即堆统计和转储单个对象。在本节的其余部分我将向你展示如何用PerfView的正确方式进行内存分析。我将多次引用内存基础来解释为什么要用这种方式进行分析这样做是有意义的而不是让你记住我们在这里做什么。如何开始进行内存性能分析当你开始进行内存性能分析时这些步骤是否听起来很熟悉捕获一个CPU采样文件看看你是否可以减少任何高开销的方法的CPU在一个工具中打开一个堆快照看看你能摆脱什么捕获内存分配看看你能摆脱什么根据你要解决的问题这些可能是有缺陷的。比方说你有一个尾部延迟的问题你正在做1)。你可能正在寻找是否可以减少你的代码中的CPU使用率或者是一些库代码。但是如果你的尾部延迟受到长GC的影响减少这些就不太可能影响你的长GC情况。处理问题的有效方法是推理出有助于实现性能目标的因素然后从那里开始。我们谈到了对不同性能目标有贡献的顶级GC指标。我们将讨论如何收集它们并看看我们如何分析每一个指标。本文档的大多数读者已经知道如何收集与内存有关的一般指标所以我将简要介绍一下。由于我们知道GC堆只是进程中内存使用的一部分但GC知道物理内存负载我们想测量进程的内存使用和机器的物理内存负载。在Windows上你可以通过收集以下性能计数器来实现这一目标。Memory\Available MBytes(内存\可用内存 单位MB)Process\Private Bytes(进程\私有内存占用 单位MB)对于一般的CPU使用率你可以监控Process\% Processor time(进程\占用处理器时间百分比)计数器。调查CPU时间的一个常见做法是每隔一段时间就进行一次CPU抽样调查例如有些人可能每小时做一次每次一分钟并查看汇总的CPU堆栈。如何收集顶层的GC指标GC发出轻量级的信息级事件你可以收集如果你愿意可以一直开着涵盖所有顶级的GC指标。使用PerfView的命令行来收集这些事件 -perfview /GCCollectOnly /AcceptEULA /nogui collect完成后在perfview cmd窗口中按下s来停止它。这应该运行足够长的时间来捕获足够多的GC活动例如如果你知道问题发生的时间这应该涵盖问题发生前的时间不仅仅是在问题发生的时间。如果你不确定问题何时开始发生你可以让它开很长时间。如果你知道要运行多长时间的集合可以使用下面的方法实际上这个方法用得更多。perfview /GCCollectOnly /AcceptEULA /nogui /MaxCollectSec:1800 collect并将1800半小时替换为你需要的任何秒数。当然你也可以将这个参数应用于其他命令行。这将产生一个名为PerfViewGCCollectOnly.etl.zip的文件。用PerfView的话来说我们称之为GCCollectOnly跟踪。在Linux上有一种类似的方法就是这个dotnet trace命令行dotnet trace collect -p pid -o outputpath with .nettrace extension --profile gc-collect --duration in hh:mm:ss format这算是一种等价的方法因为它收集了同样的GC事件但只针对已经启动的一个进程而perfview命令行收集的是整个机器的ETW即该机器上每个进程的GC事件在收集开始后启动的进程也会收集到。还有其他方法来收集顶级的GC指标例如在.NET Framework上我们有GC perf计数器在.NET Core上也增加了一些GC计数器。计数器和事件之间最重要的区别是计数器是抽样的而事件则捕获所有的GC但抽样也足够好。在.NET 5中我们还添加了一个GC采样API--public static GCMemoryInfo GetGCMemoryInfo(GCKind kind);它返回的GCMemoryInfo是基于 GCKind 的在GCMemoryInfo.cs中做了解释。/// summary指定垃圾收集的种类/summary
/// remarks
/// GC可以是3种类型中的一种--短暂的、完全阻塞的或后台的。
/// 它们的发生频率是非常不同的。短暂的GC比其他两种GC发生的频率高得多。
/// 其他两种类型。后台GC通常不经常发生而
/// 完全阻塞的GC通常发生的频率很低。为了对那些非常
/// 不经常发生的GC集合被分成不同的种类因此调用者可以要求所有三种GC
/// 同时保持
/// 合理的采样率例如如果你每秒钟采样一次如果没有这个
/// 区分你可能永远不会观察到一个后台GC。有了这个区别你可以
/// 总是能得到你指定的最后一个GC的信息。
/// /remarks
public enum GCKind
{/// summary任何种类的回收/summaryAny 0,/// summarygen0或gen1会后./summaryEphemeral 1,/// summary阻塞的gen2回收./summaryFullBlocking 2,/// summary后台GC回收/summary/// remarks这始终是一个gen2回收/remarksBackground 3
};GCMemoryInfo提供了与这个GC相关的各种信息比如它的暂停时间、提交的字节数、提升的字节数、它是压缩的还是并发的以及每一代被收集的信息。请参阅GCMemoryInfo.cs了解完整的列表。你可以调用这个API在过程中以你希望的频率对GCs进行采样。显示顶级的GC指标在PerfView的 GCStats 视图中这些数据与你刚刚收集的轨迹一起被方便地显示。在PerfView中打开PerfViewGCCollectOnly.etl.zip文件即通过运行PerfView并浏览到该目录双击该文件或者运行 PerfView PerfViewGCCollectOnly.etl.zip 命令行。你会看到该文件名下有多个节点。我们感兴趣的是 Memory Group节点下的 GCStats 视图。双击它来打开它。在顶部我们有这样的内容我运行了Visual Studio它是一个托管应用程序--这就是顶部的devenv进程。对于每一个进程你都会得到以下细节--我对那些命令行的进程添加了注释。Summary – 这包括像命令行、CLR启动标志、GC的%暂停时间等。GC stats rollup by generation – 对于gen0/1/2它有一些东西如该gen的多少个GCs被完成它们的平均/平均停顿等等。GC stats for GCs whose pause time was 200ms 暂停时间大于200ms的GC的统计数字LOH Allocation Pause (due to background GC) 200 Msec for this process - Gen2 GC stats该进程的LOH分配暂停由于后台GC200Msec 对于大型对象的分配有一个注意事项即在BGC进行时我们不允许过多的LOH分配。如果你在BGC期间分配了太多的对象你会看到一个表格出现在这里告诉你哪些线程在BGC期间被阻塞了以及多久因为有太多的LOH分配。这通常是一个信号告诉你减少LOH分配将有助于不使你的线程被阻塞。All GC stats 所有GC统计资料Condemned reasons for GCsGC被触发的原因Finalized Object Counts 终结器对象数量Summary explanation 摘要 解释· Commandline self-explanatory. 命令行· Runtime version运行时版本是相当无用的因为我们只是显示一些通用版本。然而你可以使用事件视图中的FileVersion事件来获得你所使用的运行时的确切版本。· CLR Startup FlagsGC启动标志 在GC调查中主要是寻找CONCURRENT_GC和SERVER_GC。如果你没有这两个标志通常意味着你使用的是工作站GC并且禁用了并发的GC。不幸的是这不是绝对的因为在我们捕获这个事件之后它可能会被改变。你可以用其他东西来验证这一点。注意请注意目前.NET Core/.NET 5没有这些标志所以你在这里不会看到任何东西。· Total CPU Time and Total GC CPU Time总的CPU时间和总的GC CPU时间这些总是0除非你真的在收集CPU样本。· Total Allocs你在这次追踪中为这个进程所做的总分配。· MSec/MB Alloc 是0除非你收集CPU样本它将是GC CPU总时间/分配的总字节数。· Total GC pause被GC停顿的总时间。注意这包括暂停时间即在GC开始之前暂停被管理的线程所需的时间。· % Time paused for Garbage Collection 暂停垃圾收集的时间这是 GC中暂停时间的%指标。· % CPU Time spent Garbage Collecting 花在垃圾收集上的CPU时间%这是 GC中的CPU时间%指标。它是NaN%除非你收集CPU样本。· Max GC Heap Size在本次跟踪过程中该进程的最大托管堆尺寸。· 其余的都是链接我们将在本文件中介绍其中一些。所有 All GC stats表 中显示了在跟踪收集过程中发生的每一个GC如果有太多的话它会有一个链接到没有显示的GC。在这个表中有很多列。由于这是一个非常广泛的表格我将只显示这个表格中与每个主题有关的列。其他顶级的GC指标个别暂停和堆大小作为这个表格的一部分被显示出来就像这样Peak MB指的是该GC进入时的GC堆大小After是退出时的大小。GCPausePeakAfterIndexMSecMBMB8045.7431,796.841,750.638056.9841,798.191,742.188065.5571,794.521,736.698076.7481,798.731,707.858085.4371,798.421,762.688097.1091,804.951,736.88现在这是一个html表格你不能进行排序所以如果你确实想进行排序例如找出最长的单个GC停顿你可以点击每个过程开始时的 在Excel中查看 链接 --· Individual GC Eventso View in Excel这将在Excel中打开上面的表格所以你可以对你喜欢的任何一列进行排序。在GC团队中由于我们需要对数据进行更多的切分我们有自己的性能基础设施 直接使用TraceEvent。PerfView中的其他相关视图除了GCStats视图之外介绍PerfView中的其他几个视图也很有用因为我们会用到它们。CPU Stacks是你所期望的--如果你在追踪中收集了CPU的样本事件这个视图就会亮起来。有一点值得一提的是我总是先清除3个高亮的文本框--在你这样做之后你需要点击更新来刷新。我很少发现这3个文本框有用。偶尔我会想按模块分组你可以阅读PerfView的帮助文档看看如何做到这一点。Events就是我们上面提到的 - 原始事件视图。由于是原始的它可能听起来很不友好但它有一些功能使它相当方便。你可以通过 过滤器 文本框过滤事件。如果你需要用多个字符串进行过滤你可以使用|。如果我想获得所有名称中带有file的事件和GC/Start事件我使用file|GC/Start没有空格。双击一个事件的名称会显示该事件的所有发生情况你可以搜索具体细节。例如如果我想找出coreclr.dll的确切版本我可以在查找文本框中输入coreclr。然后你可以看到你正在使用的coreclr.dll的确切版本信息。我还经常使用 开始/结束 来限制事件的时间范围使用 进程过滤器 将其限制在某个进程中使用 显示列 来限制要显示的事件字段这使我能够对事件的某个字段进行排序。内存组下的GC Heap Alloc Ignore Free是我们要用来查看分配的东西。Any Stacks显示所有的事件和它们的堆栈如果堆栈被收集。如果我想看一个特定的事件但还没有一个既定的视图或者既定的视图没有提供我所要的东西我觉得这很方便。对比堆栈视图像CPU堆栈视图一样的视图即堆快照视图或GC堆分配视图提供了一个Diff-ing功能。如果你在同一个PerfView实例中打开2个跟踪当你为每个跟踪打开一个堆栈视图时并且Diff菜单提供了一个 with Baseline 选项在Help/Users Guide中搜索 Diffing Two Traces。关于对比2个运行的问题--当你对比2个运行以查看什么是退步时最好是让工作负载尽可能的相似。比方说你做了一个改变以减少分配与其在相同的时间内运行2个运行不如用相同数量的请求来运行它们因为你知道你的新构建正在处理相同数量的工作。否则一个运行可能运行得更快因此处理更多的请求这本来就意味着它已经需要做不同数量的分配。这只是意味着它更难做比较。GC暂停时间百分比高如果你不知道如何收集GC暂停时间数据请按照如何收集顶级GC指标中的说明进行。如果总的暂停时间很高可能是由于GC太多即GC触发太频繁GC暂停时间太长或两者都有。太多的停顿即太多的GC根据我们的一条规则的一部分触发GC的频率是由不存活的东西决定的。因此如果你正在做大量的临时对象分配意味着它们不能存活这意味着你将触发大量的GC。在这种情况下看一下这些分配是有意义的。如果你能消除一些那就太好了但有时这并不实际。我们提到了3种方法来剖析分配。让我们看看如何进行每个分析。测量分配获得分配的字节数我们已经知道你可以在摘要中得到分配字节的总数。在GCStats视图中你还可以得到每个GC的分配字节数gen0。GC IndexGC编号Gen (代)Gen0 Alloc MB (Gen分配数量(MB))70N95.37381N71.10390N103.02102B0110N111.28121N94.537在Gen一栏中N表示Nonconcurrent GCB表示Background。所以完全阻塞的GC显示为2N。因为只有gen2的GC可以是后台所以你只能看到2B而不是0B或1B。你也可能看到F这意味着前景GC--当BGC正在进行时发生的短暂GC。注意对于2B来说是0因为如果gen0或gen1的分配预算被超过我们会在BGC的开始做一个gen0或gen1的GC所以gen0分配的字节数会显示在gen0或gen1的GC上。我们知道当gen0的分配预算被超过时就会触发GC。这个数据在GCStats中默认是不显示的只是因为表格中已经有很多列了。但你可以通过点击GCStats中表格前的Raw Data XML file用于调试链接来获得。一个xml文件将被生成并包含更详细的数据。对于每个GC你会看到这个我把它修剪了一下所以不会太宽-GlobalHeapHistory FinalYoungestDesired9,830,400 NumHeaps12/FinalYoungestDesired是为这个GC计算的最终gen0预算。由于我们对所有堆的预算进行了均衡这意味着每个堆都有这个相同的预算。由于有12个堆任何一个堆用完它的gen0预算都会导致下一次GC被触发。所以在这种情况下这意味着最多只有12*9,830,400117MB的分配直到下一次GC被触发。我们可以看到下一个GC是一个BGC它的Gen0 Alloc MB是0因为我们把这个BGC开始时做的短暂GC归结为GC#11它在GC#9结束后在Gen0中分配了111.28 MB。查看带有堆栈信息的采样分配当然你会想知道这些分配的情况。GC提供了一个叫做AllocationTick的事件大约每100KB的分配就会被触发。对于小对象堆来说100KB意味着很多对象这意味着对于SOH来说是采样但对于LOH来说这实际上是准确的因为每个对象至少有85000字节大。这个事件有一个叫做AllocationKind的字段--small意味着它是由分配为SOH而触发的而这个分配恰好使该SOH上的累积分配量超过了100KB那么这个量会被重置。所以你实际上不知道最后的分配量是多大。但是根据这个事件的频率看看哪些类型被分配的最多以及分配它们的调用栈仍然是一个非常好的近似值。很明显与只收集GCCollectOnly跟踪相比收集这个会增加明显的开销但这仍然是可以容忍的。PerfView.exe /nogui /accepteula /KernelEventsProcessThreadImageLoad /ClrEvents:GCStack /BufferSize:3000 /CircularMB:3000 collect这将收集AllocationTick事件及其分配被采样对象的调用栈。然后你可以在内存组下的 GC Heap Alloc Ignore Free (Coarse Sampling) 视图中打开它。点击一个类型你就可以看到分配该类型实例的堆栈。注意当你在同一个PerfView实例中打开两个追踪时你可以比较两个GC对的分配视图而且你可以双击每一种类型来查看分配给它们的调用栈。查看AllocationTick事件的另一种方法是使用Any Stacks视图因为它按大小分组。例如这是我从一个客户的跟踪中看到的情况类型名称已匿名或缩短。NameIncEvent Microsoft-Windows-DotNETRuntime/GC/AllocationTick627,509 EventData TypeName Entry[CustomerType,CustomerCollection][]221,581| EventData Size 1064964,172|| EventData Kind Small4,172|| coreclr4,172|| corelib!System.Collections.Generic.Dictionary2[CustomerType,System.__Canon].Resize(int32,bool)4,013|| corelib!System.Collections.Generic.Dictionary2[CustomerType,System.__Canon].Initialize(int32)159| EventData Size 1146883,852|| EventData Kind Small3,852|| coreclr3,852|| corelib!System.Collections.Generic.Dictionary2[CustomerType,System.__Canon].Resize(int32,bool)3,742|| corelib!System.Collections.Generic.Dictionary2[CustomerType,System.__Canon].Initialize(int32)110这说明大部分分配来自于字典的重新调整你也可以从 GC Heap Alloc 视图中看到但样本计数信息给了你更多的线索Resize 有 4013 次而 Initialize 有 159 次。所以如果你能更好地预测字典会有多大你可以把初始容量设置得更大以大大减少这些分配。**使用 CPU 样本查看内存清理成本 **如果你没有这些AllocationTick事件的跟踪但有CPU样本这很常见你也可以看一下内存清除的成本-如果你看一下memset_repmovs的调用者突出显示的2个调用者来自GC在把新对象递出之前的内存清除:(这是在.NET 5下如果你有旧版本你会看到WKS::gc_heap::bgc_loh_alloc_clr而不是WKS::gc_heap::bgc_uoh_alloc_clr。在我的例子中因为分配几乎是测试的全部内容所以分配成本非常高--占CPU总使用量的25.6%理解为什么GC决定收集这一代在GCStats中每个GC都有一列叫做 Trigger Reason。这告诉你这个GC是如何被触发的。可能的触发原因在PerfView repo的ClrTraceEventParser.cs中定义为GCReason。public enum GCReason
{AllocSmall 0x0,Induced 0x1,LowMemory 0x2,Empty 0x3,AllocLarge 0x4,OutOfSpaceSOH 0x5,OutOfSpaceLOH 0x6,InducedNotForced 0x7,Internal 0x8,InducedLowMemory 0x9,InducedCompacting 0xa,LowMemoryHost 0xb,PMFullGC 0xc,LowMemoryHostBlocking 0xd
}在这些原因中最常见的是AllocSmall - 这是说gen0的预算被超过了。如果你看到的最常见的是AllocLarge那么它很可能表明了一个问题--它是说你的GC被触发了因为你分配了大的对象超过了LOH预算。正如我们所知这意味着它将触发gen2 GC而我们知道触发频繁的完全GC通常是性能问题的秘诀。其他由于分配引起的触发原因是OutOfSpaceSOH和OutOfSpaceLOH - 你看到这些比AllocSmall和AllocLarge要少得多--这些是为你接近物理空间极限时准备的例如如果我们内存分配正在接近短暂段的终点。那些几乎总是引起危险信号的事件是 Induced因为它们意味着一些代码实际上是自己触发了GC。我们有一个GCTriggered事件专门用于发现什么代码用其调用栈触发了GC。你可以用堆栈和最小的内核事件收集一个非常轻量级的GC信息级别的跟踪:PerfView.exe /nogui /accepteula /KernelEventsProcessThreadImageLoad /ClrEvents:GCStack /ClrEventLevelInformational /BufferSize:3000 /CircularMB:3000 collect然后在任意堆栈视图中查看GCTriggered事件的堆栈:因此触发原因 是指GC是如何开始或产生的。如果一个GC开始的最常见原因是由于在SOH上分配那么这个GC将作为一个gen0的GC开始因为gen0的预算被超过了。现在在GC开始之后我们再决定我们实际上会收集哪一代。它可能保持为0代GC或者升级为1代甚至2代GC-这是我们在GC中最先决定的事情之一。导致我们升级到更高世代的GC的因素就是我们所说的 派遣的原因所以对于一个GC来说只有一个触发的原因但可以有多个派遣的原因。以下是出现在表格本身之前的 GC的派遣理由 部分的解释文本本表更详细地说明了GC决定收集那一代的确切原因。将鼠标悬停在各列标题上以获得更多信息。我不会在这里重复这些信息。最有趣的是那些升级到gen2的GC - 通常这些是由gen2的高内存负载或高碎片引起的。个别的长时间停顿如果你不知道如何收集GC暂停时间数据请按照如何收集顶级GC指标中的说明进行。如果你不熟悉是什么导致了单个GC暂停请先阅读GC暂停部分它解释了哪些因素导致了GC暂停时间。我们知道所有短暂的GCs都是阻塞的而gen2 GC可以是阻塞的也可以是背景的BGC。短暂的GC和BGC应该会产生短暂的停顿。但是事情可能出错我们将展示如何分析这些情况。如果堆的大小很大我们知道一个阻塞的gen2 GC会产生一个很长的停顿。但是当我们需要做gen2 GC的时候我们一般倾向于BGC。所以长的GC暂停是由于阻塞的gen2 GC造成的我们会想弄清楚为什么我们要做这些阻塞的gen2 GC。如此长时间的个别停顿可能是由以下因素或它们的组合造成的—· 在暂停期间有很多GC工作要做。· GC正在尝试执行工作但无法执行因为CPU被占用让我们看看如何分析每个场景。首先您是否存在托管内存泄漏如果你不知道什么是管理型内存泄露请先回顾一下那一节。根据定义这不是GC能帮你解决的问题。如果你有一个托管的内存泄漏保证GC会有越来越多的工作要做。在生产中触发完全阻塞的GC可能是非常不可取的所以在开发阶段做尽职调查很重要。例如如果你的产品处理请求你可以在每个请求或每N个请求结束时触发一个完全阻塞的GC。如果你认为内存使用量应该是相同的你应该能够用工具来验证。在这种情况下可以使用很多工具因为这是一个简单的场景。所以PerfView当然也有这个功能。你可以通过Memory/Take Heap Snapshot来获取堆快照。它确实有一些不完全直截了当的选项-Max Dump K Objs 是一个 聪明 的尝试所以它不会转储每一个对象。我总是把它增加到至少是默认值250的10倍。冻结选项是为生产诊断而设的当你不想招致完全阻塞的GC暂停时。但是如果你在开发过程中这样做并试图了解你的应用程序的基本行为你没有理由不检查它这样你就能得到一个准确的图片而不是用非Freeze选项试图 尽最大努力来跟踪对象图。然后在PerfView中打开生成的.gcDump文件它有一个类似堆栈的视图显示根信息例如GC句柄持有这个对象和转储中的类型实例的聚合信息。由于这是一个类似于堆栈的视图它提供了差分功能所以你可以在PerfView中取两个gcDump文件并进行差分。当你在生产中这样做时你可以先尝试不使用冻结。长时间的停顿是由于短暂的GCs、完全阻塞的GCs还是BGCsGCStats视图在每个进程的顶部都有一个方便的滚动表显示了各代的最大/平均/总停顿时间我确实应该把全阻塞的GC和BGC分开但现在你可以参考Gen2的表格。一个例子。计算出gen2 GC的工作量对于gen2 GCs我们希望看到大部分或所有的GC都以BGC的形式完成。但是如果你看到一个完全阻塞的GC在GCStats中表示为2N如果你的堆很大的话它的暂停时间有可能很长你会看到gen2 GCs有非常高的升代内存数量 MB与短暂的GC相比。通常人们在这个时候要做的是进行堆快照看看堆上有什么东西并尝试减少这些东西。然而首先要弄清楚的是为什么你首先要做完全阻塞的GCs。你可以查看Condemned Reasons表来了解这个问题。最常见的原因是高内存负载和gen2碎片化。要想知道每个GC观察到的内存负载点击GCStats中 GC Rollup By Generation 表格上方的 Raw Data XML file (for debugging) 链接它将生成一个xml文件其中包括内存负载等额外信息。一个例子是我剪掉了大部分的信息-GCEvent GCNumber 45 GCGeneration2 GlobalHeapHistory FinalYoungestDesired69,835,328 NumHeaps32/PerHeapHistories Count32 MemoryLoad47/PerHeapHistory/GCEvent这说明当GC#45发生时它观察到的内存负载为47%。由于bug导致的长时间停顿通常BGC的停顿都很小。唯一的一次是由于运行时的一个罕见的bug例如我们修复了一个bug即模块迭代器占用了一个锁当过程中有很多很多模块时这种锁的争夺意味着每个GC线程需要很长时间来迭代这些模块或者你正在做一些只在BGC的STW标记部分做的工作。由于这可能是由于非GC工作造成的我们将在 弄清长时间的GC是否是由于GC工作 中讨论如何诊断这个问题。计算出短暂GC的工作量GC的工作量大致与幸存者成正比这由 Promoted Bytes 指标表示该指标是GCStats表格中的一列 -这是有道理的--gen1 GCs比gen0 GCs升代的对象更多因此它们需要更长的时间。而且它们不会进行太多升代因为它们只收集通常是一小部分堆。如果你看到短暂的GCs突然增加了很多那么估计暂停时间会长很多。我所看到的一个原因是它进入了一个不经常被调用的代码路径对象存活下来而这些对象是不应该存活的。不幸的是我们用于找出导致短暂对象存活的原因的工具不是很好--我们已经在.NET 5中添加了运行时支持你可以使用PerfView中一个特殊的视图称为 Generational Aware 视图以查看哪些老年代对象导致年轻代对象存活--我将很快写出更多细节。你将看到的是这样的情况我不知道有什么其他工具可以方便地告诉你这些信息如果你知道有什么工具可以告诉你老一代的对象在年轻一代的对象上保持着什么使它们在GC期间存活请好心地告诉我。请注意如果你在gen2/LOH中有一个对象持有年轻gen对象的引用如果你不再需要它们引用那些对象你需要手动将这些引用字段设置为null。否则它们将继续持有那些对象的引用并导致它们被升代。对于C#程序来说这是导致短暂对象存活的一个主要原因对于F#程序来说就不是这样了。你可以从GCStats视图生成的Raw XML中看到这一点点击 Raw Data XML file (for debugging) 链接就在 GC Rollup By Generation 表的上方我把大部分属性从xml中修剪掉了 -GCEvent GCNumber9 GCGeneration0PerHeapHistories Count12 MemoryLoad20PerHeapHistory MarkStack0.145(10430) MarkFQ0.001(0) MarkHandles0.005(296) MarkOldGen2.373(755538)PerHeapHistory MarkStack0.175(14492) MarkFQ0.001(0) MarkHandles0.003(72) MarkOldGen2.335(518580)每个GC线程由于各种根而升代的字节数是PerHeapHistory数据的一部分-MarkStack/FQ/Handles分别是标记堆栈变量、终结队列和GC句柄MarkOldGen表示由于来自老一代的引用而升代的字节数量。因此举例来说如果你正在做一个gen1的GC这就是gen2对象对gen0/gen1对象的持有数量以使其存活。我们在.NET 5中对服务器GC所做的一个改进是当我们标记OldGen根时平衡GC线程的工作因为这通常会导致最大的升代数量。因此如果你在你的应用程序中看到这个数字非常不平衡升级到.NET 5会有帮助。弄清楚长的GC是否是由于GC工作造成的如果一个GC很长但却不符合上述任何一种情况也就是说没有很多工作需要GC去做但还是会造成长时间的停顿这意味着我们需要弄清楚为什么GC在它想做工作的时候却没有做到。而通常当这种情况发生时它似乎是随机发生的。偶尔长停的一个例子 -我们在PerfView中做了一个非常方便的功能叫做停止触发器意思是 当观察到某些条件满足时尽快停止跟踪这样我们就能捕捉到最相关的最后部分。它已经有一些专门用于GC目的的内置停止触发器。GC事件发生的顺序为了了解它们是如何工作的我们首先需要简要地看一下GC的事件序列。这里有6个相关的事件-Microsoft-Windows-DotNETRuntime/GC/SuspendEEStart //开始暂停托管线程运行
Microsoft-Windows-DotNETRuntime/GC/SuspendEEStop //暂停托管线程完成
Microsoft-Windows-DotNETRuntime/GC/Start // GC开始回收
Microsoft-Windows-DotNETRuntime/GC/Stop // GC回收结束
Microsoft-Windows-DotNETRuntime/GC/RestartEEStart //恢复之前暂停的托管线程
Microsoft-Windows-DotNETRuntime/GC/RestartEEStop //恢复托管线程运行完成(你可以在事件视图中看到这些内容)在一个典型的阻塞式GC中这意味着所有短暂的GC和完全阻塞的GC事件发生顺序非常简单:GC/SuspendEEStart
GC/SuspendEEEnd – 暂停托管线程完成
GC/Start
GC/End – actual GC is done
GC/RestartEEStart
GC/RestartEEEnd – 恢复托管线程运行完成GC/SuspendEEStart和GC/SuspendEEEnd是用于暂停GC/RestartStart和GC/RestartEEEnd是用于恢复。恢复只需要很少的时间所以我们不需要讨论它。暂停是可能需要很长时间的。BGC要复杂得多一个完整的BGC事件序列看起来是这样的1) GC/SuspendEEStart
2) GC/SuspendEEStop
3) GC/Start – BGC/ starts- there might be an ephemeral GC happen here, if so youd see这里可能有一个短暂的GC发生如果是这样你会看到
GC/Start
GC/Stop4) GC/RestartEEStart
5) GC/RestartEEStop – done with the initial suspension (完成了最初的暂停)- there might be 0 or more foreground ephemeral GC/s here, an example would be (这里可能有0个或更多的前台瞬时的GC/s一个例子是)
GC/SuspendEEStart
GC/SuspendEEStop
GC/Start
GC/Stop
GC/RestartEEStart
GC/RestartEEStop6) GC/SuspendEEStart
7) GC/SuspendEEStop
8) GC/RestartEEStart
9) GC/RestartEEStop – done with BGC/s 2nd suspension 完成了BGC/的第二次停牌- there might be 0 or more foreground ephemeral GC/s here (这里可能有0个或更多的前台短暂GC/s)10) GC/Stop – BGC/ Stops所以BGC在它的中间有两对暂停/重启。目前在GCStats视图中我们实际上是将这两个暂停合并在一起我正计划将它们分开但如果你确实看到一个长的BGC暂停你总是可以使用事件视图来找出哪个暂停是长的。在下面的例子中我从事件视图中复制并粘贴了一个客户跟踪的事件序列它遇到了我提到的bug导致长时间暂停。Event NameTime MSecReasonCountDepthTypeexplanationGC/Start160,551.74AllocSmall1882BackgroundGCGC/Start160,551.89AllocSmall1890NonConcurrentGCWe are doing a gen0 at the beginning of this BGC这个BGC开始时正在做一个gen0GC/Stop160,577.481890GC/RestartEEStart160,799.87Theres a long period of time here between last event and this one due to the bug由于错误在上次活动和这次活动之间这里有一段很长的时间GC/RestartEEStop160,799.91GC/SuspendEEStart161,803.36SuspendForGC188A Foreground gen1 happens前台gen1发生GC/SuspendEEStop161,803.42GC/Start161,803.61AllocSmall1901ForegroundGCGC/Stop161,847.141901GC/RestartEEStart161,847.15GC/RestartEEStop161,847.23The Foreground gen1 ends前台gen1结束GC/SuspendEEStart161,988.57SuspendForGCPrep188BGCs 2nd suspension starts with SuspendForGCPrep as its reasonBGC的第二次暂停开始理由是SuspendForGCPrepGC/SuspendEEStop161,988.71GC/RestartEEStart162,239.84GC/RestartEEStop162,239.94BGCs 2nd suspension ends, another long pause due to the same bugBGC的第二次停顿结束由于同样的错误又一次长时间停顿GC/Stop162,413.701882我所做的是在CPU堆栈视图中查找那些长时间停顿的时间范围160,577.482-160,799.868和161,988.57-162,239.94发现了这个错误。PerfView GC停止触发器有3个GC特定的停止触发器 -Trigger nameWhat it measuresStopOnGCOverMsectrigger if the time between GC/Start and GC/Stop is over this value, and its not a BGC如果GC/Start和GC/Stop之间的时间超过这个值并且不是BGC则触发。StopOnGCSuspendOverMSectrigger if the time between GC/SuspendEEStart and GC/SuspendEEStop is over this value如果GC/SuspendEEStart和GC/SuspendEEStop之间的时间超过这个值则触发。StopOnBGCFinalPauseOverMSectrigger if the time between GC/SuspendEEStart (with Reason SuspendForGCPrep) and GC/RestartEEStop is over this value如果GC/SuspendEEStart(与原因是 SuspendForGCPrep)和GC/RestartEEStop之间的时间超过这个值则触发。我通常与/StopOnGCOverMSec和/StopOnBGCFinalPauseOverMSec一起使用的命令行是 --PerfView.exe /nogui /accepteula /StopOnGCOverMSec:15 /Process:A /DelayAfterTriggerSec:0 /CollectMultiple:3 /KernelEventsdefault /ClrEvents:GCStack /BufferSize:3000 /CircularMB:3000 /Merge:TRUE /ZIP:True collect如果你的进程被称为A.exe你会想指定/Process:A。我在这篇博客中对每个参数都有详细解释。调试一个随机的长GC这里有一个例子演示了如何调试一个突然比其他升代了类似数量的GC要长很多的GC。我用上面的命令行收集了一个跟踪我可以在GCStats中看到有一个GC比15个长 - 它是GC#4022是20.963ms而且它没有比正常情况下更多的升代你可以看到在它上面的gen0升代的数量非常相似但花的时间却少很多。所以我在CPU堆栈视图中输入GC#4022的开始和结束时间戳30633.741到30654.704我看到对于执行实际GC工作的coreclr!SVR::gc_heap::gc_thread_function有两部分没有CPU占用而应该有很多--____ 部分意味着没有CPU占用。此我们可以在CPU堆栈视图中突出显示第一个平面部分右击它并选择 设置时间范围。这将向我们显示这个进程在这段时间内的CPU样本当然我们将看到没有。我们看到mpengine模块它来自MsMpEng.exe进程双击mpengine单元会告诉你它属于哪个进程。要确认这个进程干扰了我们的进程就是在Events中输入开始和结束的时间戳然后看一下原始的CPU样本事件如果你不知道如何使用这个视图请看PerfView中的其他相关视图部分 -你可以看到MsMpEng.exe进程的样本的优先级非常高--15。服务器GC线程运行的优先级是11左右。为了调试长时间的暂停我通常采取ThreadTime跟踪其中包括ContextSwitch和ReadyThread事件--它们是大量的但应该准确地告诉我们GC线程在调用SuspendEE时正在等待什么-PerfView.exe /nogui /accepteula /StopOnGCSuspendOverMSec:200 /Process:A /DelayAfterTriggerSec:0 /CollectMultiple:3 /KernelEventsThreadTime /ClrEvents:GCStack /BufferSize:3000 /CircularMB:3000 /Merge:TRUE /ZIP:True collect然而ThreadTime追踪可能太多可能会导致你的应用程序运行得不够 正常无法表现出你所调试的行为。在这种情况下我会从默认的内核事件开始追踪这通常会揭示问题或给你足够的线索。你可以简单地把ThreadTime替换成Default -PerfView.exe /nogui /accepteula /StopOnGCSuspendOverMSec:200 /Process:A /DelayAfterTriggerSec:0 /CollectMultiple:3 /KernelEventsDefault /ClrEvents:GCStack /BufferSize:3000 /CircularMB:3000 /Merge:TRUE /ZIP:True collect我在这篇博客中有一个详细的调试长悬挂问题的例子。大尺寸的GC堆如果你不知道如何收集GC堆大小数据请按照如何收集顶级GC指标中的说明进行操作。调试OOM在我们谈论大的GC堆大小作为一个一般的问题类别之前我想特别提到调试OOM因为这是一个大多数读者都熟悉的例外。而且有些人可能已经使用了SoS !AnalyzeOOM 命令它可以显示2个方面--1是否确实存在一个托管堆的OOM。因为GC堆只是你进程中的一种内存使用OOM不一定是GC堆造成的2如果是托管堆OOM什么操作造成的例如GC试图保留一个新段但做不到你在64位上永远不会真正看到这个或在试图做分配时无法提交。在不使用SoS的情况下你也可以通过简单地查看GC堆使用多少内存与进程使用多少内存来验证GC堆是否是OOM的罪魁祸首。我们将在下面讨论堆大小的分析。如果你确认GC堆占用了大部分的内存而且我们知道OOM只是在GC非常努力地减少堆的大小但是不能之后才被抛出这意味着有大量的内存由于用户根而幸存下来这意味着GC无法回收它。而你可以按照管理性内存泄露调查来弄清楚哪些内存幸存下来。现在让我们来谈谈这样的情况你没有得到OOM但需要看一下堆的大小看看是否可以或如何优化它。在如何正确看待GC堆的大小 一节中我们谈到了堆的大小以及如何广泛地测量。所以我们知道堆的大小在很大程度上取决于你在GC发生时的测量和分配预算。GCStats视图显示了GC进入和退出时的大小即峰值和后值。剖析一下这些尺寸是有帮助的。After MB这一栏是以下的总和Gen0 MB Gen1 MB Gen2 MB LOH MB还注意到Gen0 Frag %说的是99.99%。我们知道这是由于pinning。因此部分gen0分配将适合于这种碎片化。所以对于GC #2来说在GC #1结束时以26.497 MB开始然后分配了101.04 MB在GC #2开始时以108.659 MB的大小结束。峰值尺寸太大但GC后尺寸不大如果是这种情况这通常意味着在触发下一次GC之前有太多的gen0分配。在.NET Core 3.0中我们启用了一个限制这种情况的配置叫做GCGen0MaxBudget--我通常不建议人们设置这个配置因为你可能会把它设置得太小从而导致GC过于频繁。这是为了限制gen0的最大分配预算。当你使用Server GC时GC在设置gen0预算时相当积极因为它认为进程使用机器上的大量资源是可以的。这通常是可以的因为如果一个进程使用了服务器GC往往意味着它可以负担得起使用大量的资源。但是如果你确实有这样的情况你对更频繁地触发GC以交换到更小的堆大小没有意见你可以用这种配置来做。我的希望是在未来我们将使这成为一些高级配置的一部分允许你向我们传达你想做这种交换这样GC就可以为你自动调整而不是你自己使用一个非常低级的配置。在过去使用GC性能计数器的人认识到 #Total Committed Bytes 计数器他们问如何在.NET Core中获得这个计数器。首先如果你用这种方式测量已提交的字节你可能会看到它更接近峰值大小而不是之后的大小这是因为在短暂段上对已提交的特殊处理。因为 After size 没有报告我们将要使用但还没有使用的gen0预算部分。所以你可以直接使用GCStats中报告的峰值大小作为你的近似的总投入。但如果你使用的是.NET 5你可以通过调用我们前面提到的GetGCMemoryInfoAPI得到这个数字--它是GCMemoryInfo结构上返回的属性之一。有一个不太方便的方法就是每堆历史事件中的ExtraGen0Commit字段。你可以在你已经得到的堆大小信息即在GCHeapStats事件中的基础上添加这个字段如果你使用的是服务器GC它将是所有堆的ExtraGen0Commit之和。但是我们没有在PerfView的用户界面中公开这一点所以你需要自己去使用TraceEvent库来获得这些信息。GC后尺寸很大如果是这样大部分的尺寸是在gen2/LOH中吗你是否主要在做后台GC不压缩如果你已经在做完全阻塞的GC而After的大小还是太大这仅仅意味着你有太多的数据存活下来。你可以按照管理性内存泄露调查来弄清楚存活的数据。另一种可能的情况是有很大比例的堆在gen0中但大部分是碎片。这种情况会发生特别是当你把一些对象钉住了很久而且它们在堆上足够分散时。所以即使GC已经降代它们到了gen0只要这些引脚没有消失堆的那一部分仍然不能被回收。你可以收集GCHandle事件来计算它们何时被钉住。PerfView的命令行是perfview /nogui /KernelEventsProcessThreadImageLoad /ClrEvents:GCStackGCHandle /clrEventLevelInformational collectgen2 GC是否主要为后台GC如果GC主要是后台GC那么需要看看碎片的使用是否高效也就是说当后台GC开始时gen2 Frag %是否非常大如果不是非常大这意味着它的工作是最优化的。否则这表明后台GC的调度问题--请让我知道。要看后台GC开始时的碎片情况你可以使用GCStats视图中的Raw XML链接来查看它。我已经把数据修剪成只有相关的部分-GCEvent GCNumber 1174 GCGeneration2 Type BackgroundGC Reason AllocSmallPerHeapHistoryGenData NameGen2 SizeBefore187,338,680 SizeAfter187,338,680 ObjSpaceBefore177,064,416 FreeListSpaceBefore10,200,120 FreeObjSpaceBefore74,144/GenData NameGenLargeObj SizeBefore134,424,656 SizeAfter131,069,928 ObjSpaceBefore132,977,592 FreeListSpaceBefore1,435,640 FreeObjSpaceBefore11,424/SizeBefore ObjSpaceBefore FreeListSpaceBefore FreeObjSpaceBeforeSizeBefore 这一代的总规模ObjSpaceBefore 这一代的有效对象所占的大小FreeListSpaceBefore 这一代的自由列表所占的大小FreeObjSpaceBefore 在这一代中太小的自由对象所占用的大小不能进入自由列表。(FreeListSpaceBefore FreeObjSpaceBefore) 就是我们所说的碎片化在这种情况下我们看到FreeListSpaceBefore FreeObjSpaceBefore/ SizeBefore是5%这是相当小的这意味着我们已经用掉了大部分BGC建立好的自由空间。当然我们希望看到这个比例越小越好但如果自由空间太小就意味着GC可能无法使用它们。一般来说如果这个比例是15%或更小我不会担心除非我们看到自由空间足够大但没有被使用。你也可以从我们前面提到的GetGCMemoryInfoAPI中获得这些数据。你看到的堆的大小从GC的角度来看是合理的但仍然希望有一个更小的堆在你经历了上述情况后你可能会发现从GC的角度来看这一切都可以解释。但如果你仍然希望堆的大小更小呢你可以把你的进程放在一个内存受限的环境中也就是说一个有内存限制的容器这意味着GC会自动识别为它可以使用的内存。然而如果你使用的是Server GC你至少要升级到.NET Core 3.0它对容器的支持更加强大。在该版本中我们还添加了2个新的配置允许你指定GC堆的内存限制 - GCHeapHardLimit和GCHeapHardLimitPercent。它们在本博客文章中得到了解释。当你的进程运行在有其他进程的机器上时GC开始反应的默认内存负载可能不是每个进程都想要的。你可以考虑使用GCHighMemPercent配置并将该阈值设置得更低--这将使GC更积极地进行完全阻塞的GC所以即使有内存可用它也不会使你的堆增长得那么多。GC是否为自己的记账工作使用了太多的内存偶尔我也收到一些人的报告他们确实观察到有一大块内存被用于GC记账。你可以通过GC的gc_heap::grow_brick_card_tables的VirtualAlloc调用看到。这是因为由于在地址空间中保留了一些意想不到的区域堆的范围被拉得太长了。如果你确实遇到了这个问题并且无法防止意外的保留你可以考虑用GCHeapHardLimit/GCHeapHardLimitPercent指定一个内存限制那么整个限制将被提前保留这样你就不会遇到这个问题了。性能问题的明确迹象如果你看到以下任何情况毫无疑问你有性能问题。与任何性能问题一样正确确定优先次序总是很重要的。例如你可能有很长的GC暂停但如果它们不影响你所关心的性能指标你把时间花在其他地方会更有成效。我使用PerfView中的GCStats视图来显示这些症状。如果你不熟悉这个视图请看本节。你不一定要使用PerfView只要能够显示下面的数据使用任何工具都可以。暂停时间太长暂停通常在每次发生时都会少于1ms。如果你看到的是10秒或100秒的东西你不需要怀疑你是否有宁问题--这是一个明确的信号说明你有。如果你看到你的大部分GC暂停都被暂停占用了尤其是持续的暂停而且你的总GC暂停太多你肯定应该调试它。我在这篇博客中有一个详细的调试长暂停问题的例子。这可以通过GCStats视图中的 Suspend Msec 和 Pause Msec 列来表示。我模拟了一个例子 --GC Index(索引)Suspend Msec(暂停时间)Pause Msec(停顿时间)1015018011190200两个GCs的大部分停顿时间都是在暂停中度过的。随机的长时间GC停顿随机长的GC停顿 意味着你突然看到一个GC并没有比平时升代更多但却需要更长的时间。下面是一个模拟的例子GC Index(索引)Suspend Msec(暂停时间)Pause Msec(停顿时间)Promoted MB(升代MB)100.0152.0110.012002.1120.0162.2所有的GCs都升代了~2MB但是GC#10和#12花了几毫秒而GC#11花了200。这就说明在GC#11期间出了问题。有时你可能会看到突然花了很长时间的GC也招致了很长时间的暂停因为导致长时间暂停的原因也影响了GC的工作。我已经给出了一个例子上面如何调试这个问题。大多数GC是完全阻塞的GC如果你看到大多数GC是完全阻塞的如果你有一个大的堆这通常需要相当长的时间这就是一个性能问题。我们不应该一直做完全阻塞的GCs就是这样。即使你处于高内存负载的情况下做完全阻塞GC的目的是为了减少堆的大小这样内存负载就不再高了。而GC有办法应对有挑战的情况比如针对高内存负载的临时模式沉重的固定以避免做更多的完全阻塞GC而不是必要。我见过的最常见的原因实际上是诱导的完全阻塞的GCs这对调试来说是很容易的因为GCStats会告诉你触发原因是诱导的。下面是一个模拟的例子GC IndexTrigger ReasonGenPause Msec10Induced2NI100011Induced2NI110012Induced2NI1000本节讲述了如何找出诱发GC的原因。有助于我们帮助你调试性能问题的信息在某些时候在你遵循本文件中的建议并做了详尽的调查后你仍然发现你的性能问题没有得到解决。我们很愿意帮助你! 为了节省你和我们的时间我们建议你准备以下信息 -运行时的文件版本每个版本都会有新的GC变化所以我们很自然地想知道你使用的是哪个版本的运行时以便我们知道该版本的运行时有哪些GC变化。所以提供这些信息是非常必要的。版本如何映射到 公共名称如.NET 4.7是不容易追踪的所以提供dll的 FileVersion 属性会对我们有很大帮助它可以告诉我们版本号与分支名称对于.NET Framework或实际提交对于.NET Core。你可以通过像这样的powerhell命令来获得这些信息PS C:\Windows\Microsoft.NET\Framework64\v4.0.30319 (Get-Item C:\Windows\Microsoft.NET\Framework64\v4.0.30319\clr.dll).VersionInfo.FileVersion
4.8.4250.0 built by: NET48REL1LAST_CPS C:\ (Get-Item C:\temp\coreclr.dll).VersionInfo.FileVersion
42,42,42,42424 Commit: a545d13cef55534995115eb5f761fd0cecf66fc1获得这些信息的另一个方法是通过调试器通过lmvm命令部分省略-0:000 lmvm coreclr
Browse full module list
start end module name
00007ff8f1ec0000 00007ff8f4397000 CoreCLR (deferred) Image path: C:\runtime-reg\artifacts\tests\coreclr\windows.x64.Debug\Tests\Core_Root\CoreCLR.dllImage name: CoreCLR.dllInformation from resource tables:FileVersion: 42,42,42,42424 Commit: a545d13cef55534995115eb5f761fd0cecf66fc1如果你捕捉到ETW跟踪你也可以找到KernelTraceControl/ImageID/FileVersion事件。它看起来像这样部分省略。ThreadID-1 ProcessorNumber2 ImageSize10,412,032 TimeDateStamp1,565,068,727 BuildTime8/5/2019 10:18:47 PM OrigFileNameclr.dll FileVersion4.7.3468.0 built by: NET472REL1LAST_C你已经进行了哪些诊断如果你已经按照本文件中的技术自己做了一些诊断这是强烈建议的请与我们分享你做了什么得出了什么结论。这不仅可以节省我们的工作还可以告诉我们我们提供的信息对你的诊断有什么帮助或没有帮助这样我们就可以对我们提供给客户的信息进行调整使他们的生活更轻松。性能数据就像任何性能问题一样在没有任何性能跟踪数据的情况下我们真的只能给出一些一般性的指导和建议。要真正找出问题所在我们需要性能跟踪数据。正如本文档中多次提到的性能跟踪是我们调试性能问题的主要方法除非你已经进行了诊断表明不需要顶级GC跟踪否则我们总是要求你收集这样的跟踪来开始。我通常也会要求你提供带有CPU样本的追踪特别是当我们要诊断长时间的GC暂停时。我们可能会要求你根据我们从最初的追踪中得到的线索收集更多的追踪信息。一般来说转储不太适合调查性能问题。但是我们了解有时可能无法获得跟踪而您所拥有的只是转储(dump)。如果情况确实如此请尽可能与我们分享即在没有隐私问题的情况下因为dump可能会泄漏源码和内存数据。另外插播一个小广告[苏州-同程旅行] - .NET后端研发工程师招聘中级及以上工程师优秀应届生也可以我会全程跟进从职位匹配到面试建议与准备再到面试流程和每轮面试的结果等。大家可以直接发简历给我。工作职责负责全球前三中文在线旅游平台机票业务系统的研发工作根据需求进行技术文档编写和编码工作任职要求拥有至少1年以上的工作经验优秀的候选人可放宽熟悉.NET Core和ASP.Net CoreC#基础扎实了解CLR原理包括多线程、GC等有DDD 微服务拆分 重构经验者优先能对线上常见的性能问题进行诊断和处理熟悉Mysql Redis MongoDB等数据库中间件并且进行调优必须有扎实的计算机基础知识熟悉常用的数据结构与算法并能在日常研发中灵活使用熟悉分布式系统的设计和开发包括但不限于缓存、消息队列、RPC及一致性保证等技术海量HC 欢迎投递薪资福利月薪15K~30K 根据职级不同有所不同年假10天带薪年假 春节提前1天放假 病假有补贴年终根据职级不同有 2-4 个月餐补有餐补自有食堂交通有打车报销五险一金基础五险一金12%的公积金、补充医疗、租房补贴等节日福利端午、中秋、春节有节日礼盒通讯补贴根据职级不同每个月有话费补贴 50~400简历投递方式大家把简历发到我邮箱即可记得一定要附上联系(微信 or 手机号)方式哟~邮箱(这是啥格式大家都懂)aW5jZXJyeUBmb3htYWlsLmNvbQ