Java Optimize GC Knowledge

Memory

Heap Space

选择堆的大小其实是一种平衡。如果分配的堆过于小,程序的大部分时间可能都消耗在 GC 上,没有足够的时间去运行应用程序的逻辑。但是,简单粗暴地设置一个特别大的堆也不是解决问题的方法。GC 停顿消耗的时间取决于堆的大小,如果增大堆的空间,停顿的持续时间也会变长。这种情况下,停顿的频率会变得更少,但是它们持续的时间会让程序的整体性能变慢。

使用超大堆还有另一个风险。操作系统使用虚拟内存机制管理机器的物理内存。一台机器可能有8G的物理内存,不过操作系统可能让你感觉有更多的可用内存。虚拟内存的数量取决于操作系统的设置,譬如操作系统可能让你感觉它的内存达到了16G。操作系统通过名为“交换”(swapping)(或者称之为分页)。你可以载入需要16G内存的应用程序,操作系统在需要时会将程序运行时不活跃的数据由内存复制到磁盘。再次需这部分内存的内容时,操作系统再将它们由磁盘重新载入到内存(为了腾出空间,通常它会先将另一部分内存的内容复制到磁盘)。

系统中运行着大量不同的应用程序时,这个流程工作得很顺畅,因为大多数的应用程序不会同时处于活跃状态。但是对于 Java 应用,它工作得并不那么好。如果一个 Java 应用使用了这个系统上大约 12G 的堆,操作系统可能在 RAM 上分配了 8G 的堆空间, 另外 4G 的空间存在于磁盘(这个假设对实际情况进行了一些简化,因为应用程序也会使用部分的RAM)。JVM 不会了解这些(操作系统完全屏蔽了内存交换的细节)。这样 JVM 愉快地填满了分配给它的 12G 堆空间。但这样就导致了严重的性能问题,因为操作系统需要将相当一部分的数据由磁盘交换到内存(这是一个昂贵操作的开始)。

另外就是,当发生 Full GC 的时候,JVM 必须访问整个堆的内容,此时如果触发了 swapping 操作,停顿时间将会比正常停顿时间更长(可能会是多个数量级增长)。同理可知,如果使用 Concurrent 收集器,后台线程在回收堆时,它的速度也可能会被拖慢,因为需要等待从磁盘复制数据到内存,结果导致发生代价昂贵的并发模式失效。

堆的大小由2个参数值控制:分别是初始值(通过 -Xms 设置)和最大值(通过 -Xmx 设置)。默认值的调节取决于多个因素,包括操作系统类型、系统内存大小、使用的 JVM。 其他的命令行标志也会对该值造成影响。

堆大小的调节是 JVM 自适应调优的核心。

JVM 的目标是依据系统可用的资源情况找到一个”合理的”默认初始值,当且仅当应用程序需要更多的内存(依据垃圾回收时消耗的时间来决定)时将堆的大小增大到一个合理的最大值。

Operation Xms Xmx
Linux Client 32 16MB 256MB
Linux Server 32 64MB Min(1GB, 1/4 physical memory)
Linux Server 64 Min(512MB, 1/64 physical memory) Min(32GB, 1/4 physical memory)
MacOS Server 64 64MB Min(1GB, 1/4 physical memory)

如果 JVM 发现使用初始的堆大小,频繁地发生 GC,它就会尝试增大堆的空间,直到 JVM 的 GC 的频率回归到正常的范围,或者直到堆大小增大到它的上限值。

选择Heap Size的一个经验法则:完成 Full GC 后,应该释放出70%的空间,30%的空间仍然占用。

注意,即使你设置了堆的最大值,还是会发生堆的自动调节:初始时堆以默认的大小开始运行,为了达到根据垃圾收集算法设置的性能目标,JVM 会逐步增大堆的大小。

将堆的大小设置得比实际需要更大不一定会带来性能损耗:堆并不会无限地增大,JVM 会调节堆的大小直到其满足 GC 的性能目标。

另一方面,如果你确切地了解应用程序需要多大的堆,那么你可以将堆的初始值和最大值直接设置成对应的数值(例如,-Xms4096m -Xmx4096m)。这种设置能稍微提高 GC 的运行效率,因为它不再需要估算堆是否需要调整大小了。

ParallelScavenge(Throughput)

ParallelScavenge 的调优几乎都是围绕停顿时间进行的,寻求堆的总体大小、新生代的大小以及老年代大小之间平衡。

