从GC的角度看性能优化
从GC的角度看性能优化
作者:范维肖 (V.C Van, 维生素C.net)
首发:《程序员》杂志2008.09
垃圾回收(Garbage Collection,以下简称GC)是一些高级开发语言的一个核心部分,虽然所有的高级语言都在极力避免用户去关心它,然而对于编写高效的应用程序,理解GC是非常重要的。如果您已经了解GC的一些基础内容,那么本文将揭示一些在基于.net应用程序在windows系统上进行性能调优时与GC相关的内容。
当您决定对程序进行性能调优的时候,往往是下列两种情况:
-
遇到了诸如OutOfMemory、High CPU等严重问题的困扰。
-
程序响应能力下降。
通常来说还有一种情况:通过性能计数器(perfmon)查看一些计数器的值来决定是否进行调优、对系统哪部分进行调优。
不管是遇到上述哪种情况找到症状所在是首要问题,然后再对症下药。工欲善其事,必先利其器,笔者推荐两个最常用的工具:性能计数器和windbg。通过这两个工具绝大多数情况下都可以帮助我们定位问题。第三个利器就是我们的大脑——足够的思考之后再去着手解决问题。本文注重从通过性能计数器开始来揭露一些GC的“坏毛病”。
开始之前,我们先来看看GC最基本的概念:
GC是针对托管堆(managed heap)的,也就是说在堆栈(stack)里的东西GC是不去理会的。GC其实负责了托管堆上对象的生老病死。作为一个核心模块,GC的执行效率是不能忽视的,因此GC对于大于85,000字节的对象会单独处理,放在称为Large Object Heap(大对象堆,下文简称LOH)中管理。GC分代(Generation)管理,.net的GC分为三个代,分别为Gen0、Gen1和Gen2,并且这三个代所在的堆都会被压缩。越是顽强的对象越是容易被从低的代向高的代提升。Gen2的回收也称作完全回收(Full Collect),是最耗费性能的。而LOH不会被压缩,也不会自己被回收。
有下列三种情况会触发GC的执行:
-
内存的分配超过了Gen0或LOH的限制。
-
System.GC.Collect()方法被调用。
-
系统内存资源不足。
第1种情况是最常见的,并且每次GC完成之后,Gen0都是空的,接受新的分配,只到Gen0满了,那么GC又再次运行。第2种情况通常来说是我们的代码绝对应该避免的,按照规范,只有BCL才能调用它。情况3是由于其他的进程占用了过多的内存所至。
知道GC何时会发生,但是GC执行的过程如何?执行完的结果如何?执行的结果是否是我们所期望的?这些问题我们从哪里去找答案?性能计数器这时就起到作用了。我们大致分析一下与GC相关的几个计数器(Counter)的作用:
% Time in GC
这个值是说从上一次GC结束到当前这次GC所经历的时间的百分比。比如上次GC结束时经历了100个循环,当前的GC经历了50个循环,这个计数器的值就是50/100=50%。看性能计数器来推测究竟是什么问题,主要有两类情况,第一类需要看计数器到变化趋势,第二类需要看的是计数器到值。这里对待第2类情况引入一个“健康值”的概念。通常来说如果值大于50%我们就应该去检查一下托管堆的问题了,如果这个值在20%以下,一般来说是没有必要去优化程序的。如果系统负载较高或内存压力较大,都是有可能导致该值升高的。
Allocated Bytes/sec
如果认为在GC上花费的时间太多了,接下来应该看看Allocated Bytes/sec这个计数器.它显示了GC对托管堆的分配速率。需要注意到是这个计数器的值在分配速率很低的情况下其实是不准确的,它只有在每次GC开始的时候才会被更新,如果性能计数器的取样频率(默认是1秒)被设置为大于GC的频率的时候,这个值就不容易说明问题了。
当GC开始的时候会更新该计数器到值——将Gen0和LOH相加的和与该值相加,然后减去上一次的值,再除以时间间隔。得到的就是这个分配速率。
举个例子:默认情况下性能计数器一秒更新一次数据,在第1秒Gen0的回收操作因为需要分配100k而触发,所以再第1秒末这个值是(100k-0k)/1sec,是100k/sec。在第2秒没有GC发生,记录的值还是100k,那么第2秒末该值就是(100k-100k)/1sec,是0k/sec,第3秒Gen0 GC又被触发总共被分配了200k,所以在第3秒末的时候这个值是(200k-100k)/1sec,是100k/sec。
从上面到例子能看到如果说GC发生的不是非常频繁的话这个值应该是0k/sec的。
(在线条图(line)试图下对选定的计数器的值的监视)
Large Object Heap Size
这个值记录的是LOH的大小。
# Gen X Collections
这里X的值为0,1和2。该计数器显示了从这个process开始运行后各个代被回收的次数。从GC的实现上来讲,其实是没有针对Gen0进行回收的操作。GC对代操作最大的一个特点是对于高的代进行回收操作也会对比它低的所有的代进行回收操作。
GC是其软怕硬的“软骨头”,对于小对象,它把最容易摆平的对象在较低的代上就解决之,摆不平的就会将它发配到高一级的代上,直到Gen2,GC才变得凶猛彪悍——当然程序就要为它的强势付出较高的性能损失的代价。GC什么时候最弱势?就是面对对大对象的时候了,GC对于大对象变得很恐惧,不对该堆压缩也不做提升,就只把它们列到一份黑名单里伺机针对其中的部分下黑手。但是GC的这个特点带给我们的却是大多数情况下性能的提升。Gen2一出手,就会带着LOH的GC一起动手,这个特点有时就成了万恶的开端了。
针对这个特点,我们可以清晰的看出Gen0和Gen1运行很频繁也不会影响太多的性能,但是Gen2的频繁就让我们感觉比较明显了,如果Gen2运行的很频繁,那程序就如坐针毡了。通常来说,Gen0、Gen1和Gen2的回收次数的比值在100:10:1的比率上是不错的。
我们的在托管堆上的小对象往往有两种情况,一种是这个对象在Gen0回收时就被处理掉了,我们这里况且称之为“夭折”(die young),还有一种开起来很顽强但是一到Gen2立刻就挂了的,我们称之为“中年危机”(mid-life crisis)。对于前者,是个好事情,因为Gen0对性能的影响几乎可以忽略不计。但是对于频繁的Gen2的回收可能出现的一个情况就是LOH 的回收也伴随着Gen2的回收而执行——即使LOH还有富余的空间可以分配,但也跟着遭殃了。
另外一种情况就是我们看到在Gen2回收之后,其大小并没有明显的变化,这表明回收操作基本都发生在针对LOH的处理上了。对于LOH,里面的对象其实也不全是大于85k的,还有一些.net运行时在里面创建的对象。
所以,如果我们看到GC消耗了不少时间,但是其分配速率却不高的话,最大的可能就是有很多对象在不断的从Gen0提升到Gen2。这种场景往往可以通过下面这个计数器的值得到确认:
Promoted Memory from Gen X
X的取值范围为0和1,是用来体现对对象在低代和高代之间提升情况的。如果有大量的Gen2 回收发生,那Promoted Memory from Gen 1的值是会比较高的。但是需要注意到是被终结器(Finalization)引发的对象提升要看Promoted Finalization – Memory From Gen0,对于这个计数器的名字我们应该注意的是:虽然说是Gen0的,但是包括了Gen0和Gen1的。如果一个可终结(finalizable)的对象存活时,所有它引用的对象也都是存活的,在Promoted Finalization – Memory from Gen0计数器里也包含了这些对象。
对于finalizable的对象,是被添加到一个列表中,GC会监视着来决定何时、如何处理。因此最终化操作(finalize)最佳的操作是尽快完成,如果一个这样的操作需要运行几个小时,显然不是什么好现象,我们应该修改我们的代码来避免这种情况。因为每次GC运行时都要看那些对象的最终化操作要执行,如果它不是正在运行的,就去等待它的执行。
Gen X heap size
X的取值范围也是0和1。当与提升相关的计数器的值比较高时,应该看看它了。该计数器表示Gen0和Gen1的大小。但是我们需要知道的事Gen 0 heap size的值是个假的,他表示的仅仅是一个预算值。Gen0和Gen1都很小,从256K到几兆。
# Total committed Bytes和# Total reserved Bytes
对于内存相关的数据,从任务管理器到性能计数器,有多个,从Working Set到Commit Size再到Total reserved bytes,针对这些名称可以在MSDN上找到相应的解释。我们着重提一下这里的一个计算公式:
#Total committed bytes=Gen0 heap size+Gen1 heap size+Gen2 heap size+LOH size
而且后者的值比前者的要大。
# Induce GC
如果看到这个值比较高就比较惨了,应该检查一下是不是代码调用GC.Collect()太多了。如前文所述,通常我们不应该直接去调用它。
对于内存的情况,内存碎片(Fragmentation)是不得不说的一个话题。我们有时候会遇到还有相对充足的内存,但是却报OutOfMemory(OOM问题)的异常,这通常就是内存碎片搞的鬼。在.net 2.0里,比起1.1时代GC的一大进步就是提高了对碎片的处理能力。GC一直都是将对象处理掉或者继续提升,但2.0之后的GC也有了降级(demotion)的功能,它有效的阻止了一些特殊对象往高一级的代的提升;同时,通过提高已存在的Gen2的段(Segment)重用来减少内存碎片的情况。
与内存碎片关系最大的是钉子对象(pinned object,可以理解为被固定在内存某个位置不能被GC移动的对象)。比如我们的程序执行异步的网络I/O操作时,缓冲(buffer)就会被钉住(pinned),直到整个操作的完成。这个过程就会在GC堆上分配一块空间,这个空间不能被移动,而且该空间内还有其他的标识类的内容,它前面的内存因为剩余太小又不能被用来分配给其他对象使用,就会产生内存碎片。为什么会有如此不方便的钉子对象?其实它可是微软精心设计的,在它的帮助下,托管代码和非托管代码可以更好的交互。所以我们在处理钉子对象的时候,一定要尽可能快的解除“钉住”的状况(unpin),或者我们去pin一个在LOH里的对象(当然要确定这个LOH不会经常被“骚扰”),当然,如果确定Gen2不会频繁执行,也可以去pin一个在Gen2里的对象。像使用C#的fixed关键字,GC.KeepAlive都是可以产生这样的钉子对象的。但是这个解决问题的思路不是死板的,只有一组比较好的解决方案而已,因为微软也在紧锣密鼓的准备对GC在此问题的优化工作。
(对# of pinned objects计数器的监视)
遇到了问题,看完了性能计数器,一定要去花时间思考。比如说我们的程序在负载逐渐增大后发现cpu比较高了,千万不要急着抄起工具去抓dump然后拿过来debug。思考的越多对于缩小问题出现的范围就会越有帮助。
之前我所在的开发组负责的一个asp.net的应用程序在650-800 requests/sec的情况下遇到了较高的CPU,在对计数器的一些值进行了仔细的观察后,发现程序每秒钟抛出的异常数很高,在200-300左右,但是我们的项目有异常记录组件,并没有发现有如此之多的异常被抛出。那就确定是程序调用的一些方法自身会抛出异常并且捕获了,比如Response.End()方法就会抛出一个thread abort的异常,但是在方法内就已经捕获了。而对于这样的一个异常,# of Excepts Thrown / sec 计数器也是敬业的将其如实记录下来。回顾一下关于异常的知识,异常是耗费性能的,GC对异常的处理造成了CPU的大幅度波动,遂而检查代码,找到了像有这样特性的方法的调用,用其他方案替换,问题得已解决。
垃圾回收机制历史悠久,博大精深,单就.net上的GC写成一本沉甸甸的书也不足为过。本文分享了一些笔者对GC和相关性能优化上的认识和经验,像相关的强引用、弱引用、GC针对cpu数量不同的系统提供的不同的模式、什么是根(root)、什么是HOARD特性等非常重要的知识点本文都未提及,旨在希望从事基于.net开发的朋友可以更多的了解其本质,充分发挥.net这把犀利武器的潜能。
作者:范维肖