编写高性能.Net代码之-----第2章垃圾回收
垃圾回收
在.NET环境中,内存分配的工作量很小,因为内存总是整段分配的,通常情况下不会比内存的扩大、波小或比较增加多少开铺。在通常情况下,不存在需要遍历的空闲内存列表,也几乎不可能出现内存碎片。其实GC内存堆的效率还会更高,因为连续分配的多个对象往往在内存堆中也是连续存放的,提高了就近访问的可能性(Locality)。在默认的内存分配流程中,会有一小段代码先检查目标对象的大小,看看内存分配缓冲区中所剩的内存还够不够用。只要缓冲区还够用,内存分配过程就十分迅速,不存在资源争用问题。如果内存分配缓冲区已被耗尽,就会交由ac分配程序来检索足以容纳目标对象的空闲内存。然后一个新的分配缓冲区会被保留下来,用于以后的内存分配。上述内存分配过程的汇编代码只是一-小段指令,分析这些代码是很有价值的。简单演示内存分配过程的C#代码如下。
c1ass Myobject{
int x:
int y:
int
}
static void Main(string[] args)
{
var x = new Myobject();
}
首先,让我们分解一下。以下是调用内存分配函数的代码。
下面是实际的分配函数。
总之,以上过程只用了1个直接方法调用和9条指令。完美无暇,无懈可击。如果你的垃圾回收配置成服务器模式,内存分配过程就没有快速和慢速之分,因为每个处理器都有各自的内存堆。.NET的内存分配流程比较简单,而解除分配的过程则复杂得多,但这个复杂的过程不需要你直接处理。你只需要学习如何优化即可,也就是本章将教会你的
内容。本书之所以要从垃圾回收开始,是因为后续的很多内容都会与本章有关联。理解垃圾回收器对程序的影响,是获得理想性能的重要基础,垃圾回收器几乎会影响到其他所有的性能因素。
2.1运作方式
垃圾回收器的决策过程正在变得越来越优雅,特别是随着高性能系统越来越普遍地采
用.NET环境。下面介绍的内容可能有一-些会在未来的NET版本中发生变化,但最近一段时间内好像还不太会发生整体性的改变。在托管进程中存在两种内存堆(本机堆和托管堆)。本机内存堆(Native Heap)是由VirtualAlloc这个Windows API分配的,是由操作系统和CLR使用的,用于非托管代码所需的内存,比如Windows API、 操作系统数据结构、很多CLR数据等。CLR在托管堆(Managed Heap)上为所有.NET托管对象分配内存,也被成为GC堆,因为其中的对象均要受到垃圾回收机制的控制。托管堆又分为两种一小对象堆和大对象堆 (LOH), 两者各自拥有自己的内存段(Segment)。每个内存段的大小视配置和硬件环境而定,对于大型程序可以是几百MB或更大。小对象堆和LOH都可拥有多个内存段。小对象堆的内存段进- -步划分为3代,分别是0、1、2代。第0代和第1代总是位于同一个内存段中,而第2代可能跨越多个内存段,LOH也可以跨越多个内存段。包含第0代和第1代堆的内存段被称为暂时段(Ephemeral Segment)。一开始内存堆就如下所示,两个内存段分别被标为A和B,内存地址从左到右由小变大。小对象堆由A段内存构成,LOH拥有B段内存。第2代和第1代堆只占有开头的一点内存,因为它们还都是空的。
下面有必要介绍一下在小对象堆中分配内存的对象的生存期。如果对象小于85 000字节,CLR都会把它分配在小对象堆中的第0代,通常紧挨着当前已用内存空间往后分配。因此,正如本章开头所示,.NET的内存分配过程非常迅速。如果快速分配失败,对象就可能会被放人第0代内存堆中的任意地方,只要能容纳得下就行。如果没有合适的空闲空间,那么分配器就会扩大第0代内存堆,以便能存人新对象。如果扩大内存堆时超越了内存段的边界,则会触发垃圾回收过程。对象总是诞生于第0代内存堆。只要对象保持存活,每当发生垃圾回收时,GC都会把它提升一代。第0代和第1代内存堆的垃圾回收有时候被称为瞬时回收(EphemeralCollection)。在发生垃圾回收时,可能会进行碎片整理(Compaction), 也就是GC把对象物理迁移到新的位置中去,以便让内存段中的空闲空间能够连续起来以备使用。如果未发生碎片整理,那就只需要重新调整各块内存的边界即可。在经历了几次未做碎片整理的垃圾回收之后,内存堆的分布可能会如下所示。
对象的位置没有移动过,但各代内存堆的边界已经发生了变化。每一代内存堆都有可能发生碎片整理。因为GC必须修正所有对象的引用,使它们指向新的位置,所以碎片整理的开销相对较大,还有可能需要暂停所有托管线程。正因如此,垃圾回收器只在划算(Productive) 时才会进行碎片整理,判断的依据是一些内部指标。如果对象到达了第2代内存堆,它就会一直留在那里直至终结。这并不意味着第2代内存堆只会一直变大。如果第2代内存堆中的对象都终结了,整个内存段也没有存活的对象了,垃圾回收器会把整个内存段交还给操作系统,或者作为其他几代内存堆的附加段。在进行完全垃圾回收(Full Garbage Collection)时,就可能发生这种第2代内存堆的回收。那么“存活”是什么意思呢?如果GC能够通过任- -已知的GC根对象(Root), 沿着层层引用访问到某个对象,那它就是存活的。GC根对象可以是程序中的静态变量,或者某个线程的堆栈被正在运行的方法占用(用于局部变量),或者是GC句柄(比如固定对象的句柄,Pinned Handle),或是终结器队列(Finalizer Queue)。请注意,有些对象可能没有受GC根对象的引用,但如果是位于第2代内存堆中,那么第0代回收是不会清理这些对象的,必须等到完全垃圾回收才会被清理到。如果第0代堆即将占满一个内存段,而且垃圾回收也无法通过碎片整理获取足够的空闲内存,那么GC会分配一个新的内存段。新的内存段会用于容纳第1代和第0代堆,老的内存段将会变为第2代堆。老的第0代堆中的所有对象都会被放入新的第1代堆中,老的第1代堆同理将提升为第2代堆(提升很方便,不必复制数据)。现在的内存段将如下所示。
如果第2代堆继续变大,就可能会跨越多个内存段。LOH堆同样也可能跨越多个内存段。无论存在多少个内存段,第0代和第1代总是位于同一个段中。以后我们想找出内存堆中有哪些对象存活时,这些知识将会派上用场。LOH则遵从另一套回收规则。大于85 000字节的对象将自动在LOH中分配内存,且没有什么“代”的模式。超过这个尺寸的对象通常也就是数组和字符串了。出于性能考虑,在垃圾回收期间LOH不会自动进行碎片整理,但从.NET 4.5.1 开始,必要时你也可以人为发起碎片整理。与第2代内存堆类似,如果LOH的内存不再有用了,就可能会被用于其他内存堆。不过我们以后将会看到,理想状态下你根本就不会愿意让LOH的内存被回收掉。在LOH中,垃圾回收器用一张空闲内存列表来确定对象的存放位置。本章中我们会讨论一些减少LOH碎片的技巧。
垃趿回收是针对某- -代及其以下几代内存堆进行的。如果回收了第1代,则也会同时回收第0代。如果回收了第2代,则所有内存堆都会回收,包括LOH。如果发生了第0代或第.1代垃圾回收,那么程序在回收期间就会暂停运行。对于第2代垃圾回收而言,有部分回收是在后台线程中进行的,这要根据配置参数而定。
垃圾回收包含4个阶段。
1.挂起(Suspension) 一在垃圾回收发生之前, 所有托管线程都被强行中止。
2.标记(Mark)一从GC根对象开始,垃圾回收器沿着所有对象引用进行遍历并把所见对象记录下来。
3.碎片整理(Compact)一将对 象重新紧挨着存放并更新所有引用,以便减少内存碎片。在小对象堆中,碎片整理会按需进行,无法控制。在LOH中,碎片整理不会自动进行,但你可以在必要时通知垃圾回收器来上- -次。
4.恢复(Resume) 一-托管 线程恢复运行。在标记阶段并不需要遍历内存堆中的所有对象,只要访问那些需要回收的部分即可。比
如第0代回收只涉及到第0代内存堆中的对象,第1代回收将会标记第0代和第1代内存堆中的对象。而第2代回收和完全回收,则需遍历内存堆中所有存活的对象,这--过程的开销有可能非常大。这里有个小问题需要注意,高代内存堆中的对象有可能是低代内存堆对象的根对象。这样就会导致垃圾回收器遍历到一部分高代内存堆的对象,但这样的回收开销还是小于高代内存堆的完全垃圾回收。
由.上述讨论可以形成以下几点重要结论。
第一,垃圾回收过程的耗时几乎完全取决于所涉及“代”内存堆中的对象数量,而不是你分配到的对象数量。这就是说,即使你分配了1棵包含100万个对象的树,只要在下一次垃圾回收之前把根对象的引用解除掉,这100万个对象就不会增加垃圾回收的耗时。
第二,垃圾回收的频率取决于所涉及“代”内存堆中已被占用的内存大小。只要已分配内存超过了某个内部阈值,就会发生该“代”垃圾回收。这个阈值是持续变化的,GC会根据进程的执行情况进行调整。如果某“代”回收足够划算(提升了很多对象所处的“代”),那垃圾回收就会发生得频繁-一些, 反之亦然。另一个触发垃圾回收的因素是所有可用内存,与你的应用程序无关。如果可用内存少于某个阈值,为了减少整个内存堆的大小,垃圾回收可能会更为频繁地发生。
由上所述,貌似垃圾回收是难以控制的,但事实不是这样。通过控制内存分配模式来控
制垃圾回收的统计指标,就是一一种最容易实现的优化方法。这需要理解垃圾回收的工作机制、
可,用的配置参数、你的内存分配率,还需要对对象的生存期有很好的控制能力。
2.2配置参数
2.2.1工 作站模式还是服务器模式
最重要的垃圾回收参数选择是采用工作站(Workstation) 模式还是服务器(Server)模式。垃圾回收默认采用工作站模式。在工作站模式下,所有的GC都运行于触发垃圾回收的线程中,优先级(Priority) 也相同。工作站模式非常适用于简单应用,特别是那些运行在人机交互型工作站(Interactive Workstation).上的应用, 机器.上会运行着多个托管进程。对于单处理器的计算机而言,工作站模式是唯一选择, 配置成其他参数也是无效的。在服务器模式下,GC会为每个逻辑处理器或处理器核心创建各自专用的线程。这些线程的优先级是最高的(THREAD_ PRIORITY_ HIGHEST),但在需要进行垃圾回收之前会- -直保持挂起状态。垃圾回收完成后,这些线程会再次进入休眠(Sleep) 状态。此外,CLR还会为每个处理器创建各自独立的内存堆。每个处理器堆都包含1个小对象堆和1个LOH。从应用程序角度来看,就只有一个逻辑内存堆,你的代码不清楚对象属于哪一个堆,对象引用会在所有堆之间交叉进行(这些引用共用相同的虛拟地址空间)。
多个内存堆的存在会带来一些好处。
1.垃圾回收可以并行进行,每个垃圾回收线程负责回收-一个内存堆。这可以让垃圾回收的速度明显快于工作站模式。
2.在某些情况下,内存分配的速度也会更快- -些,特别是对LOH而言,因为会在所有内存堆中同时进行分配。服务器模式还有一点与工作站模式不同,就是拥有更大的内存段,也就意味着垃圾回收的间隔时间可以更长-一些。
请在app.config文件的<runtime>节点下把垃圾回收配置为服务器模式。
<configuration>
<runtime>
<gcServer enabled="true"/>
</ runtime>
</configuration>
到底是用工作站还是服务器模式进行垃圾回收呢?如果应用程序运行于专为你准备的多处理器主机.上,那就无疑要选择服务器模式。这样在大部分情况下,都能让垃圾回收占用的时间降至最低。不过,如果需要与多个托管进程共用一台主机,那么选择就不那么明确了。服务器模式的垃圾回收会创建多个高优先级的线程。如果多个应用程序都这么设置,那线程调度就会相互带来负面影响。这时可能还是选用工作站模式垃圾回收更好。如果你确实想让同一台主机.上的多个应用程序使用服务器模式的垃圾回收,还有一-种做法,就是让存在竞争关系的应用程序都集中在指定的几个处理器上运行,这样CLR只会为这.些处理器创建自己的内存堆。无论你怎么选择,本书给出的大部分技巧对两种垃圾回收模式都适用。
2.2.2后台垃圾回收
后台垃圾回收(Background GC)只会影响第2代内存堆的垃圾回收行为。第0代和第1代的垃圾回收仍会采用前台垃圾回收,也就是会阻塞所有应用程序的线程。后台垃圾回收由一个专用的第2代堆垃圾回收线程完成。对于服务器模式的垃圾回收而言,每个逻辑处理器都拥有-一个额外的后台GC线程。没错,这就是说,如果采用服务器模式垃圾回收和后台垃圾回收,那每个处理器就会有两个GC专用线程,但这没什么值得特别关注的。拥有多个线程并不会为进程带来多大负担,特别是大多数线程在大部分时间都是无事可干的。后台垃圾回收与应用程序的线程是并行发生的,但也有可能同时发生了阻塞式垃圾回收。这时,后台GC线程会和其他应用程序线程一起暂停运行, 等待阻塞式垃圾回收的完成。如果你正在使用工作站模式垃圾回收,那后台垃圾回收就会- -直开启。从.NET 4.5开始,服务器模式垃圾回收中默认开启了后台垃圾回收,但你还是能够将其关闭的。以下配置将会关闭后台垃圾回收。
<configuration>
<runtime>
<gcConcurrent enabled="false"/>
</ runtime>
</ confi guration>
在实际应用中,应该很少会有关闭后台垃圾回收的理由。如果你想阻止后台垃圾回收的
线程占用应用程序的CPU时间,而且不介意完全垃圾回收和阻塞垃圾回收时可能增加的时
间和频次,那就可以把它关闭。
2.2.3低延迟模式( Low Latency Mode )
如果你需要在一-I段时间内确保较高的性能,可以通知GC不要执行开销很大的第2代垃圾回收。请根据其他参数把GCSettings.LatencyMode属性赋为以下值之一。LowLatency--仅适用 于工作站模式GC,禁止第2代垃圾回收。
●SustainedLowLatency一 适用于 工作站和服务器模式的GC,禁止第2代完全垃圾回收,但允许第2代后台垃圾回收。必须启用后台垃圾回收,本参数才会生效。因为不会再进行碎片整理了,所以这两种参数都会显著增加托管堆的大小。如果你的进程需要量内存,就应该避免使用这种低延迟模式。在即将进人低延迟模式前,最好是能强制执行一次完全垃圾回收,这通过调用GC.Collect(2, GCCollectionMode .Forced)即可完成。当代码离开低延迟模式后,马上再做--次完全垃圾回收。请勿将低延迟模式作为默认模式来使用。低延迟模式确实是用于那些必须长时间不被中断的应用程序,但不是100%的时间都得如此。一个很好的例子就是股票交易,在开市期间,当然不希望发生完全垃圾回收。而在休市时间里,就可以关闭低延迟模式并执行完全垃圾回
收,等到下一次开市时再切换回来。仅当以下条件都满足时,才能开启低延迟模式。, 完全垃圾回收的持续时间过长,是程序正常运行时绝对不能接受的。应用程序的内存占用量远低于可用内存数。无论是关闭低延迟模式期间、程序重启,还是手动执行完全垃圾回收期间,应用程序都可以保持存活状态。
因为存在潜在的不确定性,低延迟模式是很少用到的,你应该三思而后行。如果你觉得这种模式有用,请仔细进行性能评估以确保效果。开启低延迟模式可能会导致其他的性能问题,因为这会产生副作用。为了应对完全垃圾回收的缺失,瞬时回收(第0代和第1代垃圾回收)的频次会增加。你很可能是“按下了葫芦起了瓢”。最后请注意,低延迟模式并不一-定能保证生效。如果要在完全回收或抛出out ofMemoryException之间做出选择,垃圾回收器可能会选择完全回收,这样你的设置就无效了。
2.3减少内存分配量
这几乎无需多言,如果你减少了内存分配数量,也就减轻了垃圾回收器的运行压力,同时还可以减少内存碎片整理量和CPU占用率。要想减少内存分配量,得动些脑筋才行,还有可能与其他设计目标发生冲突。请严格审查每-一个对象,扪心自问一下。
●是否真的需要这个对象?
●对象中有没有什么成员是可以摒弃的?
●数组能否减小一些?基元类型(Primitive) 能否减小体积(比如Int64换成Int32) ?
●有些对象是否很少用到,仅在必要时再行分配? .
●有些类能否转成“结构”(Struct) ?这样就能存放在堆栈中,或者是成为其他对象
的成员。
分配的内存很多,是否只用了一小部分?
能否用其他途径获取数据?