调优方案时有两种取舍。首先比较经典的是编程技术上的取舍,即时间与空间的取舍。第二个取舍与完成垃圾回收所需的时长相关。

增大堆能够减少 Full GC 停顿发生的频率,但也有其局限性:由于 GC 时间变得更长,平均响应时间也会变长。类似地,为新生代分配更多的堆空间可以缩短 Full GC 的停顿时间,不过这又会增大老年代垃圾回收的频率(因为老年代空间保持不变或者变得更小了)。

使用 256 MB 的小堆时,应用服务器在垃圾回收上消耗了大量的时间(实际消耗的时间高达总时间的 36%),吞吐量因此受到限制,比较低。随着堆大小的增加,吞吐量迅速提升——直到堆的容量增大到 1500 MB。这之后吞吐量的增速迅速减缓,这时应用程序实际已经不太受垃圾回收的影响(垃圾回收消耗的时间仅仅只占总时间的6%左右)。收益递减规律逐渐凸显出来:虽然应用程序可以通过增加内存的方式提升吞吐量,不过其效果已经很有限了。

堆的大小达到 4500 MB 后,吞吐量开始出现少量下滑。这时应用程序面临着第二个选择:增加的内存导致 GC 周期愈加冗长,虽然它们发生的频率小得多,但这些超长的 GC 周期也会影响系统整体的吞吐量。

这幅图中的数据取自关闭了自适应调整的 JVM,即它的最大、最小堆的容量设置成了同样的大小。对任何一种应用,我们都可以通过实验确定堆和代的最佳大小,但是让 JVM 自己来选择通常是更容易的方法(这也是最通常的做法,因为默认情况下自适应调整就是开启的)。

如果设置了 -XX:MaxGCPauseMillis=N 和 -XX:GCTimeRatio=N,ParallelScavenge 就会自适应地重新分配堆(包括代)的大小。

MaxGCPauseMillis 标志用于设定应用可承受的最大停顿时间。我们可以将其设置为0或者一些非常小的值,譬如50毫秒。请注意,这个标志设定的值同时影响 Minor GC 和 Full GC。如果设置的值非常小,那么应用的老年代最终就会非常小:譬如,你设定该参数希望应用在50毫秒内完成垃圾回收,这将会触发非常频繁的 Full GC,对应用程序的性能而言将是灾难性的。因此,设定该值时,请尽量保持理性,将该值设定为可达到的合理值。默认情况下,我们不设定该参数。

GCTimeRatio 标志可以设置你希望应用程序在垃圾回收上花费多少时间(与应用线程的运行时间相比较)。它是一个百分比,因此N值的计算稍微有些复杂。将N值代入下面的公 式可以计算出理想情况下应用线程的运行时间所占的百分比:

ThroughputGoal = 1 - 1 / (1 + GCTimeRatio)

GCTimeRatio 的默认值是99。将该值代入公式能得到0.99,这意味着应用程序的运行时间占总时间的99%,只有1%的时间消耗在垃圾回收上。但是,不要被列出的默认值搞糊涂。 譬如,GCTimeRatio 设置为95并不意味着会使用总时间的5%去做垃圾回收。它表示的是最多会使用总时间的1.94%去做垃圾回收。

建议先设定 ThroughputGoal 的期望值,通过 ThroughputGoal 去推算 GCTimeRatio 的值:

GCTimeRatio = ThroughputGoal / (1 - ThroughputGoal)

对于95%(0.95)的吞吐量目标,利用该公式计算出的 GCTimeRatio 是19。

JVM 使用这两个标志在堆的初始值(-Xms)和最大值(-Xmx)之间设置堆的大小。MaxGCPauseMillis 标志的优先级最高,如果设置了这个值,新生代和老年代会随之进行调整,直到满足对应停顿时间的目标。一旦这个目标达成,堆的总容量就开始逐渐增大,直到运行时间的比率达到设定值。这两个目标都达成后,JVM 会尝试缩减堆的大小,尽可能以最小的堆大小来满足这两个目标。

由于默认情况不设置停顿时间目标,通常自动堆调整的效果是堆(以及代空间)的大小会持续增大,直到满足设置的 GCTimeRatio 目标。不过,在实际操作中,该标志的默认设置已经相当优化了。每个人的使用经验各有不同,但是根据我以往的经验,如果应用程序在垃圾回收上消耗总时间的3%至6%,其效果会是相当不错的。


Generation Space

