垃圾收集算法

Throughput收集器

新生代垃圾回收

Throughput收集器MinorGC

如上图,当新生代的Eden空间即将用尽时,收集器会把Eden空间所有对象挪走:一部分对象会被挪到Survivor空间(即上图中 S0 区域),其他会被移动到老年代。没有任何对象引用的会被回收。

1
17.806: [GC [PSYoungGen: 227983K->14463K(264128K)] 280122K->66610K(613696K), 0.0169320 secs] [Times: user=0.05 sys=0.00, real=0.02 secs]

输出日志如上,新生代的占用空间从回收之前的227983K到回收之后的14463K,新生代总空间为264128K。与此同时,堆的总空间从280122K减少到66610K,总空间为613696K。

最后一部分表示时间,完成垃圾回收操作时间是0.02秒,程序消耗的CPU时间是0.05秒。程序消耗时间比垃圾回收时间更多的原因在于新生代垃圾回收会使用多个线程。

老年代垃圾回收

Throughput收集器老年代垃圾回收FullGC

老年代垃圾收集会回收新生代中所有对象(包括Survivor空间中的对象)。只有活跃对象或者已经经过压缩整理的对象会继续待在老年代空间中。

1
2
64.546: [Full GC [PSYoungGen: 15808K->0K(339456K)]
[ParOldGen: 457753K->392528K(554432K)] 473561K->392528K(893888K) [PSPermGen: 56728K->56728K(115392K)], 1.3367080 secs] [Times: user=4.44 sys=0.01, real=1.34 secs]

老年代输出日志如上,新生代经过FullGC之后变为0K,老年代空间从457753K变成了392528K,这里永久代的空间大小并没有变化。这是因为在多数的Full GC中,永久代对象并不会并回收。但如果永久代空间耗尽,JVM就会发起Full GC回收永久代空间中的对象。

注意:以上信息是在Java 7上面打印的信息。在Java 8中类似的信息我们可以在元空间中看到。

堆大小自适应调整和静态调整

Throughput收集器调优方案有两中取舍:

  1. 时间和空间的取舍;
  2. 完成垃圾回收所需时长;

虽然增加堆的大小,可以降低触发垃圾回收的频率,但也会增加每次垃圾回收的时长,需要平衡这两者。

Throughput默认是开启自适应调整堆以及分代的大小,可以通过以下参数来设置期望的性能指标:

  • -XX:MaxGCPauseMillis=N,设定应用可承受的最大停顿时间。该值会影响Minior GC和Full GC,如果该值非常小,老年代也会非常小,会导致频繁的Full GC,谨慎设置该值,默认不设置。
  • -XX:GCTimeRatio=N,设置希望应用程序在垃圾回收上花费的时间。公式如下:
1
2
# Throughput 表示期望应用程序线程工作时间占比(比如:95%,则GCTimeRatio = 19)
GCTimeRatio = Throughput /(1 - Throughput)

注意:MaxGCPauseMillis 的优先级高于-Xms和-Xmx,一旦设置该值,新生代和老年代会随之进行调整,直至满足期望的停顿时间。对于大部分情况,默认的动态调整已能满足大部分的需求。

理解CMS收集器

CMS新生代垃圾回收
CMS新生代垃圾回收和Throughput的新生代垃圾回收非常相似。

CMS并发垃圾回收

CMS会根据堆的使用情况启动并发回收,当堆的占用达到某个程度时,JVM会启动后台线程扫描堆,回收不用的对象。回收情况如上图所示,出现在老年代的空间不连续是由于CMS回收时不会对老年代空间进行整理。

CMS的并发回收会有好几个阶段:

1、初始标记阶段:主要任务是识别根可达的下一层对象,这个阶段会暂停所有应用线程

1
2
3
4
5
6
## 初始标记阶段日志
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]

## 702254K(1398144K)表示对象占用了老年代空间1398MB中的702MB空间
## 772530K(2027264K)表示整个堆中,对象占用了2027MB中的772MB空间
## 该阶段暂停了0.08秒

2、标记阶段:会基于初始标记的对象继续往下标记可达对象,与应用程序并发执行

1
2
90.059: [CMS-concurrent-mark-start] 
90.887: [CMS-concurrent-mark: 0.823/0.828 secs] [Times: user=1.11 sys=0.00, real=0.83 secs]

3、预清理阶段:使用单线程跟踪记录 回溯上一步因并发 应用线程修改的对象引用,与应用程序并发执行

1
2
90.887: [CMS-concurrent-preclean-start] 
90.892: [CMS-concurrent-preclean: 0.005/0.005 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]

4、重新标记:重新标记上次并发可能修改的对象,会暂停所有应用程序。为避免与Minior GC发生连续暂停,会有可中断预清理阶段,目的就是减少停顿时间和连续停顿。

1
2
3
4
5
6
7
90.892: [CMS-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: [CMS-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.0011530 secs] [1 CMS-remark: 734079K(1398144K)] 1201017K(2027264K), 0.1863430 secs] [Times: user=0.60 sys=0.01, real=0.18 secs]

5、清除阶段:清理未标记的所有垃圾对象,与应用程序并发执行

1
2
3
4
## 这里日志在清除阶段,插入了一次新生代垃圾回收
94.661: [CMS-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: [CMS-concurrent-sweep: 0.680/0.813 secs] [Times: user=1.45 sys=0.00, real=0.82 secs]

6、并发重置:并发的重置所有算法需要的内部数据结构,为下个收集周期作准备。

1
2
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]

