CodeSnippet.Cn
代码片段
Csharp
架构设计
.NetCore
西班牙语
kubernetes
MySql
Redis
Algorithm
Ubuntu
Linux
Other
.NetMvc
VisualStudio
Git
pm
Python
WPF
java
Plug-In
分布式
CSS
微服务架构
JavaScript
DataStructure
Shared
.NET内存性能分析指南-内存基础知识
0
架构设计
小笨蛋
发布于:2023年05月06日
更新于:2023年05月16日
121
#custom-toc-container
> 作者信息:Maoni Stephens - 微软架构师,负责.NET Runtime GC设计与实现 博客链接 Github > 译者:Bing Translator、INCerry 博客链接:https://incerry.cnblogs.com 联系邮箱:incerry@foxmail.com > 本文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 --- ### 虚拟内存基础知识 我们通过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](https://docs.microsoft.com/zh-cn/dotnet/core/run-time-config/garbage-collector#high-memory-percent "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 GB | 1 GB | | 2 | 分配 2 GB | 3 GB | | 3 | 分配 0 GB | 3 GB | | 4 | GC发生(500M存活),然后分配1GB | 1.5 GB | | 5 | 分配 3 GB | 4.5 GB | 我们可以说,是的,有一个GC发生在第4秒,因为堆的大小比第3秒小。但我们再看看另一种可能性- 表格2 | 秒 | 动作 | 这一秒过后的堆大小 | | --- | --- | --- | | 1 | 分配 1 GB | 1 GB | | 2 | 分配 2 GB | 3 GB | | 3 | GC发生(1GB存活),然后分配2GB | 3 GB | | 4 | 分配 1 GB | 4 GB | 如果我们只有堆的大小数据,我们就不能说GC是否已经发生。 这就是为什么测量GC发生时的堆大小是很重要的。自然,GC本身提供的部分性能测量数据正是如此 - 每次GC前后的堆大小,也就是说,每次GC的开始和结束(以及其他大量的数据,我们将在本文的后面看到)。不幸的是,许多内存工具,或者我经常看到人们采取的诊断方法,都没有考虑到这一点。他们做内存诊断的方式是 "让我给你看看在你碰巧问起的时候堆是什么样子的"。这通常是没有帮助的,有时甚至是完全误导的。这并不是说像这样的工具完全没有帮助 - 当问题很简单的时候,它们可能会有帮助。如果你有一个已经持续了一段时间的非常大的内存泄漏,并且你使用了一个工具来显示你在那个时候的堆(要么通过采取进程转储和使用SoS,要么通过另一个工具来转储堆),那找到什么东西在泄露内存就真的很容了。这是性能分析中的一个常见模式 - 问题越严重,就越容易找出问题。但是,当你遇到的性能问题不是这种显而易见的情况时,这些工具就显得不足了。 ##### 分配预算 看完上一段,思考分配预算的一个简单方法是上一次GC退出时的堆大小和这次GC进入时的堆大小之间的差异。因此,分配预算是指在触发下一次GC之前,GC允许多少分配。在表1和表2中,分配预算是一样的 - 3GB。 然而,由于.NET GC支持钉住对象(防止GC移动被钉住的对象)以及钉住的复杂情况,分配预算往往不是2个堆大小之间的区别。然而,预算是 "在触发下一次GC之前的分配量 "的想法仍然成立。我们将在本文档的后面讨论更多关于钉住的问题( 后面的内容.)。 当试图提高内存性能时,我看到人们经常做的一件事(或只做一件事)是减少分配。如果你真的可以在性能关键路径开始之前预先分配所有的东西,我说你更有更多的权利!但是,这有时是非常不实际的。例如,如果你使用的是库,你并不能完全控制它们的分配(当然,你可以尝试找到一种无分配的方式来调用API,但并不保证有这样的方式,而且它们的实现可能会改变)。 那么,减少分配是一件好事吗?是的,只要它确实会对你的应用程序的性能产生影响,并且不会使你的代码中的逻辑变得非常笨拙或复杂,从而使它成为一个值得的优化。减少分配实际上会降低性能吗?这完全取决于你是如何减少分配的。你是在消除分配还是用其他东西来代替它们?因为用其他东西代替分配可能不会减少GC所要做的工作。 ##### 分代GC的影响 .NET的GC是[分代](https://en.wikipedia.org/wiki/Tracing_garbage_collection#Generational_GC_(ephemeral_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的对象 [![](/uploads/images/20230506/141713-9d33b358cb4d44e898a72f54fb49f107.jpg)](https://www.codesnippet.cn) 一个对象 "死了 "和它被清理掉之间的区别可能会让人困惑。我收到的一个常见问题是:"我不再保留我的对象了,而且我看到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](https://docs.microsoft.com/en-us/dotnet/core/run-time-config/garbage-collector#large-object-heap-threshold "GCLOHThreshold")配置来调整更高。[LOH](https://docs.microsoft.com/en-us/dotnet/api/system.runtime.gcsettings.largeobjectheapcompactionmode?view=netcore-3.1 "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堆的段 [![](/uploads/images/20230506/142322-8e7d9d609a3c473b8d70e934483eb4a9.jpg)](https://www.codesnippet.cn) 随着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堆段 [![](/uploads/images/20230506/142524-21ec4930dbf846c78dd0559800ce20b5.jpg)](https://www.codesnippet.cn) 在gen0的GC之后,由于它只能收集gen0的空间,我们可能会看到这个: 图4 - gen0 GC后的GC堆段 [![](/uploads/images/20230506/142629-4c0a11b5ce0e48ff95c6ff76a996c474.jpg)](https://www.codesnippet.cn) 大多数时候,你不必关心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的[图解](https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/workstation-server-gc "图解")。既然我们现在谈到了服务器和并发/后台GC,你可能会问服务器GC也有并发的吗?答案是肯定的。我再次向你推荐[MSDN doc](https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/background-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堆/线程。这些配置的解释是[这里](https://devblogs.microsoft.com/dotnet/middle-ground-between-server-and-workstation-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不能回收它。目前,以下[用户暴露的句柄类型](https://docs.microsoft.com/en-us/dotnet/api/system.runtime.interopservices.gchandletype?view=netcore-3.1 "用户暴露的句柄类型")是强句柄。Strong和Pinned;而弱柄是Weak和WeakTrackResurrection。但是如果你看过SoS的 !gchandles输出,Pinned句柄也可以包括AsyncPinned。 ####### 钉住 我在上面提到过几次钉住。大多数人都知道钉住是什么 - 它向GC表示一个对象不能被移动。但从GC的角度来看,钉住的意义是什么呢?由于GC不能移动这些被钉住的对象,它使被钉住的对象之前的死角变成了一个自由对象,这个自由对象可以用来容纳年轻一代的生存者。但这里有一个问题 - 正如我们从上面的代际讨论中看到的,如果我们简单地将这些被钉住的对象提升到老一代,就意味着这些自由空间也是老一代的一部分,要用它们来容纳年轻一代的幸存者,唯一的办法就是我们真的对年轻一代做一次GC(否则我们甚至没有 "年轻一代的幸存者")。然而,如果我们能在gen0中保留这些自由空间,它们就可以被用户分配使用。这就是为什么GC有一个叫做降代的功能,我们将把这些被钉住的对象降代到gen0,这意味着它们之间的空闲空间将是gen0的一部分,当用户代码分配时,我们可以立即使用它们。 图5 - 降代(我从一张旧的幻灯片上取下来的,所以这看起来与之前的片段图片有些不同。) [![](/uploads/images/20230506/143518-bee1fe494e8440239994755a8a5c6b55.jpg)](https://www.codesnippet.cn) 由于gen0分配可以发生在这些自由空间中,这意味着它们将消耗gen0预算而不增加gen0的大小(除非自由空间不能满足所有的gen0预算,在这种情况下它将需要增长gen0)。 然而,GC 不会无条件地降代,因为我们不想在 gen0 中留下许多固定对象,这意味着我们必须在每次 GC 中再次查看它们,可能会有很多次 GC(因为它们是 gen0 的一部分,每当我们执行 gen0 GC 我们需要查看它们)。 这意味着如果您遇到严重的固定情况,它仍然会导致 gen2 中的碎片问题。 同样,GC 确实有机制来应对这些情况。 但是如果你想对 GC 施加更少的压力,你可以从用户的 POV 中遵循这个规则— > 早点钉住对象,分批钉住对象 我们的想法是,如果你把对象钉在已经整理的那部分堆里,意味着这些对象已经不需要移动了,所以碎片化就不是问题。如果你以后确实需要钉住,通常的做法是分配一批缓冲区,然后把它们钉在一起,而不是每次都分配一个并钉住它。在.NET 5中,我们引入了一个名为POH([Pinned Object Heap](https://github.com/dotnet/runtime/blob/master/docs/design/features/PinnedHeap.md "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 是可终结的 [![](/uploads/images/20230506/143730-7100ccd07f7e46f184b78424d02469c4.jpg)](https://www.codesnippet.cn) ###### 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指标)。 表格3 | `Application 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堆的大小就没有意义了。
这里⇓感觉得写点什么,要不显得有点空,但还没想好写什么...
返回顶部
About
京ICP备13038605号
© 代码片段 2024