一旦堆的大小确定下来,接着就需要决定分配多少堆给新生代空间,多少给老年代空间。

我们应该清楚地了解代的划分对性能的影响:如果新生代分配得比较大,垃圾收集发生的频率就比较低,从新生代晋升到老年代的对象也更少。任何事物都有两面性,采用这种分配方法,老年代就相对比较小,比较容易被填满,会更频繁地触发 Full GC。

所有用于调整代空间的命令行标志调整的都是新生代空间,新生代空间剩下的所有空间都被老年代占用。

  • -XX:NewRatio=N 设置新生代与老年代的空间占用比率,Default is 2。计算公式:Initial Young Gen Size = Initial Heap Size / (1 + NewRatio),Default is 33%。
  • -XX:NewSize=N 设置新生代空间的初始大小。
  • -XX:MaxNewSize=N 设置新生代空间的最大大小。
  • -XmnN 将 NewSize 和 MaxNewSize 设定为同一个值的快捷方法。

Permanent Generation and Metaspace

  • Java 7:Permanent Generation
  • Java 8:Metaspace
JVM Type Min Max Limit
Client 32 12MB 64MB Infinite
Server 32 16MB 64MB Infinite
Server 64 20.75MB 82MB Infinite

对于永久代而言,可以通过 -XX:PermSize=N、-XX:MaxPermSize=N 标志调整大小。而元空间的大小可以通过 -XX:MetaspaceSize=N 和 -XX:MaxMetaspaceSize=N 调整。

由于元空间默认的大小是没有作限制的,因此 Java 8(尤其是 32 位系统)的应用可能 由于元空间被填满而耗尽内存。如果元空间增长得过大,通过设置 MaxMetaspaceSize 你可以调整元空间的上限,将其限制为一个更小的值,不过这又会导 致应用程序最后由于元空间耗尽,发生 OutOfMemoryError 异常。解决这类问题的终极方 法还是定位出为什么类的元空间会变得如此巨大。

如果程序在启动时发生大量的 Full GC(因为需要载入数量巨大的类),通常都是由于永久代或者元空间发生了大小调 整,因此这种情况下为了改善启动速度,增大初始值是个不错的主意。对于定义了大量类的 Java 7 应用,同时还需要增大永久代空间的最大值。譬如,通常情况下应用服务器永久代的最大值会设置为128MB、192MB或者更多。


Thread

GC Thread

除 Serial 收集器之外几乎所有的垃圾收集器使用的算法都基于多线程。启动的线程数由
-XX:ParallelGCThreads=N 参数控制。

受到该参数影响的 GC 分别是:

  • -XX:+UseParallelGC 收集新生代空间
  • -XX:+UseParallelOldGC 收集老年代空间
  • -XX:+UseParNewGC 收集新生代空间
  • -XX:+UseG1GC 收集新生代空间
  • CMS的 STW 阶段(非 Full GC)
  • G1的 STW 阶段(非 Full GC)

由于 GC 操作会暂停所有的应用程序线程,JVM 为了尽量缩短停顿时间就必须尽可能地利用更多的 CPU 资源。这意味着,默认情况下,JVM 会在机器的每个 CPU 上运行一个线程,最多同时运行 8 个。一旦达到这个上限,JVM 会调整算法,每超出 5/8 个 CPU 启动一个新的线程。所以总的线程数就是(这里的 N 代表 CPU 的数目):

ParallelGCThreads = 8 + ((N - 8) * 5 / 8)

5 / 8 = 0.625

有时候使用这个算法估算出来的线程数目会偏大。如果应用程序使用一个较小的堆(譬如大小为 1 GB)运行在一个八颗 CPU 的机器上,使用4个线程或者6个线程处理这个堆可能会更高效。在一个128颗 CPU 的机器上,启动83个垃圾收集线程可能也太多了,除非系统使用的堆已经达到了最大上限。

除此之外,如果机器上同时运行了多个 JVM 实例,限制所有 JVM 使用的线程总数是个不错的主意。这时,垃圾收集线程运行起来会更加高效,每个线程都能 100% 地利用各 CPU 的资源。在拥有更多 CPU、运行了多个 JVM 的机器上,通常出现的问题是有太多的垃圾回收 线程在同时并发运行。

