导言
SUN官方发布的关于垃圾收集性能调整的文档,在不同JDK下有不同的版本,文档内容大同小异。本文档基于SUN 1.3.1版本调优文档创作,添加了关于1.4.2中可选垃圾收集器的内容。而是加入了我对于GC的一些思考,同时删除了原文档作者不合时宜的幽默。本文档前半部分内容由暴风尖塔独立完成,后半部分引用了原dev2dev版主伍昊献的翻译
这里给出官方文档在不同版本之下的链接
1.3.1 http://java.sun.com/docs/hotspot/gc/index.html
1.4.2http://java.sun.com/docs/hotspot/gc1.4.2/
5.0http://java.sun.com/docs/hotspot/gc5.0/gc_tuning_5.html
现在,Java 2 平台的发展已经远远超过了当时设计者的预料,从专门为小型设备和Web 小程序设计的语言,发展到广泛被应用于企业级应用。很多大型的应用软件也使用Java语言编写,如作为应用程序服务器(Application Server)的BEA WebLogic,IBM WebSPhere,RedHat JBOSS ,Apache Tomcat等软件。使用者对此类软件通常有很高的性能要求,即要求此类软件的性能随着CPU,内存,线程数目,Socket数目等资源的增加而提升。但是很多时候,事情并不象想象中那么简单,提升性能不仅仅需要增加相应的资源,同时也需要有特别的技术方面的支持。这篇文档阐述了在获取高性能的过程中所使用到的技术:GC参数以及策略调整。
在JDK 1.4.1上有4种备选的垃圾收集器,但哪一个都不能适用于所有的情形。在JDK5.0中,垃圾收集器的类型基于应用程序所运行的machine的类型。本节内容的目的是给出在不同情况下选择垃圾收集器的一些指导性建议。本文首先给出了不同垃圾收集器都具有的通用功能和关于这些功能的调整选项的最佳实践,使用的例子是默认的单线程收集器。然后介绍了不同垃圾收集器各自的特点,以及选择垃圾收集器时应该考虑的内容。
虽然多CPU主机已经成为主流,并且多线程程序也成为了大多数平台的程序标准。但是,Amdahl发现很多的工作并没有很好的利用并行处理的优势,某些工作总是串行的,不能从并行处理获得好处。Java2平台就存在这种情况。特别是,JVM 1.3.1及以下版本没有并行GC,并且GC的运行会挂起所有正在运行的应用线程。垃圾收集算法会在一段时间里使系统停止,单线程的收集器很快会成为伸缩性瓶颈,因为在垃圾收集器将用户程序线程挂起时,除了一个处理器之外,其他的处理器都是空闲的。所以在多CPU系统之上,相对于能够多线程运行的应用程序来说,只能单线程运行的GC会造成很大的Throughput损失。
下图显示了一个除GC之外其余均达到完美的系统。最上面的线(红色),反映了在单处理器上,只花1%时间在GC上的应用情况:这可以理解为,在32个处理器上,将会损失至少20%的Throughput。到10%时,如果不考虑单处理器应用中GC所用的时间,那么损失的Throughput将会超过75%。
这里说明一下为什么在高GC时间花费的情况下,吞吐量会随着CPU的增加而显著降低。通常情况下java线程的实现方式是使用操作系统级别线程,每一个java线程都会表现为一个操作系统本地线程,在多CPU的机器上,每一个线程被分配到不同的CPU运行。无并发的垃圾收集器(1.3.1的唯一gc,1.4.2以及以后版本的缺省 gc)在运行的时候,会挂起所有的应用线程。但是只使用一个线程进行垃圾收集工作。在多CPU的机器上,这样就明显的降低了吞吐量。
假设总的工作量是1000,在单CPU环境下,假设每个CPU每秒处理的工作量是10,则无GC处理完所有的工作时间是100秒种。
无GC每秒吞吐量=1000/100=10
在GC时间花费10%的情况下,花费的时间是110秒。则实际吞吐量是1000/110= GC每秒吞吐量=1000/110=9.1
降低的吞吐量比为(10-9.1)/10=9%
在10CPU的情况下,无GC处理完所有的工作的时间是10秒
无GC每秒吞吐量=1000/10=100
加上GC时间,总花费时间20秒。
GC每秒吞吐量=1000/20=50
降低的吞吐量比为(100-50)/100=50%
在32CPU的情况下,无GC处理完所有的工作的时间是3.125秒
无GC每秒吞吐量=1000/3.125=320
加上GC时间,总花费时间13.125秒。
GC每秒吞吐量=1000/13.125=76.2
降低的吞吐量比为(320-76.2)/ 320=76.1%
这就证明了当GC花费时间比例增大的时候,在小型系统应用上所损失的Throughput可能会成为瓶颈问题。但是请无需担心,对这个瓶颈问题的一点小改进能获得很高的性能提升;对于一个大型的系统来讲,调整GC也同样是值得做的一个工作。
另外,在此给出一些专有名词的意思:
Ø gc:garbage collection(垃圾收集)
Ø infant mortality:对象分配以后很快成为垃圾,就称该对象具有“infant mortality”
Ø minor collection:较小收集,指发生在young generation的gc
Ø major collection:较大收集,指发生在older generation的gc
Ø older generation:年老代,在1.4.2版本之后改称为tenured generation。
Ø tenured generation:年老代,在1.3.1之前称为older generation
Ø permanent generation:永久代。又称为永久域,方法区。
Ø young generation:年轻代
Ø footprint:是一批工作进程的集合,以页和缓冲行数计量,在物理内存有限或者有很多处理器的系统里,footprint 可代表伸缩性
Ø survivor spaces:生存空间
Ø eden:新的对象分配的地方
Ø throughput:是未消耗在垃圾收集的时间占总时间的百分比
(一)GC按代收集
Java 2 平台一个很强的特性之一就是屏蔽内存分配和GC的复杂性。然而,一旦GC成为瓶颈,那么就要理解所隐藏的实现细节。垃圾收集器对应用使用对象的方式作了限定,这些限定就反映在可调整参数中。这些参数可以被调整,在不牺牲抽象能力情况下获取更高的性能。
在一个运行的程序中,如果一个对象不再有任何引用,那么它将成为“垃圾”。大部分GC算法就是简单地对每个可触及对象进行遍历:任何不可触及的对象,将成为“垃圾”。这种算法所花的时间和实际活动对象的数量成比例,因此对于具有大量活动数据的大型应用,就不再适用。
从J2SE平台的1.2版本开始,引入了集许多不同的GC算法为一体的新算法,这些不同的算法是通过分代收集结合在一起的,因此称为分代垃圾收集器。当GC在Heap中检查每一个活动的对象时,分代收集利用大多数应用的几个属性来避免额外的工作。
这些属性中,最重要的是infant mortality(对象分配以后很快成为垃圾)。下图中蓝色区域显示了对象生命周期的典型分布。左边的峰值代表在分配之后能很快收集的对象。例如,迭代器对象(Iterator objects)的生命周期通常只是在一个循环语句的执行期间是可触及的。
这里做一下简单解释,Iterator是Java内嵌的一种设计模式,Iterator模式是用于遍历集合类的标准访问方法。它可以把访问逻辑从不同类型的集合类中抽象出来,从而避免向客户端暴露集合的内部结构。示例代码如下:
for(Iterator it = c.iterater(); it.hasNext(); ) { ... }
可见,it只有在for循环语句内部才是可以访问的。在for循环外部,由于没有不再使用的变量会从局部变量表中清除,所以it局部变量被清除,同时对象不再被引用,可以被垃圾收集。
一些对象存活时间越长,就越向右进行分布。例如,典型的例子是,一些在初始化时就被分配并一直存活到程序退出的对象。在这两个极端之间的是一些在中间计算中所存活的对象,就是这里那个峰值右边的区域。尽管一些应用有不同的分布情况,但大多数应用都符合这个通用图形。通过关注大多数对象的infant mortality进行有效的收集是可能的。
为此,内存是分代管理的:内存池对不同代中的对象进行管理。GC是在每代中内存池满的时候进行的:如上图中竖线所示。对象分配在Eden中,那是多数初期对象变成垃圾的地方。当Eden 填满时,将会引起minor collection,在其中的存活的对象将会移动到tenured generation中。当tenured generation需要去收集的时候,那就是major collection,通常会比较慢。因为它包含了所有存活的对象。
上图显示了一个调整好的系统,在该系统中,大多数对象在第一次的垃圾收集前就销毁掉了。一个对象活动时间越长,经历GC的次数就越多,GC速度就越慢。通过控制大多数对象存活不到一次收集就销毁,可使GC变得十分有效。但是,如果对象有异常生命周期分布,或者generation的大小设置不当引起频繁gc,这种令人满意的情况就会被破坏。默认的GC参数对大多数小型应用都是有效的。对于许多服务器应用,它们并不是最佳参数。这就引出了这篇文档的主旨:如果GC成为瓶颈,你可以定制代的大小。检查详细的GC输出,研究 GC 参数对性能的影响。
默认情况下的分代排列如下图所示:
在进行初始化的时候,最大的地址空间只是事实上的设定,在实际需要的时候,才分配物理内存。全部的地址空间分成young generation和tenured generation。
young generation包括Eden和两个survivor spaces。对象最初分配在Eden中。其中保证一个送survivor spaces在任何时候都是空的,当垃圾收集发生时, Eden中的存活的对象复制到survivor spaces,此后该对象就在survivor spaces之间复制,直到到达最大阀值(老化),然后复制到tenured generation。(其它的虚拟机,包括JVM 1.2版本 For Solaris,使用两个大小相等的空间来复制,而不是使用一个大的Eden加两个小空间)。这就是说定义young generation 参数,并不能直接可比较的。
tenured generation在合适的时候,使用Mark-compact方式进行收集。名为永久代选项比较特别,因为它保存包括JVM 自身的所有反映数据(reflective data),例如类以及方法。所以永久域的另外一个名字被称为方法区。
(二)GC性能指标
衡量GC性能有两个指标。Throughput是未消耗在垃圾收集的时间占总时间的百分比,Throughput包括花在分配上的时间(不包括调整分配速度的时间),停顿(Pauses)是应用因为垃圾收集而停止响应的时间。
用户对于垃圾收集有不同的需求,例如,对于web服务器的主要衡量标准是Throughput,因此垃圾收集所造成的停顿并非是不可容忍的,因为用户可能认为是网络延时而已。 但是,对于交互式图形程序,哪怕是非常短暂的延迟也会影响用户的使用体验。
一些用户对于其他一些考虑敏感,Footprint是处理的工作区,用页面和cache line 作为尺度测量.在有限的物理内存或许多处理器的系统上,footprint 可以显示伸缩性.Promptness是从对象死亡到对象占用的内存变得可用之间的时间间隔.另一个对于分布时系统比较重要的考量标准是远程方法调用(RMI)。
一般来说,选择某个代大小时要平衡考虑各种考虑因素.例如,一个非常大的young generation也许会最大化throughput,但是以footprint,promptness为代价的,也就是说会引起长时间的pause。小的young generation和incremental collection可以使停顿时间的减少,但是以牺牲Throughput为代价的。
没有一种正确的方式去衡量代的大小:最好的选择是由应用使用用户需要的内存。因此,JVM 默认的GC可能并不是最好的,可以由用户使用命令行参数去覆盖。
(三)GC测量方法
Throughput和footprint是最好的标准,最好使用对于应用来说特定的手段测量。例如,一个web server的Throughput可以使用客户端的压力负载工具来测试,同时在Solaris操作系统上,服务器的footprint可以用pmap命令来衡量。换句话说,由于GC而停顿,很容易由于JVM自己的诊断输出来得到。
命令行的参数: -verbose:gc 显示了每次收集时的打印的信息。例如,这里时从大型的服务器应用中输出结果:
[GC 325407K->83000K(776768K), 0.2300771 secs]
[GC 325816K->83372K(776768K), 0.2454258 secs]
[Full GC 267628K->83769K(776768K), 1.8479984 secs]
上面,我们看到两个minor collection和一个major collection。箭头前后的数
325407K->83000K(776768K)
显示了GC前后活动对象空间的大小。值得一提的是,在minor collection之后,这个数字包括不再需要存活但是不能被回收的对象所占用的空间,因为它们或者是活动的,或者是被tenured generration中的对象所引用)。括号里的数目0.2300771 secs是总空闲空间的大小,它是堆的总的大小减去一个survivor spaces。总空闲空间中不包括永久域。第三行中输出的major collection的信息格式和上面类似。标示-XX:+PrintGCDetails 会输出一些额外的信息,具体的信息和jvm的版本有关。以下是在1.4.2版本中带有-XX:+PrintGCDetails标示时输出的情况:
[GC [DefNew: 64575K->959K(64576K), 0.0457646 secs] 196016K->133633K(261184K), 0.0459067 secs]]
上面的信息指示minor collection回收了将近98%的young generation,使用了将近46毫秒的时间(0.0457646 secs)。整体堆的使用量减少到51%左右(196016K->133633K(261184K))。另外有一个另外的整体时间统计,稍微大于发生在young generation的gc时间。另外有一个另外的整体时间统计,稍微大于发生在young generation的gc时间。标示-XX:+PrintGCTimeStamps会附加额外的时间戳信息到输出:
111.042: [GC 111.042: [DefNew: 8128K->8128K(8128K), 0.0000505 secs]111.042: [Tenured: 18154K->2311K(24576K), 0.1290354 secs] 26282K->2311K(32704K), 0.1293306 secs]
gc在系统启动111秒之后发生,minor collection同时启动,有一些关于major collection的额外的信息。major collection将tenured generation的使用率降低到10%(18154K->2311K(24576K))。并且使用了将近0.13秒的时间(0.1290354 secs)。
(四)调整Sun JVM代的大小
很多的参数都会影响分代的大小。下面的这副图举例说明了调整JVM1.3.1最重要的一点。许多参数实际用比率来表示x:y, 分别用黑色部分(用x来表示),灰色部分(用y来表示)来显示。
堆的整体大小
当generation被占满的时候,相对应的收集就发生了,throughput与可用内存的数量成反比,总可用内存是影响垃圾收集性能最重要的因素。默认情况下,JVM在每次收集之后,增长或减少堆,来保持可用的内存和活动对象的比例。通过参数-XX:MinHeapFreeRatio=<minimum> 和 -XX:MaxHeapFreeRatio=<maximum>,这个范围被设定为一个百分率,总大小在-Xms 和 -Xmx 之间。
在Solaris上的默认参数,显示如下:
-XX:MinFreeHeapRatio=40
-XX:MaxHeapFreeRatio=70
-Xms 3584k
-Xmx 64m
大型的服务器应用经常经历两个问题。一个是启动很慢,因为初始化的堆很小,必须通过多次的major collectiosns 后调整大小。更严重的问题是默认的maximum 堆大小是对于大多数的服务器应用是不合适的。服务器应用的设置规则是:
除非有停顿问题,否则尽量设置JVM更多的内存。默认情况下,64M太小了。设置-xms 和-xmx值一样大。确定去提高内存,正像你提高线程数一样,尽管GC不是并行的,但分配内存可以并行,所以在增加处理器的时候确保增加内存。
Young generation
第二个影响性能的问题是young generation的大小。young generation越大,minor collections将会经常发生。然而,由于堆的整体大小是固定的,young generation越大,tenured generation越小,越会增加major collections的执行的次数。所以最佳的选择是由对象的生命周期分布所决定。
默认情况下,年轻代是由NewRatio参数所决定的。例如,设置 –XX:NewRatio=3 意思是tenured generation和young generation的比例是3。换句话说,Eden和survivor spaces组合大小是整个堆的1/4。
参数NewSize和MaxNewSize设置young generation的最小和最大值。设置这两个值相等,就固定了young generation,正像设置-xms ,-xmx相等,就固定了整个堆的大小一样。
因为young generation使用复制收集,在tenured generation中必须有足够大的内存大小,才能保证minor collections进行。在最基本的情况下,这个值至少等于Eden的大小加上非空的survivor spaces的大小。如果在tenured generation中没有足够的内存,major collections将会发生。对于一些小应用,这种规则是很好的,因为在tenured generation保留的内存只具有形式意义,只是虚拟上的使用,而不是实际使用。但是对于需要更大堆的应用,超过虚拟堆大小一半的Eden是没有用的,因为这种情况下只有major collections会发生。
如果需要,参数SurvivorRatio被用来调整survivor spaces,但是对于性能这是不重要的。例如,设置 =6设置每个survivor spaces和Eden的比例是1:6;换句话说,每个survivor spaces将是young generations的1/8。(不是1/7,因为有两个survivor spaces)
如果survivor spaces太小,拷贝收集直接溢出到tenured generation,如果幸存空间太大,它们将无用地空着。虚拟机会适当选择对象在老化前能被拷贝的次数。并利用这个次数始终保持survivor spaces半空,对象达到拷贝次数之后会被提升到tenured generation。选项XX:+PrintTenuringDistribution被用来显示这个次数,和new generation中对象的年龄。它也可以用来发现应用的对象生命分周期布。
这儿是Solaris操作系统上默认值:
NewRatio 2(client JVM:8)
NewSize 2172k
MaxNewSize 32m
SurvivorRatio 25
那么,服务器应用规则如下:首先决定可以提供给虚拟机的总内存,然后根据young generation的大小绘制你自己的性能曲线,找到最好的设置。不要young generation让达到总堆大小的一半,那样做并不提高性能。增加处理器的数量时,请确保增加 young generation ,因为分配可以并行。
垃圾收集器
在前面提高,分代收集器是一些垃圾收集器算法的总称。默认情况下,每个分代有一个相关联的GC类型,在1.3.1中,JVM实现了三种不同的GC:Copying(有时,称为清扫):这个收集者可以有效的在两个或多个分代中进行对象的移动。原分代变空,可以将遗留的对象销毁。然而,Copying需要空间去操作,并需要拷贝所需的footprint。
在1.3.1,复制收集用于所有的minor collections.也就是说复制收集针对young generation。Mark-compact:这个收集者允许分代在适当的时候进行分配,而不需要额外的内存。然后,这种紧凑的比复制方式,速度上要慢一些。在1.3.1中,紧凑标记的方式主要用于major collection.也就是说针对tenured generation。Incremental:只有在命令行中设置了 -Xincgc之后,这种收集方式才起作用。借助于详细的记录,递增式的GC一次只能收集tenured generation的一部分,在多次minor collections之后,才尝试进行major collections。然而,如果考虑所有的Throughput的话,这种方式比紧凑标记的速度还要慢。
JDK 1.4.1中,为了解决多处理器系统中垃圾收集器的问题,增加了2种具备并行处理功能的新的垃圾收集器。分别是并行复制收集器(throughput collector)和并发标记-清除收集器(Concurrent Low Pause Collector),这些并行收集器被设计为减少收集暂停时间或者是在提高在大堆上的吞吐能力而设计的。
并行复制收集器和并发标记-清除收集器基本上是默认的复制收集器和标记-整理收集器的并发版本。并行复制收集器:用 JVM 选项 -XX:+UseParNewGC 启用,是一个发生在young generation的并行版本的复制收集器,它将垃圾收集的工作分为与 CPU 数量一样多的线程。针对多处理器系统上非常大(G字节以及更大的)堆进行了优化。在指定-XX:+UseParNewGC参数时,tenured generation使用默认的Mark-compact收集器。
并发标记-清除收集器:由 -XX:+UseConcMarkSweepGC 选项启用,它是一个发生在tenured generation的并发版本标记-清除收集器,它在初始标记阶段(及在以后暂短重新标记阶段)暂短地停止整个系统,然后恢复用户程序,同时垃圾收集器线程与用户程序并发地执行。特别的是,如果在命令行指定了-XX:+UseConcMarkSweepGC参数,那么UseParNewG也被缺省设置为true,如果没有进行特别指定的话。
增量收集选项自 1.2 起就成为 JDK 的一部分。增量收集减少了垃圾收集暂停,以牺牲吞吐能力为代价,这使它只在更短的收集暂停非常重要时才值得考虑,如接近实时的系统。要使用增量收集使用-Xincgc参数记住,-XX:+UseParallelGC参数不能和-XX:+UseConcMarkSweepGC参数同时在命令行中使用,这是因为进程对命令行参数进行解析的机制造成的。同时设置这两个参数的结果是不确定的。
何时使用Throughput Collector
在多处理器环境下,如果希望提升应用程序的性能,可以考虑使用Throughput Collector。默认的垃圾收集行为是由一个线程完成的,垃圾收集行为增加了了应用程序完成工作花费的时间。Throughput Collector使用多个线程来完成minor collection,因此减少了时间花费。一个典型案例就是应用程序中有许多线程都要申请对象,这样的应用程序往往会需要一个很大的young generation,在这种情况下,使用Throughput Collector是非常合适的。
Throughput Collector是一个分代垃圾收集器,在这点上和young generation的缺省的垃圾收集器类似,只不过Throughput Collector使用多个线程来进行minor collection。tenured generation的major collections则仍然使用缺省的方式。在一个具有N个处理器的主机上,Throughput Collector缺省使用N个线程进行垃圾收集,不过线程数也可以使用命令行参数指定。在1CPU的主机上,Throughput Collector没有缺省的垃圾收集器的性能表现好,因为并行的操作需要一些额外的开销,在2CPU的主机上两者表现类似,在多于2个CPU的主机上,Throughput Collector表现要好于缺省的垃圾收集器。
Throughput Collector可以使用命令行参数-XX:+UseParallelGC启用,使用的线程数可以用命令行参数ParallelGCThreads来指定,使用方式是-XX:+ParallelGCThreads=<desired number>。
Throughput Collector所需要的堆空间大小刚开始和缺省的收集器相同。启用Throughput Collector的目的是为了缩短minor collection pause的时间,由于是多个收集器线程参与minor collection,在垃圾收集期间,从young generation到tenured generation的对象转移可能会造成一定程度的内存碎片,因为每一个线程都需要从tenured generation中划分一部分区域来暂时存放自己转移的对象。减少收集器线程数和增加tenured generation的空间都可以减轻内存碎片问题的危害。
大小自适应
从1.4.1开始,Throughput Collector就具有一个特征,就是大小自适应。这个特征可以使用参数-XX:+UseAdaptiveSizePolicy来指定,并且这个参数选项默认是打开的。这个特征对收集时间、分配比例、收集之后堆的空闲空间等数据进行了统计分析,然后以此为依据调整young generation和tenured generation的大小以达到最佳效果。可以使用-verbose:gc来查看堆的大小。
-XX:+AggressiveHeap选项会检测主机的资源如内存大小、处理器数量等,然后调整相关参数,使得长时间运行的、内存申请密集的任务能够以最佳状态运行,该选项最初是为拥有大量CPU和内存的主机而设计的,但是从1.4.1以及后续版本来看,即使是只有4颗CPU的主机也能从该选项中受益。因此,Throughput Collector(-XX:+UseParallelGC),大小自适应((-XX:+UseAdaptiveSizePolicy),以及本选项AggressiveHeap(-XX:+AggressiveHeap)经常结合在一起使用,要使用本选项,主机上至少有256M的内存,堆的最初大小是基于物理内存计算出来的,然后会根据需要尽可能的利用物理内存。
Throughput Collector的监测方法。-verbose:gc的使用和缺省垃圾收集器一样。
何时使用Concurrent Low Pause Collector
并发收集器和默认收集器类似,都是分代收集器。并发收集器在旧生代上并发进行垃圾收集。并发收集器旨在降低旧生代上进行收集的暂停时间,他使用一组互相隔离的收集线程,每个线程负责一部分的主要收集,这个收集过程和应用的运行是并发进行的。通过命令行选项-XX:+UseConcMarkSweepGC来使用并发收集器。每当发生主要收集的时候,并发收集器在收集开始的时候和中期会短时间的暂停所有的应用线程,中期的暂停时间相对长一点,在这次暂停中,多个线程同时工作完成收集任务。剩余的收集工作将由一个收集线程完成,这次是和应用并发执行的。次要收集的过程和默认收集器类似,也可以使用多线程的方式完成次要收集,参看"并发收集器的并行次要收集"章节。
在下面连接中对并发收集器(针对旧生代)中用到的技术进行了详细的阐述:
http://research.sun.com/techrep/2000/abstract-88.html
并发的额外开销
虽然并发收集器有效的降低了收集时的暂停时间,但却是以使用更多的处理器资源为代价的。收集过程中并发进行的那一部分是由单一的一个线程完成的。对于一个在N个处理器上运行的系统,并发进行的那部分会使用1/N的处理器资源。可能在单处理器的系统上你看到他表现也还可以,那只是偶然罢了。当然,它能够将一次长时间的暂停(这里的暂停指所有的应用线程都不可用了)分割成多个短时间的暂停,但是这个并不是它的设计初衷。同时,并发收集会有额外的开销,并且可能降低系统吞吐能力,同时对于某些类型的应用来说,并发收集是有先天缺陷的(例如容易造成内存碎片)。在拥有2颗处理器的系统上,并发收集在执行的时候,还有1颗处理器可以为应用服务,因此,执行收集不会"暂停"应用的运行。虽然降低了暂停时间,但是并发收集确实占用了一部分处理器资源,所以你可能感觉到应用会有所缓慢。N的值越大,在并发收集上所用的处理器资源就越少,并发收集器的优势就越明显。
新生代的保证
如果使用默认收集器,那么在进行次要收集的时候,必须保证旧生代中有足够的空间来容纳从Eden和存活空间复制过去的对象。由于并发收集会产生内存碎片,所以这个保证的条件就更加苛刻:在旧生代必须要有足够的连续空间来容纳来自Eden和一个存活空间的对象,因为没有什么方式能够准确知道Eden和这个存活空间中对象大小的分布情况(主要是为了避免巨大的性能消耗)。相对于默认收集器,并发收集器往往需要更大的堆内存。在默认收集器时,堆内存需要保留但是不一定真正的使用。先使用默认收集器,找到一个新生代和旧生代大小的合适的估算值,然后将旧生代的大小设置成和新生代一样大再去使用并发收集器。这只是一个非常粗略的近似值,至于实际上的最佳设置是由应用来决定的。
完全收集
在旧生代被填满之前,并发收集器使用一个收集线程来完成收集工作,同时不暂停应用的执行。实际上,即使应用的线程都在运行,收集器都可以并发来做更多的工作,所以,对于应用而言,只会感觉到非常短暂的暂停。但是当旧生代在填满之前如果收集工作无法全部完成,那么就会暂停应用的线程来完成所有的收集工作。这就是我们所说的完全收集(full collections)。如果发现完全收集比较频繁,可能需要调整并发收集的相关参数。
漂浮垃圾
垃圾收集的工作就是查找堆中的所有活动对象。当应用的线程和垃圾收集的线程并发执行的时候,那么对于收集线程来说当时是活动的对象可能在收集工作完成之后就变成了非活动对象。这就是所说的"漂浮垃圾"(floating garbage)。漂浮垃圾的数量和并发收集的时间有关(应用线程需要花一些时间才丢弃对象),和应用的细节也有关系。可以通过增加旧生代20%(这个是估计值)解决漂浮垃圾问题。当然了,在下一轮收集的时候,这些垃圾都将被收集掉。
暂停
在一次并发收集周期,需要两次暂停应用。第一次暂停时,标识所有的可以从根对象(例如线程栈,静态对象等)或者堆中的其它对象(例如新生代)直接到达的对象,这就是"初始标识"(initial mark)。紧接着就是第二次暂停,此次暂停的目的是为了找出由于收集和应用的并发执行而疏漏的、未被标识对象,这叫做"重新标识"(remark)。
并发阶段
在初始标识和重新标识之间有一个并发标识的过程,在并发标识的时候,收集线程需要占用一部分本来属于应用的处理器资源。在重新标识阶段之后,还有一个并发清理过程,同样,也会占用一部分处理器资源。在清理阶段之后,并发收集线程进入休眠状态,直到下一轮主要收集的发生。
并发收集的测量
下面是使用了-XX:+PrintGCDetails参数的-verbose:gc输出(已经删除了一些详细信息),我们可以看到并发收集的输出中穿插了很多次要收集,一般来说,一个并发收集周期中,会有多次的次要收集。CMS-initial-mark表示并发收集周期的开始,CMS-concurrent-mark表示并发标识阶段的结束,CMS-concurrent-sweep表示并发清理阶段的结束。我们之前没有讨论的预清理阶段(precleaning phase)由CMS-concurrent-preclean标识,它表示的是可以并发执行的一些工作并且是为重新标识阶段(CMS-remark)做好了准备。最后一个阶段由CMS-concurrent-reset标识,表示已经为下一轮的并发收集做好了准备。
[GC [1 CMS-initial-mark: 13991K(20288K)] 14103K(22400K), 0.0023781 secs]
[GC [DefNew: 2112K->64K(2112K), 0.0837052 secs] 16103K->15476K(22400K), 0.0838519 secs]
......
[GC [DefNew: 2077K->63K(2112K), 0.0126205 secs] 17552K->15855K(22400K), 0.0127482 secs]
[CMS-concurrent-mark: 0.267/0.374 secs]
[GC [DefNew: 2111K->64K(2112K), 0.0190851 secs] 17903K->16154K(22400K), 0.0191903 secs]
[CMS-concurrent-preclean: 0.044/0.064 secs]
[GC[1 CMS-remark: 16090K(20288K)] 17242K(22400K), 0.0210460 secs]
[GC [DefNew: 2112K->63K(2112K), 0.0716116 secs] 18177K->17382K(22400K), 0.0718204 secs]
[GC [DefNew: 2111K->63K(2112K), 0.0830392 secs] 19363K->18757K(22400K), 0.0832943 secs]
......
[GC [DefNew: 2111K->0K(2112K), 0.0035190 secs] 17527K->15479K(22400K), 0.0036052 secs] [CMS-concurrent-sweep: 0.291/0.662 secs]
[GC [DefNew: 2048K->0K(2112K), 0.0013347 secs] 17527K->15479K(27912K), 0.0014231 secs]
[CMS-concurrent-reset: 0.016/0.016 secs]
[GC [DefNew: 2048K->1K(2112K), 0.0013936 secs] 17527K->15479K(27912K), 0.0014814 secs]
初始标识的暂停相对于次要收集的暂停要短,反之,并发阶段(并发标识,并发预清理,并发清理)的暂停可能相对要长一些,但是在收集的过程中,应用不会有任何暂停。而由于标识所引起的暂停和应用的细节(例如频繁的修改对象会增加暂停时间)以及上一次次要收集的时间(例如新生代中有大量的对象时也会增加暂停时间)有关。
并发收集器的并行次要收集
在多处理器平台上,可以使用参数UseParNewGC来降低次要收集的暂停时间:-XX:+UseParNewGC
如果使用了UseParNewGC,那么同时使用CMSParallelRemarkEnabled参数可以降低标识暂停:-XX:+CMSParallelRemarkEnabled
何时使用增量收集器
如果你的应用可以用较频繁的、较长时间的新生代上收集来换取稍短时间的旧生代收集,就可以考虑使用增量收集器。典型情况是如果需要长时间的旧生代收集时(大量的长存活期对象),小型的新生代收集也能够满足(大部分对象是短存活期的),并且只有一个处理器。
增量收集器
同样,增量收集器也是和默认收集器类似的分代收及器,在新生代上的次要收集和默认收集器一样。不要在使用增量收集器的同时使用-XX:+UseParallelGC或者-XX:+UseParNewGC。在旧生代上的主要收集是增量完成的。
这种收集器在每次做次要收集的时候,进行一部分的主要收集,这样就避免了做完整的主要收集所带来的长时间暂停。但是有时候为了避免出现内存溢出(out of memory)的问题,也会在旧生代上进行完整的主要收集(就象默认收集器那样)。
由于这种收集器会在堆内存上产生碎片,所以相对于默认的标识-清理-压缩(mark-sweep-compact)收集器来说,可能需要更大一些的堆内存。为了能够在每次次要收集时进行一部分的主要收集,收集器需要维护一些附加信息,所以,增量收集的总体消耗要高一些,并且吞吐可能不如默认收集器那么好。
首先使用默认收集器找到一个合适的堆大小,如果此时主要收集的暂停时间还是无法满足应用需求,尝试调整各个代的大小,并且使用增量收集器,直到找到合适的堆设置。如果在使用增量收集器的时候发生了完全收集(full collection),可能在旧生代发生内存溢出之前无法完成增量的垃圾收集,这个时候,你需要减小新生代的大小,以迫使次要收集发生频率更高一些。
如果由于无法满足新生代保证而发生的主要收集,那么会产生内存碎片。一次次要收集没有能够回收任何空间,此时表明无法保证新生代需求了,此时尝试增大旧生代大小来弥补碎片问题,可能不会真正使用很大的旧生代,但是对于新生代保证来说是有帮助的。
增量收集器测量
将-verbose:gc和-XX:+PrintGCDetail组合使用,可以看到如下的输出:
[GC [DefNew: 2074K->25K(2112K), 0.0050065 secs][Train: 1676K->1633K(63424K), 0.0082112 secs] 3750K->1659K(65536K), 0.0138017 secs]
从上面的输出可以看出,进行次要收集用时大约5毫秒,同时还有一次增量收集(Train:…),用时大约8毫秒。如果发生了完全收集,在输出中会看到Train:MSC字样:
[GC [DefNew: 2049K->2049K(2112K), 0.0003304 secs][Train MSC: 61809K->357K(63424K), 0.3956982 secs] 63859K->394K(65536K), 0.3987650 secs]
同时,从上面的输出中可以看出,次要收集并没有起到作用:收集前后都是2049K,这就表明了在旧生代上没有连续的空间能够满足新生代保证。
其他考虑事项
对于大部分应用来说,持久代的大小不会影响垃圾收集的性能。但是有些应用会动态的产生或者加载大量的对象,例如JSP的容器,如果需要,可以使用参数ManPermSize来增加持久代的大小。有些应用的finalization或者弱引用/软引用/幻影引用(weak/soft/phantom refrences)和垃圾收集相互影响。这种特点可能从Java语言本身就造成了很差的垃圾收集性能,一个典型的例子就是依赖对象的finalize方法来释放资源,比如关闭文件描述符(file descriptor),这样就极大地影响了垃圾收集的性能。无论如何,依赖垃圾收集来释放资源都是非常不可取的方式。
应用影响垃圾收集器的另外一种方式就是显式的调用垃圾回收,例如调用System.gc()方法。这个调用强制进行主要收集,对于大型应用的可扩展性有很大的伤害。可以使用参数-XX:+DisableExplicitGC来禁止应用显式的调用垃圾收集。
另外就是在RMI分布式垃圾收集(RMI distributed garbage collection, DGC)时经常遇到使用显式的垃圾收集,应用通过使用RMI引用在另外一个Java虚拟机中的对象,在这种分布式应用中,垃圾对象无法通过传统的垃圾收集进行清理,所以RMI强制进行周期性的垃圾收集。可以通过一些属性来设置收集周期:
java -Dsun.rmi.dgc.client.gcInterval=3600000 -Dsun.rmi.dgc.server.gcInterval=3600000 ...
默认的收集周期是1分钟,上面的参数指定的周期为1小时。但是,这样设置可能会使得某些对象要经过很长时间才能够被回收。如果对于DGC没有时间上的需求限制,可以设置为Long.MAX_VALUE。Solaris 8操作系统平台使用另外一个版本的线程库(libthread)能够将线程绑定为轻量进程(light-weight process, LWP),对于一些应用来说可能这个版本的线程库比较有益处。要使用这个线程库,在启动Java虚拟机时在环境变量LD_LIBRARY_PATH中包含/usr/lib/lwp。在Solaris 9上,这个线程库是默认的。
相对于客户端模式的虚拟机(-client选项),当使用服务器模式的虚拟机时(-server选项),对于软引用(soft reference)的清理力度要稍微差一些。可以通过增大-XX:SoftRefLRUPolicyMSPerMB=1000来降低收集频率。默认值是1000,也就是说每秒一兆字节。
总结
根据应用的需求不同,垃圾收集可能会成为性能的瓶颈。如果充分了解应用的需求,并且深入理解垃圾收集的机制以及相关选项,能够将垃圾收集对性能的影响降至最小。GC输出示例列出了不同类型的垃圾收集的行为,以及对于垃圾收集详细信息的诊断,并描述了如何来分析问题。
http://java.sun.com/docs/hotspot/gc1.4.2/example.html
对于常见问题的一些解答,比本文档要详细一些。
http://java.sun.com/docs/hotspot/gc1.4.2/faq.html