注意:理想的并发回收经历如上面所述。然而一但在新生代垃圾回收时,老年代没有足够的空间接受晋升的对象,或者老年代碎片化严重,没有连续的空间供分配,就会导致发生和Throughput收集器一样的Full GC,此时新生代空间完全空闲,老年代空间也会经过整理。

并发模式失效调优

针对并发模式失效,可通过以下方法来避免或者减少发生的次数:

  1. 增大老年代空间;
  2. 以更高的频率运行后台的回收线程,通过CMSInitiatingOccupancyFraction来设置,将该值设置的比活跃数据小;
  3. 开启更多的回收线程:XX:ConcGCThreads=N可调整后台线程数量,默认ConcGCThreads = (3 + ParallelGCThreads) / 4;

永久代调优

如果永久代需要进行垃圾收集时(Java8之后调整元空间大小时),就会发生Full GC。此种情况一般发生在频繁部署的服务器上,或者需要频繁定义类的应用中。

通过-XX:+CMSPermGenSweepingEnabled和XX:+CMSClassUnloadingEnabled开启,使永久代和老年代一样进行回收。
-XX:CMSInitiatingPermOccupancyFraction=N可以设置当永久代空间达到一定占比时启动永久代垃圾回收(默认该值为80%)。

增量式CMS垃圾收集

该方式主要适用于在资源受限的机器上,但又要求较小的停顿可开启使用。不推荐使用。可以替代考虑使用G1收集器。

-XX:+CMSIncrementalMode可以开启增量式垃圾收集。-XX:CMSInc rementalSafetyFactor=N、 -XX:CMSIncrementalDutyCycleMin=N和 -XX:CMSIncrementalPacing可以通知后台线程让出多少CPU周期给应用线程。

理解G1垃圾收集器

G1虽然也有和其他收集器一样的堆分区概念,分为新生代和老年代。但不同的是,G1引入了分区的概念,将堆空间分成固定大小的分区(默认情况下,一个堆会被划分成2048个分区),同一个代中的分区也并非是连续的。为老年代设计分区的初衷在于,是由于发现在并发线程回收老年代空间时,发现有的分区垃圾对象数量很多,另一些分区相对较少。G1专注于垃圾最多的分区,所以相比能花较少的时间来回收这些分区垃圾。

注意,上述回收垃圾较多的分区的算法并不适用新生代。因为新生代进行垃圾回收时,新生代空间要么被回收,要么被晋升(对象被移动到Survivor空间,或者移动到老年代)。新生代采用分区的原因,是因为采用预定义的分区便于分代大小的调整。

G1新生代垃圾回收

当Eden空间即将耗尽时,会触发新生代垃圾收集。从上图我们可以看到垃圾收集时,eden对象被回收或晋升Survior,Survior回收或晋升老年代空间。

G1并发收集前后

上图中,我们需要注意三点:

  1. 新生代空间的变化,并发周期中,至少进行了一次新生代垃圾收集。在Eden空间中的分区标记为完全释放之前,新的Eden分区已经开始分配。
  2. 被标记为X的为老年代空间分区,表示被识别出的包含垃圾最多的分区。
  3. 老年代的空间占用,在周期后实际可能更高,因为标记周期中,新生代的对象会晋升到老年代,而标记周期中老年代并不会释放对象。

G1收集器会经历以下几个阶段:

  1. 初始标记阶段:应用线程会暂停;
  2. 扫描根目录:使用后台线程扫描根目录,如果扫描期间发生新生代空间用尽,新生代垃圾收集必须等待根扫描结束才开始,意味着新生代垃圾收集时间会更长;
  3. 并发标记阶段:使用后台线程进行,期间可能会发生新生代垃圾收集;
  4. 重新标记阶段:应用线程会暂停;
  5. 清理阶段。

触发Full GC的4种情况:

  • 并发模式失效:G1收集器启动标记周期,但在周期完成之前老年代空间被填满。这种情况下,G1会放弃标记周期。

解决方案:增加堆的空间大小;调整G1后台出现线程的周期;增加处理能力;

  • 在混合式垃圾回收时,老年代空间在收集器释放出足够空间之前被耗尽

  • 疏散失败:新生代垃圾收集时,Survivor空间和老年代没有足够的空间容纳所有的幸存对象。

解决方案:增加堆的大小

  • 巨型对象分配失败

G1垃圾收集器调优

通常以下方法可以避免Full GC:

  1. 增加总的堆空间大小或者通过调整新生代和老年代之间的比例来增加老年代的空间大小;
  2. 增加后台线程的数目:如果有空闲的CPU利用,对于应用线程暂停运行的周期,可以通过ParallelGCThreads标志设置运行的线程数。对于并发运行阶段,可以使用ConGCThreads标志设置运行线程数:ConcGCThreads = (ParallelGCThreads + 2) / 43
  3. 以更高频率进行G1的后台垃圾收集活动:G1 垃圾收集周期通常在堆的占用达到参数 -XX:InitiatingHeapOccupancyPercent=N设定的比率启动,默认值为 45;
  4. 在混合式垃圾回收周期中完成更多的垃圾收集工作:减少-XX:G1MixedGCCountTarget=N值(默认8)可以帮助解决晋升失败问题(但GC周期的停顿时间会增长)。也可以通过MaxGCPauseMillis参数设定GC停顿可忍受的最大时长;

资料参考

【1】:Java性能权威指南.pdf

【2】:Java Platform, Standard Edition HotSpot Virtual Machine Garbage Collection Tuning Guide