以16核 CPU 的机器同时运行4个 JVM 实例为例,每个 JVM 默认会启动13个垃圾收集线程。如果4个 JVM 同时进行垃圾回收操作,机器上会启动大约52个 CPU 密集型线程竞争 CPU 资源。这会导致大量的冲突,如果能够限制每个 JVM 最多启动4个垃圾收集线程,效率会高很多。即使在同一个时刻,4个 JVM 中的线程不大可能同时进行 GC 操作, 一个 JVM 上同时运行13个线程也意味着其他 JVM 上的应用程序线程不得不在一台总共有16个 CPU,且其中13个 CPU 被繁忙的垃圾收集任务100%占用的机器上竞争资源。 这种情况下,将每个 JVM 的垃圾收集线程数限制到4个是一个比较合理的平衡。注意,这个标志不会对 CMS 收集器或者 G1 收集器的后台线程数作设定(虽然它们也会受设置的影响)。


Monitor

GC Log

Enable Print Log Flag:

  • -XX:+PrintGCDetails
  • -XX:+PrintGCTimeStamps:相对于 JVM 启动的时间的值。
  • -XX:+PrintGCDateStamps:实际的日期字符串。由于需要格式化时间,需要一点额外开销。
  • -Xloggc:filename:输出到文件。
  • -Xloggc:自动地开启基本日志模式。
  • -XX:+UseGCLogfileRotation、-XX:NumberOfGCLogfiles=N and -XX:GCLogfileSize=N:控制日志文件的循环。

    默认情况下,UseGCLogfileRotation 标志是关闭的。开启 UseGCLogfileRotation 标志后,默认的文件数目是0(意味着不作任 何限制),默认的日志文件大小是0(同样也是不作任何限制)。因此,为了让日志循环功能真正生效,我们必须为所有这些标志设定值。需要注意的是,如果设定的数值不足 8 KB 的话,日志文件的大小会以 8 KB 为单位规整。

ParallelScavenge Log

ParallelScavenge 会进行两种操作:

  • Minor GC
  • Full GC

Minor GC日志形式

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]

这次 GC 在程序开始运行 17.806 秒后发生。现在新生代中对象占用的空间为 14463 KB (约为 14 MB,位于 Survivor 空间内);GC 之前,新生代对象占用的空间为 227983 KB (约为 227 MB)。新生代这时总的大小为 264 MB。

1000 KB = 1 MB

与此同时,堆的空间总的使用情况(包含新生代和老年代)从 280 MB 减少到了 66 MB,这个时刻整个堆的大小为 613 MB。完成垃圾回收操作耗时 0.02 秒(排在输出最后的 Real 时间是 0.0 169 320 秒——实际时间进行了归整)。程序消耗的 CPU 时间比 Real 时间往往更多,原因是新生代垃圾回收会使用多个线程(GC使用了4个并行的线程)。

PSYoungGen:-XX:+UseParallelOldGC,新生代和老年代都使用并行回收收集器。如果只启用-XX:+UseParallelGC,则表示新生代使用并行回收收集器,老年代使用串行收集器。
ParNew:-XX:+UseParNewGC,新生代使用并行收集器,老年代使用串行回收收集器。如果增加了-XX:+UseConcMarkSweepGC,则表示老年代使用CMS。
Throughput GC 通常是指 ParallelScavenge。它的目标是达到一个可控制的吞吐量(Throughput),而CMS是尽可能地缩短在GC时用户线程的停顿时间。

Full GC日志形式

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

新生代的空间使用在经历 Full GC 之后变为 0 字节(新生代的大小为 339 MB)。老年代中的空间使用从 457 MB 减少到了 392 MB,因此整个堆的使用从 473 MB 减少到了 392 MB。

永久代空间的使用没有发生变化;在多数的 Full GC 中,永久代的对象都不会被回收。由于 Full GC 要进行大量的工作,所以消耗了约 1.3 秒的 Real 时间,4.4 秒的 CPU 时间(GC使用了4个并行的线程)。

如果永久代空间耗尽,JVM 会发起 Full GC 回收永久代中的对象,这时你会观察到永久代空 间的变化——这是永久代进行回收唯一的情况。这个例子使用的是 Java 7;在 Java 8 中, 类似的信息可以在元空间中找到。


CMS Log

CMS 的新生代垃圾收集与 ParallelScavenge 的新生代垃圾收集非常相似:对象从 Eden 空间移动到 Survivor 空间,或者移动到老年代空间。CMS GC 日志也非常相似。

