Java性能权威指南-总结7
- 垃圾收集算法
- 理解Throughput收集器
- 堆大小的自适应调整和静态调整
- 理解CMS收集器
垃圾收集算法
理解Throughput收集器
Throughput收集器有两个基本的操作;其一是回收新生代的垃圾,其二是回收老年代的垃圾。
下图展示了堆在新生代回收之前和回收之后的情况:
通常新生代的垃圾回收发生在Eden空间快用尽时。新生代垃圾收集会把Eden空间中的所有对象挪走:一部分对象会被移动到Survivor空间(即这幅图中的S0区域),其他的会被移动到老年代;回收之后老年代中保存了更多的对象。当然,还有大量的对象因为没有任何对象引用而被回收。
开启了PrintGCDetails标志的GC日志中,Minor GC形式如下:
17.806:[GC [PSYoungGen:227983K->14463K(264128K)]
280122K->66610K(613696K),0.0169320 secs]
[Times: user=0.05 sys=0.00,real=0.02 secs]
这次GC在程序开始运行17.806秒后发生。现在新生代中对象占用的空间为14463 KB(约为14 MB,位于Survivor空间内);GC之前,新生代对象占用的空间为227983 KB(约为227 MB)。(实际上,227893 KB严格折算只有222 MB,为了便于讨论,以1000为单位将它们折算到KB。)新生代这时总的大小为264 MB。
与此同时,堆的空间总的使用情况(包含新生代和老年代)从280 MB减少到了66 MB,这个时刻整个堆的大小为613 MB。完成垃圾回收操作耗时0.02秒(排在输出最后的Real时间是0.0169320秒——实际时间进行了归整)。程序消耗的CPU时间比Real时间往往更多,原因是新生代垃圾回收会使用多个线程。
下图展示了Full GC之前及之后堆的使用情况:
老年代垃圾收集会回收新生代中所有的对象(包括Survivor空间中的对象)。只有那些有活跃引用的对象,或者已经经过压缩整理的对象(它们占据了老年代的开始部分)会在老年代中继续保持,其余的对象都会被回收。
Full GC的日志输出示例如下:
64,546:[FuLl GC [PSYoungGen: 15808K->0K(339456K)]
[ParoldGen:457753K->392528K(554432K)] 473561K->392528K(893888K)
[PSPermGen: 56728K->56728K(115392K)],1.3367880 secs]
[Times: user=4.44 sys=0.01,real=1.34 secs]
新生代的空间使用在经历Full GC之后变为0字节(新生代的大小为339 MB)。老年代中的空间使用从457MB减少到了392MB,因此整个堆的使用从473 MB减少到了392 MB。永久代空间的使用没有发生变化;在多数的Full GC中,永久代的对象都不会被回收。(如果永久代空间耗尽,JVM会发起FulI GC回收永久代中的对象,这时会观察到永久代空间的变化——这是永久代进行回收唯一的情况。这个例子使用的是Java 7;在Java 8中,类似的信息可以在元空间中找到)。由于Full GC要进行大量的工作,所以消耗了约1.3秒的Real时间,4.4秒的CPU时间(同样源于使用了多个并行的线程)。
快速小结
- Throughput收集器会进行两种操作,分别是Minor GC和Full GC。
- 通过GC日志中的时间输出,我们可以迅速地判断出Throughput收集器的GC操作对应用程序总体性能的影响。
堆大小的自适应调整和静态调整
Throughput收集器的调优几乎都是围绕停顿时间进行的,寻求堆的总体大小、新生代的大小以及老年代大小之间平衡。
考虑Throughput收集器的调优方案时有两种取舍。首先比较经典的是编程技术上的取舍,即时间与空间的取舍。第二个取舍与完成垃圾回收所需的时长相关。增大堆能够减少Full GC停顿发生的频率,但也有其局限性:由于GC时间变得更长,平均响应时间也会变长。类似地,为新生代分配更多的堆空间可以缩短Full GC的停顿时间,不过这又会增大老年代垃圾回收的频率(因为老年代空间保持不变或者变得更小了)。
下图展示了采用这些取舍的效果。图上显示的是运行在GlassFish实例上的股票Servlet应用,在使用不同大小的堆时,最大吞吐量的变化情况。使用256 MB的小堆时,应用服务器在垃圾回收上消耗了大量的时间(实际消耗的时间高达总时间的36%);吞吐量因此受到限制,比较低。随着堆大小的增加,吞吐量迅速提升——直到堆的容量增大到1500MB。这之后吞吐量的增速迅速减缓,这时应用程序实际已经不太受垃圾回收的影响(垃圾回收消耗的时间仅仅只占总时间的6%左右)。收益递减规律逐渐凸显出来:虽然应用程序可以通过增加内存的方式提升吞吐量,不过其效果已经很有限了。
堆的大小达到4500MB后,吞吐量开始出现少量下滑。这时,应用程序面临着第二个选择:增加的内存导致GC周期愈加冗长,虽然它们发生的频率小得多,但这些超长的GC周期也会影响系统整体的吞吐量。
这幅图中的数据取自关闭了自适应调整的JVM;它的最大、最小堆的容量设置成了同样的大小。对任何一种应用,都可以通过实验确定堆和代的最佳大小,但是,让JVM自己来选择通常是更容易的方法(这也是最通常的做法,因为默认情况下自适应调整就是开启的)。
为了达到停顿时间的指标,Throughput收集器的自适应调整会重新分配堆(以及代)的大小。使用这些标志可以设置相应的性能指标:-XX:MaxGCPauseMillis=N
和-XX:GCTimeRatio=N
。
快速小结
- 采用动态调整是进行堆调优极好的入手点。对很多的应用程序而言,采用动态调整就已经足够,动态调整的配置能够有效地减少JVM的内存使用。
- 静态地设置堆的大小也可能获得最优的性能。设置合理的性能目标,让JVM根据设置确定堆的大小是学习这种调优很好的入门课程。
理解CMS收集器
CMS收集器有3种基本的操作,分别是:
- CMS收集器会对新生代的对象进行回收(所有的应用线程都会被暂停);
- CMS收集器会启动一个并发的线程对老年代空间的垃圾进行回收;
- 如果有必要,CMS会发起Full GC。
下图展示了使用CMS回收新生代的情况。
CMS收集器的新生代垃圾收集与Throughput收集器的新生代垃圾收集非常相似:对象从Eden空间移动到Survivor空间,或者移动到老年代空间。CMS收集的GC日志也非常相似:
89.853:[GC 89.853:[ParNew: 629120K->69888K(629120K),0.1218970 secs]
1303940K->772142K(2027264K),0.1220090 secs]
[Tines: user=0.42 sys=0.02,real=0.12 secs]
这时的新生代空间大小为629MB;垃圾回收之后变成了69 MB(位于Survivor空间)。与Throughput收集器的日志类似,整个堆的大小为2027 MB,其中772 MB在垃圾回收之后依然被占用。虽然并行的GC线程使用了0.42秒的CPU时间,但整个垃圾回收过程仅耗时0.12秒。并发的垃圾回收周期如下面的图所示。
JVM会依据堆的使用情况启动并发回收。当堆的占用达到某个程度时,JVM会启动后台线程扫描堆,回收不用的对象。扫描结束的时候,堆的状况就像这幅图中最后一列所描述的情况一样。请注意,如果使用CMS回收器,老年代空间不会进行压缩整理:老年代空间由已经分配对象的空间和空闲空间共同组成。新生代垃圾收集将对象由Eden空间挪到老年代空间时,JVM会尝试使用那些空闲的空间来保存这些晋升的对象。
通过GC日志,可以看到回收过程划分成了好几个阶段。虽然主要的并发回收(ConcurentCycle)阶段都使用后台线程进行工作,有些阶段还是会暂停所有的应用线程,并因此引入短暂的停顿。
并发回收由“初始标记”阶段开始,这个阶段会暂停所有的应用程序线程:
89.976:[GC [1 CMs-initial-mark: 702254K(1398144K)]
772530K(2027264K),0.0830120 secs]
[Times: user=0.08 sys=0.00,real=0.08 secs]
这个阶段的主要任务是找到堆中所有的垃圾回收根节点对象。从第一组数据中可以看到这个例子中对象占用了老年代空间1398 MB中的702 MB空间。第二组数据显示整个堆的大小为2027 MB,其中772 MB被占用。应用程序线程在这个CMS回收周期中被暂停了0.08秒。
下一个阶段是“标记阶段”,这个阶段中应用程序线程可以持续运行,不会被中断。GC日志中,这个阶段的标识如下:
90.059:[CNS-concurrent-mark-start]
90.887:[CHS-concurrent-mark:0.823/0.828 secs]
[Times: user=1.11 sys=0.00,real=0.83 secs]
标识阶段耗时0.83秒(以及1.11秒的CPU时间)。由于这个阶段进行的工作仅仅是标记,不会对堆的使用情况产生实质性的改变,所以没有任何相关的数据输出。如果这个阶段还有数据输出,很可能是由于这0.83秒内新生代对象的分配导致了堆的增长,因为应用程序线程还在持续运行着。
然后是“预清理”阶段,这个阶段也是与应用程序线程的运行并发进行的:
90.887:[CMS-concurrent-preclean-start]
90.892:[CNS-concurrent-preclean:0.005/0.005 secs]
[Tlmes: user=0,01 sys=0.00,real=0.01 secs]
接下来的是“重新标记”阶段,这个阶段涵盖了多个操作:
90.892:[CNS-concurrent-abortable-preclean-start]
92.392:[GC 92.393:[ParNew: 629120K->69888K(629120K),0.1289040 secs]
1331374K->803967K(2027264K),0.1290200 secs]
[Times: user=0.44 sys=0.01,real=0.12 secs]
94.473:[CNS-concurrent-abortable-preclean: 3.451/3.581 secs]
[Times: user=5.03 sys=0.03,real=3.58 secs]
94.474:[GC[YG occupancy: 466937 K(629120 K)]
94.474:[Rescan (parallel),0.1850000 secs]
94.659:[weak refs processing, 0.0000370 secs]
94.659:[scrub string table,0.0611530 secs]
[1 CNS-remark:734079K(1398144K)]
1201017K(2027264K),0.1863430 secs]
[Times: user=0.60 sys=0.01,real=0.18 secs]
使用可中断预清理阶段是由于标记阶段不是并发的,所有的应用线程进入标记阶段后都会被暂停。如果新生代收集刚刚结束,紧接着就是一个标记阶段的话,应用线程会遭遇2次连续的停顿操作,CMS收集器希望避免这样的情况发生。使用可中断预清理阶段的目的就是希望尽量缩短停顿的长度,避免连续的停顿。
因此,可中断预清理阶段会等到新生代空间占用到50%左右时才开始。理论上,这时离下一次新生代收集还有半程的距离,给了CMS收集器最好的机会避免发生连续停顿。这个例子中,可中断预清理阶段在90.8秒开始,等待常规的新生代收集开始花了1.5秒(根据日志的记录,92.392秒开始)。CMS收集器根据以往的历史记录推算下一次新生代垃圾收集可能持续的时间。'这个例子中,CMS收集器计算出的时长大约是4.2秒。所以2.1秒之后(即94.4秒),CMS收集器停止了预清理阶段(这种行为被称为“放弃”了这次回收,不过这可能是唯一能停止该次回收的方式)。这之后,CMS回收器终于开始了标记阶段的工作执行,标记阶段的回收工作将应用程序线程暂停了0.18秒(在可中断预清理过程中,应用程序线程不会被暂停)。
接下来是另一个并发阶段——清除(sweep)阶段:
94.661:[CNS-concurrent-sweep-start]
95.223:[GC 95.223:[ParNew: 629120K->69888K(629120K),0.1322530 secs]
999428K->472094K(2027264K),0.1323690 secs]
[Times: user=0.43 sys=0.00,real=0.13 secs]
95.474:[CNS-concurrent-sweep:0.680/0.813 secs]
[Times: user=1.45 sys=0.00,real=0.82 secs]
这个阶段耗时0.82秒,回收线程与应用程序线程并发运行。碰巧这次的并发-清除过程被新生代垃圾回收中断了。新生代垃圾回收与清除阶段并没有直接的联系,将这个例子保留在这里是为了说明新生代的垃圾收集与老年代的垃圾收集可以并发进行。从上面的图中可以看到,新生代的状态在并发收集的过程中发生了变化——清除过程中新生代可能发生了多次垃圾收集(至少发生了一次新生代垃圾收集,因为可中断的预清理至少会经历一次新生代垃圾收集)。接下来是并发重置(concurrent reset)阶段:
95.474:[CMS-concurrent-reset-start]
95.479:[CMS-concurrent-reset: 0.005/0.005 secs]
[Times: user=0.00 sys=0.00,real=0.00 secs]
这是并发运行的最后一个阶段;CMS垃圾回收的周期至此告终,老年代空间中没有被引用的对象被回收。 但是无法从日志中了解到底有多少对象被回收;重置阶段的日志也没有提供更多的信息,最后还有多少堆空间被占用不得而知。为了发掘这些信息,可以尝试从新生代垃圾收集日志中找到一些蛛丝马迹,如下所示:
98.049:[GC 98.049:[ParNew:629120K->69888K(629120K),0.1487040 secs]
1031326K->504955K(2027264K),0.1488730 secs]
与89.853秒时(即CMS回收周期开始之前)老年代空间的占用情况相比较,那时的空间占用大约是703MB(整个堆的占用为772 MB,其中包含69 MB的Survivor空间占用,因此老年代占用了剩下的703 MB)。到98.049秒,垃圾收集结束,老年代空间占用大约为504 MB,由此可以计算出CMS周期回收了大约199MB的内存。
如果一切顺利,这些就是CMS垃圾回收会经历的周期,以及所有可能出现在CMS垃圾收集日志中的消息。不过,事实并不是这么简单,还需要查看另外三种消息,出现这些日志表明CMS垃圾收集碰到了麻烦。首当其冲的是并发模式失效(concurrent modefailure):
267.006:[GC 267.006:[ParNew: 629120K->629120K(629120K),0.0000200 secs]
267.006:[CMS267.350:[CMS-concurrent-mark: 2.683/2.804 secs]
[Times: user=4.81 sys=0.02,real=2.80 secs]
(concurrent mode failure):
1378132K->1366755K(1398144K),5.6213320 secs]2007252K->1366755K(2027264K),
[CMS Perm:57231K->57222K(95548K)],5.6215150 secs]
[Times: user=5.63 sys=0.00,real=5.62 secs]
新生代发生垃圾回收,同时老年代又没有足够的空间容纳晋升的对象时,CMS垃圾回收就会退化成Full GC。所有的应用线程都会被暂停,老年代中所有的无效对象都被回收,释放空间之后老年代的占用为1366 MB——这次操作导致应用程序线程停顿长达5.6秒。这个操作是单线程的,这就是为什么它耗时如此之长的原因之一(这也是为什么发生并发模式失效比堆的增长更加恶劣的原因之一)。
第二个问题是老年代有足够的空间可以容纳晋升的对象,但是由于空闲空间的碎片化,导致晋升失败:
6043.903:[GC 6043.903:
[ParNew (promotion failed): 614254K->629120K(629120K),0.1619839 secs]
6044.217:[CMS:1342523K->1336533K(2027264K),30.7884210 secs]
2004251K->1336533K(1398144K),
[CNS Perm; 57231K->57231K(95548K)],28.1361340 secs]
[Times:user=28.13 sys=0.38,real=28.13 secs]
在这个例子中,CMS启动了新生代垃圾收集,判断老年代似乎有足够的空闲空间可以容纳所有的晋升对象(否则,CMS收集器会报告发生并发模式失效)。这个假设最终被证明是错误的:由于老年代空间的碎片化(或者,不太贴切地说,由于晋升实际要占用的内存超过了CMS收集器的判断),CMS收集器无法晋升这些对象。
因此,CMS收集器在新生代垃圾收集过程中(所有的应用线程都被暂停时),对整个老年代空间进行了整理和压缩。好消息是,随着堆的压缩,碎片化问题解决了(至少在短期内不是问题了)。不过随之而来的是长达28秒的冗长的停顿时间。由于需要对整个堆进行整理,这个时间甚至比CMS收集器遭遇并发模式失效的时间还长的多,因为发生并发模式失效时,CMS收集器只需要回收堆内无用的对象。这时的堆就像刚由Throughput收集器做完Full GC一样(如图6-2);新生代空间完全空闲,老年代空间也已经整理过。
最终,CMS收集的日志中可能只有一条FullGC的记录,不含任何常规并发垃圾回收的日志。
279.803:[Full GC 279.803:
[CMS: 88569K->68870K(1398144K),0.6714090 secs]558070K->68870K(2027264K),
[CHS Pern:81919K->77654K(81920K)],
0.6716570 secs]
永久代空间用尽,需要回收时,就会发生这样的状况;应注意到,CMS收集后永久代空间大小减小了。Java 8中,如果元空间需要调整,也会发生同样的情况。默认情况下,CMS收集器不会对永久代(或元空间)进行收集,因此,它一旦被用尽,就需要进行FulI GC,所有没有被引用的类都会被回收。
快速小结
- CMS垃圾回收有多个操作,但是期望的操作是Minor GC和并发回收(concurrent cycle)。
- CMS收集过程中的并发模式失效以及晋升失败的代价都非常昂贵;我们应该尽量调优CMS收集器以避免发生这些情况。
- 默认情况下CMS收集器不会对永久代进行垃圾回收。