注意点:

  • CMS 垃圾回收有多个操作,但是期望的操作是 Minor GC 和 concurrent cycle,尽可能避免 Full GC。
  • CMS 回收过程中的并发模式失效以及晋升失败的代价都非常昂贵;应该尽量调优 CMS 以避免发生这些情况。
  • 默认情况下 CMS 不会对永久代进行垃圾回收。

Minor GC日志形式

1
89.853: [GC 89.853: [ParNew: 629120K->69888K(629120K), 0.1218970 secs] 1303940K->772142K(2027264K), 0.1220090 secs]     [Times: user=0.42 sys=0.02, real=0.12 secs]

这时的新生代空间大小为629MB。垃圾回收之后变成了 69MB(位于 Survivor 空间)。与 ParallelScavenge 日志类似,整个堆的大小为2027MB,其中 772MB在垃圾回收之后依然被占用。虽然并行的GC线程使用了0.42秒的 CPU 时间,但整个垃圾回收过程仅耗时 0.12秒。

回收新生代前后 Heap 变化如下:

JVM 会依据堆的使用情况启动并发回收。当堆的占用达到某个程度时,JVM 会启动后台线程扫描堆,回收不用的对象。扫描结束的时候,堆的状况如上图的情况一样。

请注意,如果使用 CMS 回收器,老年代空间不会进行压缩整理:老年代空间由已经分配对象的空间和空闲空间共同组成。新生代垃圾收集将对象由 Eden 空间挪到老年代空间时,JVM 会尝试使用那些空闲的空间来保存这些晋升的对象。

CMS-initial-mark 日志形式,STW

1
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(1398144K) 中的 702MB(702254K) 空间。第二组数据显示整个堆的大小为 2027MB(2027264K),其中 772MB(772530K)被占用。应用程序线程在这个 CMS 回收周期中被暂停了0.08秒。

CMS-concurrent-mark-start 日志形式

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]

标识阶段耗时 0.83秒(以及 1.11秒的 CPU 时间)。由于这个阶段进行的工作仅仅是标记,不会对堆的使用情况产生实质性的改变,所以没有任何相关的数据输出。如果这个阶段还有数据输出,很可能是由于这 0.83秒内新生代对象的分配导致了堆的增长,因为应用程序线程还在持续运行着。

CMS-concurrent-preclean-start 日志形式

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]

CMS-concurrent-abortable-preclean-start

1
2
3
4
5
6
7
8
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]
# STW 0.18s
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]

这个”可中断预清理”(abortable preclean)阶段是做什么的呢?

使用可中断预清理阶段是由于标记阶段(严格说起来,它应该是最后的输出项)不是并发的,所有的应用线程进入标记阶段后都会被暂停。如果新生代收集刚刚结束,紧接着就是一个标记阶段的话,应用线程会遭遇2次连续的停顿操作,CMS 希望避免这样的情况发生。使用可中断预清理阶段的目的就是希望尽量缩短停顿的长度,避免连续的停顿。

因此,可中断预清理阶段会等到新生代空间占用到50%左右时才开始。理论上,这时离下一次新生代收集还有半程的距离,给了 CMS 最好的机会避免发生连续停顿。这个例子中,可中断预清理阶段在90.8秒开始,等待常规的新生代收集开始花了1.5秒(根据日志的记录,92.392秒开始)。CMS 根据以往的历史记录推算下一次新生代垃圾收集可能持续的时间。这个例子中,CMS 计算出的时长大约是4.2秒。所以2.1秒之后(即 94.4 秒),CMS 收集器停止了预清理阶段(这种行为被称为“放弃”了这次回收,不过这可能是唯一能停止该次回收的方式)。这之后,CMS 终于开始了标记阶段的工作执行,标记阶段的回收工作将应用程序线程暂停了0.18秒(在可中断预清理过程中,应用程序线程不会被暂停)。

CMS-concurrent-sweep-start 日志形式

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

这个阶段耗时0.82秒,回收线程与应用程序线程并发运行。碰巧这次的并发-清除过程被新生代垃圾回收中断了。新生代垃圾回收与清除阶段并没有直接的联系,将这个例子保留在这里是为了说明新生代的垃圾回收与老年代的垃圾回收可以并发进行

从上图可知,新生代的状态在并发收集的过程中发生了变化—清除过程中新生代可能发生了多次垃圾收集(至少发生了一次新生代垃圾回收,因为可中断的预清理至少会经历一次新生代垃圾回收)。

CMS-concurrent-reset-start 日志形式

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]

这是并发运行的最后一个阶段。CMS 回收的周期至此告终,老年代空间中没有被引用的对象被回收。遗憾的是,我们无法从日志中了解到底有多少对象被回收。重置阶段的日志也没有提供更多的信息,最后还有多少堆空间被占用不得而知。为了发掘这些信息,我们尝试从新生代垃圾收集日志中找到一些蛛丝马迹,如下所示:

1
98.049: [GC 98.049: [ParNew: 629120K->69888K(629120K), 0.1487040 secs] 1031326K->504955K(2027264K), 0.1488730 secs]

与89.853秒时(即 CMS 回收周期开始之前)老年代空间的占用情况相比较,那时的空间占用大约是703MB(整个堆的占用为 772MB,其中包含69MB的 Survivor 空间占用,因此老年代占用了剩下的703MB)。到98.049秒,垃圾收集结束,老年代空间占用大约为504MB,由此可以计算出 CMS 周期回收了大约199MB的内存。

concurrent mode failure 日志形式

1
2
3
4
5
6
7
8
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。所有的应用线程都会被暂停,老年代中所有的无效对象都被回收,释放空间之后老年代的占用为1366MB——这次操作导致应用程序线程停顿长达5.6秒,这个操作是单线程的,这就是为什么它耗时如此之长的原因之一。

promotion failed 日志形式

1
2
3
4
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),
[CMS 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 收集器只需要回收堆内无用的对象。

最终,CMS 日志中可能只有一条 Full GC 的记录,不含任何常规并发垃圾回收的日志。

1
2
3
279.803: [Full GC 279.803:
[CMS: 88569K->68870K(1398144K), 0.6714090 secs] 558070K->68870K(2027264K),
[CMS Perm : 81919K->77654K(81920K)], 0.6716570 secs]

应注意到,CMS 回收后永久代空间大小减小了。Java 8中,如果元空间需要调整,也会发生同样的情况。默认情况下,CMS 不会对永久代(或元空间)进行回收,因此,它一旦被用尽,就需要进行 Full GC,所有没有被引用的类都会被回收。


G1 Log

Minor 日志形式,STW

1
2
3
4
5
23.430: [GC pause (young), 0.23094400 secs]
...
[Eden: 1286M(1286M)->0B(1212M)
Survivors: 78M->152M Heap: 1454M(4096M)->242M(4096M)]
[Times: user=0.85 sys=0.05, real=0.23 secs]

这里新生代垃圾收集的 Real 时间消耗是0.23秒,这期间,垃圾收集线程消耗了0.85秒的 CPU 时间,1286ßMB的对象移出了 Eden 空间(Eden 空间的大小调整到了1212MB)。这其中的74MB移动到了 Survivor 空间(Survivor 空间的大小从78 MB增加到了152MB),其余的空间都被垃圾收集器回收掉了。

通过观察堆的总占用降低了1212MB我们知道,这些空间被释放了。通常情况下,一部分对象已经从 Survivor 空间移动到老年代空间,如果 Survivor 空间被填满,无法容纳新生代的晋升对象,部分 Eden 空间的对象会被直接晋升到老年代空间——这种情况下,老年代空间的占用也会增加。

initial-mark 日志形式,STW

1
2
3
4
50.541: [GC pause (young) (initial-mark), 0.27767100 secs]
[Eden: 1220M(1220M)->0B(1220M)
Survivors: 144M->144M Heap: 3242M(4096M)->2093M(4096M)]
[Times: user=1.02 sys=0.04, real=0.28 secs]

同常规的新生代垃圾收集一样,初始—标记阶段中,应用线程被暂停(大约时长0.28秒),之后新生代被清空(71MB的数据从新生代移到了老年代)。初始—标记阶段的输出日志表明后台并发周期启动。

由于初始—标记阶段也需要暂停所有的应用线程,G1 重用了新生代 GC 周期来完成这部分的工作。在新生代垃圾回收中添加初始标记阶段的影响并不大:与之前的垃圾收集相比较,CPU 周期的开销增加了大约20%,即便如此,停顿时间只有些微的增长(幸运的是,这台机器上有空闲的 CPU 周期可以运行并发 G1 回收线程,否则停顿时间会更长一些)。

concurrent-root-region-scan-start 日志形式

G1 扫描根分区(root region)

1
2
50.819: [GC concurrent-root-region-scan-start]
51.408: [GC concurrent-root-region-scan-end, 0.5890230]

这个过程耗时0.58秒,不过扫描过程中不需要暂停应用线程,G1 使用后台线程进行扫描工作。不过,这个阶段中不能发生新生代垃圾收集,因此预留足够的 CPU 周期给后台线程运行是非常重要的。如果扫描根分区时,新生代空间刚巧用尽,新生代垃圾回收(会暂停所有的应用线程)必须等待根扫描结束才能完成。效果上,这意味着新生代垃圾收集的停顿时间会更长(远超过正常的耗时)。

如果出现上述情况,则输出的日志如下所示:

1
2
3
350.994: [GC pause (young)
351.093: [GC concurrent-root-region-scan-end, 0.6100090]
351.093: [GC concurrent-mark-start], 0.37559600 secs]

此处 GC 的停顿发生在根分区扫描之前,这意味着 GC 停顿还会继续等待,我们会看到 GC 日志中的相互交织的输出。GC 日志的时间戳显示应用线程等待了大概100毫秒——这就是新生代 GC 停顿时间比日志中其他停顿的平均持续时间还长100毫秒的原因。这是一个信号,说明你的 G1 需要进行调优。

concurrent-mark-start 日志形式

根分区扫描完成后,G1 就进入到并发标记阶段。这个阶段完全在后台运行。

1
2
3
111.382: [GC concurrent-mark-start]
....
120.905: [GC concurrent-mark-end, 9.5225160 sec]

并发标记阶段是可以中断的,所以这个阶段中可能发生新生代垃圾收集。

remark 日志形式,STW

紧接在标记阶段之后的是重新标记(remarking)阶段。

1
2
3
4
5
120.910: [GC remark 120.959:
[GC ref-PRC, 0.0000890 secs], 0.0718990 secs]
[Times: user=0.23 sys=0.01, real=0.08 secs]
120.985: [GC cleanup 3510M->3434M(4096M), 0.0111040 secs]
[Times: user=0.04 sys=0.00, real=0.01 secs]

concurrent-cleanup-start 日志形式

紧接着重新标记阶段的是清理阶段。

1
2
120.996: [GC concurrent-cleanup-start]
120.996: [GC concurrent-cleanup-end, 0.0004520]

至此,正常的 G1 周期结束了。清理阶段真正回收的内存数量很少,G1 到这个点为止真正做的事情是定位出哪些老的分区可回收垃圾最多。


Collectors

CMS

Concurrent Mode Failure

调优 CMS 时最要紧的工作就是要避免发生并发模式失效以及晋升失败。正如我们在 CMS 回收日志中看到的那样,发生并发模式失效往往是由于 CMS 不能以足够快的速度清理老年代空间:新生代需要进行垃圾回收时,CMS 计算发现老年代没有足够的空闲空间可以容纳这些晋升对象,不得不先对老年代进行垃圾回收。

初始时老年代空间中对象是一个接一个整齐有序排列的。当老年代空间的占用达到某个程度(默认值为70%)时,并发回收就开始了。一个 CMS 后台线程开始扫描老年代空间,寻找无用的垃圾对象时,竞争就开始了:CMS 必须在老年代剩余的空间(30%)用 尽之前,完成老年代空间的扫描及回收工作。如果并发回收在这场速度的比赛中失利,CMS 就会发生并发模式失效。

解决思路:

  • 想办法增大老年代空间,要么只移动部分的新生代对象到老年代,要么增加更多的堆空间。
  • 以更高的频率运行后台回收线程。
  • 使用更多的后台回收线程。

如果有更多的内存可用,更好的方案是增加堆的大小,否则可以尝试调整后台线程运行的方式来解决这个问题。

CMS 收集器使用两个配置 MaxGCPauseMllis=N 和 GCTimeRatio=N 来确定使用多大的堆和多大的代空间。
CMS 回收方法与其他的GC回收方法一个显著的不同是除非发生 Full GC,否则 CMS 的新生代大小不会作调整。由于 CMS 的目标是尽量避免 Full GC,这意味着使用精细调优的 CMS 的应用程序永远不会调整它的新生代大小。
程序启动时可能频发并发模式失效,因为 CMS 需要调整堆和永久代(或者元空间)的大小。初始时采用一个比较大的堆(以及更大的永久代 / 元空间)是一个很好的主意,这是一个特例,增大堆的大小反而帮助避免了那些失效。


G1


Reference

https://hllvm-group.iteye.com/group/topic/37095