垃圾收集算法

11 篇文章 0 订阅

普遍适用于所有垃圾回收算法的 JVM 调优标志包括如何选择堆的大小,如何选择代的大小,如何开启和设置 GC 日志,等等。

这些基础的调优标志已经足以应付大多数的场景。当它们无法解决问题时,往往需要查看使用的 GC 算法中具体是哪些操作影响了性能,进一步判断如何调整对应的参数,从而最大程度地减少 GC 操作对应用程序性能的影响。

调优特定收集器最要紧的信息是启动垃圾收集器后 GC 日志中的数据

还有一些其他的因素也会影响几乎所有垃圾回收算法的性能,包括分配巨型对象、对象的生命周期既不长又不短,等等。

6.1 理解Throughput收集器

Throughput 收集器有两个基本的操作:其一是回收新生代的垃圾,其二是回收老年代的垃圾

图 6-1:Throughput 垃圾回收中的新生代

通常新生代的垃圾回收发生在 Eden 空间快用尽时。新生代垃圾收集会把 Eden 空间中的所有对象挪走:一部分对象会被移动到 Survivor 空间(即这幅图中的 S0 区域),其他的会被移动到老年代;正如你看到的,回收之后老年代中保存了更多的对象。

图 6-2:使用 Throughput 收集器的 Full GC

老年代垃圾收集会清空新生代(包括 Survivor 空间)。唯一留在老年代的对象是那些具有活跃引用的对象,所有这些留下的对象都已被压缩,因此老年代的开始部分被占用,其余部分是空闲的

堆大小的自适应调整和静态调整(设置最大停顿时间,GC花费时间百分比的性能指标自动调整)

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

考虑 Throughput 收集器的调优方案时有两种权衡(通过增大堆来增大吞吐量的边际收益递减)。首先,存在时间与空间的经典编程权衡。 更大的堆在机器上消耗更多内存,消耗该内存的好处是(至少在一定程度上)应用程序将具有更高的吞吐量。

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

为了达到停顿时间的指标,Throughput 收集器的自适应调整会重新分配堆(以及代)的大小。使用这些标志可以设置相应的性能指标:-XX:MaxGCPauseMillis=N 和 -XX:GCTimeRatio=N。

MaxGCPauseMillis 标志用于设定应用可承受的最大停顿时间。这个标志设定的值同时影响 Minor GC 和 Full GC。

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

GCTimeRatio 标志可以设置你希望应用程序在垃圾回收上花费多少时间(与应用线程的运行时间相比较)。默认值是应用程序的运行时间占总时间的 99%,只有 1% 的时间消耗在垃圾回收上。

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

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

快速小结

  1. 动态堆调整是调整堆大小的良好第一步。对很多的应用程序而言,采用动态调整就已经足够,动态调整的配置能够有效地减少 JVM 的内存使用。

  2. 静态地设置堆的大小也可能获得最优的性能(e.g. Xms=Xmx=2048m)。为JVM确定一组合理的性能目标是调优的良好开始。

6.2 理解CMS收集器

CMS 收集器有 3 个基本的操作,分别是:

  • CMS 收集器对新生代的对象进行回收(所有的应用线程都会被暂停);

  • CMS 收集器运行一个并发回收周期(a concurrent cycle)对老年代空间的垃圾进行回收;

  • 如果有必要,CMS 会发起 Full GC。

图 6-4 展示了使用 CMS 回收新生代的情况。

图 6-4:使用 CMS 收集器回收新生代空间

图 6-4:使用 CMS 收集器回收新生代空间

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

JVM 会依据堆的使用情况启动并发回收周期(a concurrent cycle)。当堆的占用达到某个程度时,JVM 会启动后台线程扫描堆,回收不用的对象。扫描结束的时候,堆的状况就像这幅图中最后一列所描述的情况一样。请注意,老年代没有被压缩整理:老年代空间由已经分配对象的空间和空闲空间共同组成。新生代垃圾收集将对象由 Eden 空间挪到老年代空间时,JVM 会尝试使用那些空闲的空间来保存这些晋升的对象。

图 6-5:由 CMS 收集器完成的并发垃圾收集

图 6-5:由 CMS 收集器完成的并发垃圾收集

通过 GC 日志,我们看到回收过程划分成了好几个阶段。虽然主要的并发回收周期(Concurrent Cycle)阶段都使用后台线程进行工作,有些阶段(phase)还是会暂停所有的应用线程,并因此引入短暂的停顿

step1 initial mark phase(stw)

并发回收周期(concurrent cycle)由 initial mark (初始标记)阶段开始,这个阶段会暂停所有的应用程序线程。

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]
step2 mark phase(concurrent)

下一个阶段是 mark (标记)阶段,这个阶段中应用程序线程可以持续运行,不会被中断

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]

由于这个阶段进行的工作仅仅是标记,不会对堆的使用情况产生实质性的改变。

step3 preclean phase(concurrent)

然后是 preclean (预清理)阶段,这个阶段也是与应用程序线程的运行并发进行的。

90.887: [CMS-concurrent-preclean-start]
90.892: [CMS-concurrent-preclean: 0.005/0.005 secs]
step4 remark phase(stw) (注意和 mark phase 区分)

下一个阶段是 remark 阶段,但是涉及到几个操作:

90.892: [CMS-concurrent-abortable-preclean-start]
92.392: [GC[ParNew]
94.473: [CMS-concurrent-abortable-preclean]
94.474: [GC[YG occupancy]
94.474: [Rescan (parallel)]
94.659: [weak refs processing]
94.659: [scrub string table] [1 CMS-remark]

且慢,CMS 收集不是只执行一次 preclean 阶段吗?这个 abortable preclean (可中断预清理)阶段是做什么的呢?

使用 abortable preclean 阶段是由于 remark 阶段(严格说起来,是此输出中的最后一个条目)不是并发的——它将停止所有应用程序线程。如果新生代收集刚刚结束,紧接着就是一个 remark 阶段的话,应用线程会遭遇 2 次连续的停顿操作,CMS 收集器希望避免这样的情况发生。使用 abortable preclean 阶段的目的就是希望尽量缩短停顿的长度,避免连续的停顿。

因此,abortable preclean 阶段会等到新生代空间占用到 50% 左右时才开始。理论上,这时离下一次新生代收集还有半程的距离,给了 CMS 收集器最好的机会避免发生连续停顿。

这个例子中,abortable preclean 阶段在 90.8 秒开始,等待常规的新生代收集开始花了 1.5 秒(根据日志的记录,92.392 秒开始)。CMS 收集器根据以往的历史记录推算下一次新生代垃圾收集可能发生的时间——这个例子中,CMS 收集器计算出的时长大约是 4.2 秒。所以 2.1 秒之 后(即 94.4 秒),CMS 收集器停止了(ends) preclean 阶段(这种行为被称为“aborting”了 preclean 阶段,即使这是停止阶段的唯一方式)。然后也是最后,CMS 执行 remark 阶段,将应用程序线程暂停 0.18 秒(应用程序线程在 abortable preclean 阶段没有暂停)。

step5 sweep phase(concurrent)

接下来是另一个并发阶段—— sweep (清除)阶段:

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-5 中可以看到,新生代的状态在并发收集的过程中发生了变化——在 sweep 阶段可能有任意数量的新生代回收(并且由于 abortable preclean ,至少会有一个新生代回收)。

step6 reset phase(concurrent)

接下来是并发重置(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 垃圾回收的周期至此告终,老年代空间中没有被引用的对象被回收(此时堆的状况如图 6-5 所示)。

如果一切顺利,这些就是 CMS 垃圾回收会经历的周期,以及所有可能出现在 CMS 垃圾收集日志中的信息。

不过,事实并不是这么简单,我们还需要查看另外三种消息,出现这些日志表明 CMS 垃圾收集碰到了麻烦。

error1 concurrent mode failure(后台跑输MinorGC老年代放不下,回退Serial Full GC,不压缩老年代)

首当其冲的是并发模式失效(concurrent mode failure)

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 秒。这个操作是单线程的,这就是为什么它耗时如此之长的原因之一(这也是为什么发生并发模式失效比堆的增长更加恶劣的原因之一)。

error2 promotion failed(老年代因碎片虚假能放下,回退 Serial Full GC,压缩老年代)

第二个问题是老年代有足够的空间可以容纳晋升的对象,但是由于空闲空间的碎片化,导致晋升失败(promotion failed):

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 收集器遭遇并发模式失效的时间还长的多,因为发生并发模式失效时,只是释放了堆中的对象这时的堆就像刚由 Throughput 收集器做完 Full GC 一样(如图 6-2):新生代空间完全空闲,老年代空间也已经整理过

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

永久代空间用尽,需要回收时,就会发生这样的状况;应注意到,CMS 收集后永久代空间大小减小了。Java 8 中,如果元空间需要调整,也会发生同样的情况。

默认情况下,CMS 收集器不会对永久代(或元空间)进行收集,因此,它一旦被用尽,就需要进行 Full GC,所有没有被引用的类都会被回收。CMS 高级调优一节会有针对性地介绍如何解决这种问题。

快速小结

  1. CMS 垃圾回收有多个操作,但是期望的操作是 Minor GC 和并发回收(concurrent cycle)。

  2. CMS 收集过程中的并发模式失效以及晋升失败的代价都非常昂贵;我们应该尽量调优 CMS 收集器以避免发生这些情况。

  3. 默认情况下 CMS 收集器不会对永久代进行垃圾回收。

6.2.1 针对并发模式失效的调优(加堆,加后台频率,加后台线程)

调优 CMS 收集器时最要紧的工作就是要避免发生并发模式失效以及晋升失败

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

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

有以下途径可以避免发生这种失效。

  • 想办法增大老年代空间,要么转移一定比例的新生代空间给到老年代,要么干脆增加更多的堆空间。

  • 以更高的频率运行后台回收线程

  • 使用更多的后台回收线程

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

MaxGCPauseMillis=N 和 GCTimeRatio=N 自适应调优和 CMS 垃圾搜集

CMS 收集器使用两个配置 MaxGCPauseMillis=N 和 GCTimeRatio=N 来确定使用多大的堆和多大的代空间(这就是之前辣个性能指标,会自动调整堆大小,代大小)。

CMS 收集与其他的垃圾收集方法一个显著的不同是除非发生 Full GC,否则 CMS 的新生代大小不会作调整。由于 CMS 的目标是尽量避免 Full GC,这意味着使用精细调优的 CMS 收集器的应用程序永远不会调整它的新生代大小。

程序启动时可能频发并发模式失效,因为 CMS 收集器需要调整堆和永久代(或者元空间)的大小。

使用 CMS 收集器,初始时采用一个比较大的堆(以及更大的永久代 / 元空间)是一个很好的主意,这是一个特例,增大堆的大小反而帮助避免了那些失效

1. 给后台线程更多的运行机会

为了让 CMS 收集器赢得这场比赛,方法之一是更早地启动并发收集周期。显然地,CMS 收集器在老年代空间占用达到 60% 时启动并发周期,这和老年代空间占用到 70% 时才启动相比,前者完成垃圾收集的几率更大。为了实现这种配置,最简单的方法是同时设置下面这两个标志:-XX:CMSInitiatingOccupancyFraction=N 和 -XX:+UseCMSInitiatingOccupancyOnly。同时使用这两个参数能帮助 CMS 更容易地进行决策:如果同时设置这两个标志,那么 CMS 就只依据设置的老年代空间占用率来决定何时启动后台线程。默认情况下,UseCMSInitiatingOccupancyOnly 标志的值为假,CMS 会使用更复杂的算法判断什么时候启动并行收集线程。如果有必要提前启动后台线程,推荐使用最简单的方法,即将 UseCMSInitiatingOccupancyOnly 标志的值设置为真

CMSInitiatingOccupancyFraction 参数值的调整可能需要多次迭代才能确定。如果开启了 UseCMSInitiatingOccupancyOnly 标志,CMSInitiatingOccupancyFraction 的默认值就被置为 70,即 CMS 会在老年代空间占用达到 70% 时启动并发收集周期。

了解了 CMSInitiatingOccupancyFraction 的工作原理之后,你可能会有疑问,我们能不能将参数值设置为 0 或者其他比较小的值,让 CMS 的后台线程持续运行。通常我们不推荐进行这样的设置,但是,如果你对其中的取舍非常了解,适当地妥协也是可以接受的。

这其中的第一个取舍源于 CPU:CMS 后台线程会持续运行,它们会消耗大量的 CPU 时钟——每个 CMS 后台线程运行时都会 100% 地占用一颗 CPU。多个 CMS 线程同时运行时还会有短暂的爆发,机器的总 CPU 使用因此也会暴涨。如果这些线程都是毫无目的地持续运行,只会白白浪费宝贵的 CPU 资源。

另一方面,这并不是说使用了过多的 CPU 周期就是问题。后台的 CMS 线程需要时必须运行,即使在最好的情况下,这也是很难避免的。因此,机器必须预留足够的 CPU 周期来运行这些 CMS 线程。所以规划机器时,你必须考虑留出余量给这部分 CPU 的使用。

CMS 周期中,如果 CMS 后台线程没有运行,这些 CPU 时钟可以用于运行其他的应用吗?通常不会。如果还有另一个应用也在使用同一个时钟周期,它没有途径了解何时 CMS 线程会运行。因此,应用程序线程和 CMS 线程会竞争 CPU 资源,而这很可能会导致 CMS 线程的“失速”(lose its race)。有些时候,通过复杂的操作系统调优,有可能让应用线程以低于 CMS 线程优先级的方式让两种线程在同一个时钟周期内运行,但是这些方法都相当复杂,很容易出错。因此,答案是肯定的,CMS 周期运行得越频繁,CPU 周期越长,如果不这样,这些 CPU 周期就是空闲状态(idle)。

第二个取舍更加重要,它与应用程序的停顿相关。正如我们在 GC 日志中观察到的,CMS 在特定的阶段会暂停所有的应用线程。使用 CMS 收集器的主要目的就是要限制 GC 停顿的影响,因此频繁地运行更多无效的 CMS 周期只能适得其反。CMS 停顿的时间与新生代的停顿时间比起来要短得多,应用线程甚至可能感受不到这些额外的停顿——这也是一种取舍,我们是要避免额外的停顿还是要减少发生并发模式失败的几率。不过,正如我们前面提到的,持续地运行后台 GC 线程所造成的停顿可能会导致总体的停顿,而这最终会降低应用程序的性能。

除非这些取舍都能接受,否则不要将 CMSInitiatingOccupancyFraction 参数的值设置得比堆内的活跃数据数还少,至少要少 10% 到 20%。

2. 调整CMS后台线程

每个 CMS 后台线程都会 100% 地占用机器上的一颗 CPU。如果应用程序发生并发模式失效,同时又有额外的 CPU 周期可用,可以设置 -XX:ConcGCThreads=N 标志,增加后台线程的数目。默认情况下,ConcGCThreads 的值是依据 ParallelGCThreads 标志的值计算得到的:

ConcGCThreads = (3 + ParallelGCThreads) / 4

上述计算使用整数计算方法,这意味着如果 ParallelGCThreads 的取值区间在 1 到 4,ConcGCThread 的值就为 1,如果 ParallelGCThreads 的取值在 5 到 8 之间,ConcGCThreads 的值就为 2,以此类推。

调整这一标志的要点在于判断是否有可用的 CPU 周期。如果 ConcGCThreads 标志值设置的偏大,垃圾收集会占用本来能用于运行应用线程的 CPU 周期;最终效果上,这种配置会导致应用程序些微的停顿,因为应用程序线程需要等待再次在 CPU 上继续运行的机会。

除此之外,在一个配备了大量 CPU 的系统上,ConcGCThreads 参数的默认值可能偏大。如果没有频繁遭遇并发模式失败,可以考虑减少后台线程数,释放这部分 CPU 周期用于应用线程的运行。

快速小结
  1. 避免发生并发模式失效是提升 CMS 收集器处理能力、获得高性能的关键。

  2. 避免并发模式失效(如果有可能的话)最简单的方法是增大堆的容量。

  3. 否则,我们能进行的下一个步骤就是通过调整 CMSInitiatingOccupancyFraction 参数,尽早启动并发后台线程的运行。

  4. 另外,调整后台线程的数目对解决这个问题也有帮助。

6.2.2 CMS收集器的永久代调优

如果永久代需要进行垃圾收集,就会发生 Full GC(如果元空间的大小需要调整也会发生同样的情况)。这往往发生在程序员频繁部署(或者重新部署)应用的服务器上,或者发生在需要频繁定义(或者回收)类的应用中。

默认情况下,Java 7 中的 CMS 垃圾收集线程不会处理永久代中的垃圾,如果永久代空间用尽,CMS 会发起一次 Full GC 来回收其中的垃圾对象。除此之外,还可以开启 -XX:+CMSPermGenSweepingEnabled 标志(默认情况下,该标志的值为 false),开启后,永久代中的垃圾使用与老年代同样的方式进行垃圾收集:通过一组后台线程并发地回收永久代中的垃圾对象。注意,触发永久代垃圾回收的指标与老年代的指标是相互独立的。使用 -XX:CMSInitiatingPermOccupancyFraction=N 参数可以指定 CMS 收集器在永久代空间占用比达到设定值时启动永久代垃圾回收线程,这个参数的默认值为 80%。

不过,开启永久代垃圾收集只是整个流程中的一步,为了真正释放不再被引用的类,我们还需要设置 -XX:+CMSClassUnloadingEnabled 标志。否则,即使启用了永久代垃圾回收也只能释放少量的无效对象,类的元数据并不会被释放。由于永久代中大量的数据都是类的元数据,因此启动 CMS 永久代垃圾收集时,这个标志同时也应该开启。

Java 8 中,CMS 收集器默认就会收集元空间中不再载入的类。

6.2.3 增量式CMS垃圾收集(单CPU的机器)

增量式 CMS 垃圾收集在 Java 8 中已经不推荐使用(docker导致的单核怎么处理?)

使用增量式 CMS 垃圾收集的主要好处是后台线程会间歇性地停顿,让出一部分 CPU 给应用程序线程运行,从而使得 CMS 收集器即使在只配备了有限 CPU 资源的机器上也能运行。

如果系统确实只配备了极其有限的 CPU,作为替代方案,可以考虑使用 G1 收集器——因为 G1 收集器的后台线程在垃圾收集的过程中也会周期性地暂停,客观上减少了与应用线程竞争 CPU 资源的情况。

6.3 理解 G1 垃圾收集器(Garbage First)

G1 垃圾收集器是一种工作在堆内不同分区上的并发收集器。分区(region)既可以归属于老年代,也可以归属于新生代(默认情况下,一个堆被划分成 2048 个分区),同一个代的分区不需要保持连续

为老年代设计分区的初衷是我们发现并发后台线程在回收老年代中没有引用的对象时,有的分区垃圾对象的数量很多,另一些分区的垃圾对象相对较少。

虽然分区的垃圾收集工作实际仍然会暂停应用程序线程,不过由于 G1 收集器专注于垃圾最多的分区,最终的效果是花费较少的时间就能回收这些分区的垃圾。这种只专注于垃圾最多分区的方式就是 G1 垃圾收集器名称的由来,即首先收集垃圾最多的分区。(其实把堆分为新生代和老年代不也是专注于垃圾最多的代吗??哈哈其实都是同一种思想,参见为什么回收新生代比回收老年代更快)

不过这一算法并不适用于新生代的分区:新生代进行垃圾回收时,整个新生代空间要么被回收,要么被晋升(对象被移动到 Survivor 空间,或者移动到老年代)。新生代也采用分区机制的部分原因,是因为采用预定义的分区能够便于代的大小调整

G1 收集器的收集活动主要包括 4 种操作:

  • 新生代垃圾收集( A young collection);
  • 后台并发周期(A background, concurrent cycle);
  • 混合式垃圾收集(A mixed collection);
  • 以及必要时的 Full GC(If necessary, a full GC);

操作1:新生代垃圾收集(A young collection)

图 6-6:用 G1 垃圾收集器的新生代收集前后对比

图 6-6:用 G1 垃圾收集器的新生代收集前后对比

图中的每一个小方块都代表一个 G1 的分区。分区中黑色的区域代表数据,每个分区中的字母表示该区域属于哪个代([E] 代表 Eden 空间,[O] 代表老年代,[S] 代表 Survivor 空间)。空的分区不属于任何一个代;需要的时候 G1 收集器会强制指定这些空的分区用于任何需要的代

Eden 空间耗尽会触发 G1 垃圾收集器进行新生代垃圾收集(这个例子中,标识为 Eden 的 4 个分区填满之后就会触发新生代收集)。新生代收集之后不会有新的分区马上分配到 Eden 空间,因为这时 Eden 空间为空。不过至少会有一个分区分配到 Survivor 空间(这个例子中,Survivor 空间被部分填满),一部分数据会移动到老年代。

如果 Survivor 空间被填满,无法容纳新生代的晋升对象,部分 Eden 空间的对象会被直接晋升到老年代空间——这种情况下,老年代空间的占用也会增加。

操作2:后台并发周期(A background, concurrent cycle)

图 6-7 是并发 G1 垃圾收集周期(concurrent G1 cycle)开始和结束时的情况。

图 6-7:G1 收集器进行的并发垃圾收集

图 6-7:G1 收集器进行的并发垃圾收集

这幅图中有三方面值得我们关注。首先,新生代的空间占用情况发生了变化:在并发周期中,至少有一次(有可能是多次)新生代垃圾收集。因此,标记周期之前的 eden 区域已经完全释放,并且开始分配新的 eden 区域。

其次,我们注意到一些分区现在被标记为 X。这些分区属于老年代(注意,它们依然还保持着数据),它们就是标记周期(marking cycle)找出的包含最多垃圾的分区

最后,我们还要留意老年代(包括标记为 O 或者 X 的分区)的空间占用,在周期结束时实际可能更多。这是因为在标记周期中,新生代的垃圾收集会晋升对象到老年代。除此之外,标记周期中实际不会释放老年代中的任何对象:它仅仅识别锁定了那些垃圾最多的分区。这些分区中的垃圾数据会在之后的周期中被回收释放

G1 收集器的并发周期包括多个阶段,其中的一些会暂停所有应用线程,另一些则不会。

initial-mark phase (stw)

并发周期的第一个阶段是初始—标记(initial-mark)阶段。这个阶段会暂停所有应用线程——部分源于初始—标记阶段也会进行新生代垃圾收集。

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 秒),之后新生代被清空(71 MB 的数据从新生代移到了老年代)。初始—标记阶段的输出日志表明后台并发周期启动。由于初始—标记阶段也需要暂停所有的应用线程,G1 收集器重用了新生代 GC 周期来完成这部分的工作。在新生代垃圾收集中添加初始标记阶段的影响并不大:与之前的垃圾收集相比较,CPU 周期的开销增加了大约 20%,即便如此,停顿时间只有些微的增长(幸运的是,这台机器上有空闲的 CPU 周期可以运行并发 G1 收集线程,否则停顿时间会更长一些)。

concurrent root region scan phase (不stw,后台)

接下来,G1 收集器会扫描根分区(root region):

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

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

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 phase (不stw,后台)

根分区扫描完成后,G1 收集器就进入到并发标记阶段。这个阶段完全在后台运行,阶段启动和停止时在 GC 日志中各会打印一条日志。

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

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

remark phase(stw)
clearup phase(stw)

紧接在标记阶段之后的是重新标记(remarking)阶段和正常的清理(cleanup)阶段。

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 (不stw,后台)

紧接着是一个额外的并发清理阶段:

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

这之后,正常的 G1 周期就结束了——至少是垃圾的定位就完成了清理阶段真正回收的内存数量很少,G1 到这个点为止真正做的事情是定位出哪些老的分区可回收垃圾最多(即图 6-7 中标记为 X 的分区)

操作3:混合式垃圾收集(A mixed collection)(回收Young和上一步标记的部分X old)

现在,G1 会执行一系列的混合式垃圾回收(mixed GC)。这些垃圾回收被称作“混合式”是因为它们不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的分区。混合式垃圾收集的效果如图 6-8 所示。

图 6-8:使用 G1 收集器进行的混合式 GC

图 6-8:使用 G1 收集器进行的混合式 GC

同新生代垃圾收集通常的行为一样,G1 收集器已经清空了 Eden 空间,同时调整了 Survivor 空间的大小。此外,标记的两个分区也已经被回收。这些分区在之前的扫描中已经证实包含大量垃圾对象,因此绝大部分已经被释放

这些分区中的活跃数据被移动到另一个分区(就像把活跃数据从新生代移动到老年代的分区)。这就是为什么 G1 收集器最终出现碎片化的堆的频率,跟 CMS 收集器比较起来要小得多的原因——随着 G1 垃圾的回收以这种方式移动对象,实际伴随着压缩

关于混合式垃圾回收操作,请参考下面的日志:

79.826: [GC pause (mixed), 0.26161600 secs]
....
    [Eden: 1222M(1222M)->0B(1220M)
         Survivors: 142M->144M Heap: 3200M(4096M)->1964M(4096M)]
    [Times: user=1.01 sys=0.00, real=0.26 secs]

应注意,减少的整个堆的使用不仅仅是 Eden 空间移走的 1222 MB。这其中的差异看起来很小(只有 16 MB),但是同时还有部分 Survivor 空间的对象晋升到了永久代,除此之外,每次混合式垃圾回收只会清理部分目标老年代分区。接下来的讨论中,我们会看到确保混合式垃圾收集清理掉足够的内存对避免将来发生并发失效有多重要。

混合式垃圾回收周期会持续运行直到(几乎)所有标记的分区都被回收,这之后 G1 收集器会恢复常规的新生代垃圾回收周期。最终,G1 收集器会启动再一次的并发周期,决定哪些分区应该在下一次垃圾回收中释放。

操作4:必要时的 Full GC( If necessary, a full GC)

同 CMS 收集器一样,有的时候你会在垃圾回收日志中观察到 Full GC,这些日志是一个信号,表明我们需要进一步调优(具体的方式很多,甚至很可能要分配更多的堆空间)才能提升应用程序的性能。主要有 4 种情况会触发这类的 Full GC,如下所列。

触发情况1:并发模式失效(Concurrent mode failure)

G1 垃圾收集启动 marking cycle (标记周期),但老年代在周期完成之前就被填满,在这种情况下,G1 收集器会放弃 marking cycle:

51.408: [GC concurrent-mark-start]
65.473: [Full GC 4095M->1395M(4096M), 6.1963770 secs]
 [Times: user=7.87 sys=0.00, real=6.20 secs]
71.669: [GC concurrent-mark-abort]

发生这种失败意味着堆的大小应该增加了,或者 G1 收集器的后台处理应该更早开始,或者是需要调整周期,让它运行得更快(譬如,增加后台处理的线程数)(这不是和CMS一样)。

触发情况2:晋升失败(promotion failed)

G1 收集器完成了 marking cycle ,开始启动混合式垃圾回收,清理老年代的分区,不过,老年代空间在垃圾回收释放出足够内存之前就会被耗尽。垃圾回收日志中,这种情况的现象通常是混合式 GC 之后紧接着一次 Full GC。

2226.224: [GC pause (mixed)
        2226.440: [SoftReference, 0 refs, 0.0000060 secs]
        2226.441: [WeakReference, 0 refs, 0.0000020 secs]
        2226.441: [FinalReference, 0 refs, 0.0000010 secs]
        2226.441: [PhantomReference, 0 refs, 0.0000010 secs]
        2226.441: [JNI Weak Reference, 0.0000030 secs]
                (to-space exhausted), 0.2390040 secs]
....
    [Eden: 0.0B(400.0M)->0.0B(400.0M)
        Survivors: 0.0B->0.0B Heap: 2006.4M(2048.0M)->2006.4M(2048.0M)]
    [Times: user=1.70 sys=0.04, real=0.26 secs]
2226.510: [Full GC (Allocation Failure)
        2227.519: [SoftReference, 4329 refs, 0.0005520 secs]
        2227.520: [WeakReference, 12646 refs, 0.0010510 secs]
        2227.521: [FinalReference, 7538 refs, 0.0005660 secs]
        2227.521: [PhantomReference, 168 refs, 0.0000120 secs]
        2227.521: [JNI Weak Reference, 0.0000020 secs]
                2006M->907M(2048M), 4.1615450 secs]
    [Times: user=6.76 sys=0.01, real=4.16 secs]

这种失败意味着混合收集需要更快地发生;每次新生代垃圾收集需要处理更多老年代的分区。

触发情况3:疏散失败(Evacuation failure)

进行新生代垃圾收集时,Survivor 空间和老年代中没有足够的空间容纳所有的幸存对象。这种情形在 GC 日志中通常被当成一种特别的新生代:

60.238: [GC pause (young) (to-space overflow), 0.41546900 secs]

这条日志表明堆已经几乎完全用尽或者碎片化了。G1 收集器会尝试修复这一失败,但是你可以预期,结果会更加恶化:G1 收集器会转而使用 Full GC。解决这个问题最简单的方式是增加堆的大小。

触发情况4:巨型对象分配失败(Humongous allocation failure)

使用 G1 收集器时,分配非常巨大对象的应用程序可能会遭遇另一种 Full GC;目前为止没有工具可以很方便地专门诊断这种类型的失败,尤其是从标准垃圾收集日志中进行诊断。不过,如果发生了莫名其妙的 Full GC,其源头很可能是巨型对象分配导致的问题。

快速小结

  1. G1 垃圾收集包括多个周期(以及并发周期内的阶段)。调优良好的 JVM 运行 G1 收集器时应该只经历新生代周期、混合式周期和并发 GC 周期。

  2. G1 的并发阶段会产生少量的停顿。

  3. 恰当的时候,我们需要对 G1 进行调优,才能避免 Full GC 周期发生。

G1垃圾收集器调优(比CMS多一个)

G1 垃圾收集器调优的主要目标是避免发生并发模式失败或者疏散失败,一旦发生这些失败就会导致 Full GC。避免 Full GC 的技巧也适用于频繁发生的新生代垃圾收集,这些垃圾收集需要等待扫描根分区完成才能进行

其次,调优可以使过程中的停顿时间最小化。下面所列的这些方法都能够避免发生 Full GC。

  • 通过增加总的堆空间大小或者调整老年代、新生代之间的比例来增加老年代空间的大小。
  • 增加后台线程的数目(假设我们有足够的 CPU 资源运行这些线程)。
  • 以更高的频率进行 G1 的后台垃圾收集活动。
  • 在混合式垃圾回收周期中完成更多的垃圾收集工作(比CMS多一个)。
simple important 自动调整:-XX:MaxGCPauseMillis=N(最简单也最重要)

这里有很多的调优可以做,不过 G1 垃圾收集器调优的目标之一是尽量简单。

为了达到这个目标,G1 收集器最主要的调优只通过一个标志进行:这个标志跟 Throughput 收集器的标志一致,也是 -XX:MaxGCPauseMillis=N

使用 G1 垃圾收集器时,该标志有一个默认值:200 毫秒(这一点跟 Throughput 收集器有所不同,Throughput 默认不设置)。如果 G1 收集器的任何时空停顿(stop-the-world)阶段时长超过该值,G1 收集器就会尝试各种方式进行弥补——譬如调整新生代与老年代的比例,调整堆的大小,更早地启动后台处理,改变Survivor 空间的晋升阈值(tenuring(终身制) threshold),或者是在混合式垃圾收集周期中处理更多或更少的老年代分区(这是最重要的方式)。

通常的取舍就发生在这里:如果减小参数值,为了达到停顿时间的目标,新生代的大小会相应减小,不过新生代垃圾收集的频率会更加频繁。除此之外,为了达到停顿时间的目标,混合式 GC 收集的老年代分区数也会减少,而这会增大并发模式失败发生的机会

1. 调整堆大小

如果设置停顿时间目标无法避免 Full GC,我们可以进一步针对不同的方面逐一调优。对 G1 垃圾收集器而言,调整堆大小的方法与其他的垃圾收集算法并没有什么不同。

2. 调整G1垃圾收集的后台线程数

为了帮助 G1 赢得这场垃圾收集的比赛,可以尝试增加后台标记线程的数目(假设机器有足够的空闲 CPU 可以支撑这些线程的运行)。

调整 G1 垃圾收集线程的方法与调整 CMS 垃圾收集线程的方法类似:

对于应用线程暂停运行的周期,可以使用 ParallelGCThreads 标志设置运行的线程数

对于并发运行阶段可以使用 ConcGCThreads 标志设置运行线程数

3. 调整G1垃圾收集器运行的频率

如果 G1 收集器更早地启动垃圾收集,也能赢得这场比赛。

-XX:InitiatingHeapOccupancyPercent=N

4. 调整G1收集器的混合式垃圾收集周期

并发周期之后、老年代的标记分区回收完成之前,G1 收集器无法启动新的并发周期。因此,让 G1 收集器更早启动标记周期的另一个方法是在混合式垃圾回收周期中尽量处理更多的分区(如此一来最终的混合式 GC 周期就变少了)。

-XX:G1MixedGCLiveThresholdPercent=N

-XX:G1MixedGCCountTarget=N

快速小结

  • 作为 G1 收集器调优的第一步,首先应该设定一个合理的停顿时间作为目标
  • 如果使用这个设置后,还是频繁发生 Full GC,并且堆的大小没有扩大的可能,这时就需要针对特定的失败采用特定的方法进行调优。
     + 通过 InitiatingHeapOccupancyPercent 标志可以调整 G1 收集器,更频繁地启动后台垃圾收集线程。
     + 如果有充足的 CPU 资源,可以考虑调整 ConcGCThreads 标志,增加垃圾收集线程数。
     + 减小 G1MixedGCCountTarget 参数可以避免晋升失败。

6.4 高级调优

一些比较少见的性能问题调优。

6.4.1 晋升及Survivor空间(Tenuring and Survivor Spaces)

新生代被划分成一个 Eden 空间和两个 Survivor 空间的原因是这种布局让对象在新生代内有更多的机会被回收,不再局限于只能晋升到老年代(最终填满老年代)

新生代垃圾收集时,如果 JVM 发现对象还十分活跃,会首先尝试将其移动到 Survivor 空间,而不是直接移动到老年代。首次新生代垃圾收集时,对象被从 Eden 空间移动到 Survivor 空间 0。紧接着的下一次垃圾收集中,活跃对象会从 Survivor 空间 0 和 Eden 空间移动到 Survivor 空间 1。这之后,Eden 空间和 Survivor 空间 0 被完全清空。下一次的垃圾回收会将活跃对象从 Survivor 空间 1 和 Eden 空间移回 Survivor 空间 0,如此反复。(Survivor 空间也被称为“To”空间和“From”空间;每次回收,对象由“From”空间移出,移入到“To”空间。“From”和“To”只是简单地表示两个 Survivor 空间之间的指向,每次垃圾回收时,方向都会互换。)

显而易见,这种状况不会一直持续下去,否则没有任何对象会进入老年代。

两种情况下,对象会被移动到老年代

第一,Survivor 空间的大小实在太小。新生代垃圾收集时,如果目标 Survivor 空间被填满,Eden 空间剩下的活跃对象会直接进入老年代。

第二,对象在 Survivor 空间中经历的 GC 周期数有个上限,超过这个上限的对象也会被移动到老年代。这个上限值被称为晋升阈值(Tenuring Threshold)

Survivor 空间的初始大小由 -XX:InitialSurvivorRatio=N 标志决定。

JVM 可以增大 Survivor 空间的大小直到其最大上限,这个上限可以通过 -XX:MinSurvivorRatio=N 参数设置。

-XX:+PrintTenuringDistribution 显示晋升日志。

快速小结

  1. 设计 Survivor 空间的初衷是为了让对象(尤其是已经分配的对象)在新生代停留更多的 GC 周期。这个设计增大了对象晋升到老年代之前被回收释放的几率

  2. 如果 Survivor 空间过小,对象会直接晋升到老年代,从而触发更多的老年代 GC

  3. 解决这个问题的最好方法是增大堆的大小(或者至少增大新生代),让 JVM 来处理 Survivor 空间的回收

  4. 有的情况下,我们需要避免对象晋升到老年代,调整晋升阈值或者 Survivor 空间的大小可以避免对象晋升到老年代

6.4.2 分配大对象

这一节会详细介绍 JVM 是如何分配对象的。这是一些非常有趣的背景知识,了解这些对于调优需要频繁创建大量大型对象的应用尤其重要。这一节的上下文中,“大型”是一个相对的概念;正如我们后面会看到的,它取决于 JVM 内的“线程本地分配缓冲区”(Thread Local Allocation Buffer,TLAB)

1. TLAB(Thread Local Allocation Buffer,TLAB)

Eden 空间让更快地进行对象分配成为可能(尤其是对于分配之后又被迅速回收的对象)。

结果表明,Eden 空间中对象分配速度更快的原因是每个线程都有一个固定的分区用于分配对象,即一个 TLAB。对象在一个共享的空间中分配,我们需要采用一些同步机制来管理空间内的空闲空间指针。每个线程有固定的分配区域,分配对象时,线程之间不需要进行任何的同步

TLAB 都不大,因此大型对象无法在 TLAB 内进行分配。大型对象必须直接从堆上分配,由于需要同步,这会消耗额外的时间

如果发现大量的对象分配发生在 TLAB 之外,我们有两种选择:减小分配对象的大小,或者调整 TLAB 的参数

JFR 可以监控 TLAB 分配情况。对于开源版本的 JVM(不附带 JFR),要监控 TLAB 的分配情况,最好的途径就是在命令行中添加 -XX:+PrintTLAB 标志来监控。

2. 调整TLAB的大小

对于花费大量时间在 TLAB 之外分配对象的应用程序,将分配移动到 TLAB 之内能有效提升应用程序的性能。如果只有极少数对象的分配发生在 TLAB 之外,提升性能最好的方案是修改应用程序。

如果不可能变更应用程序代码,你还可以尝试通过调整 TLAB 的大小来适配应用程序的需要。由于 TLAB 的大小基于 Eden 空间,通过参数调整(增大)Eden 空间会自动增大 TLAB 的大小。

使用 -XX:TLABSize=N 标志可以显式地指定 TLAB 的大小(默认为 0,表示使用前面介绍的方法动态计算得出)。这个标志只能设置 TLAB 的初始大小;为了避免在每次 GC 时都调整 TLAB 的大小,可以使用 -XX:-ResizeTLAB 标志(大多数的平台上,这个参数的默认值都是 true)。这是通过调整 TLAB,充分提升对象分配性能最简单的方法(坦率地说,通常这也是最有效的方法)。

快速小结

对需要分配大量大型对象的应用,TLAB 空间的调整就变得必不可少(不过,通常情况下,我们更推荐在应用程序中使用小型对象的做法)。

3. 巨型对象(Humongous Objects)

对 TLAB 空间中无法分配的对象,JVM 会尽量尝试在 Eden 空间中进行分配。如果 Eden 空间无法容纳该对象,就只能在老年代中分配空间。而这种内存布局打乱了该对象正常的垃圾回收周期,如果它是一个短期存在的对象,还会对垃圾收集造成负面的影响。对于这种情况,除非修改应用程序,放弃使用那些短期存在的巨型对象,否则别无它法。

G1 收集器使用不同的方法处理巨型对象,不过如果对象的大小超过了 G1 收集器的分区,这些对象也会被分配到老年代。因此,对于使用大量巨型对象的应用程序,即使使用 G1 收集器还是需要特别的调优才能弥补这部分的性能损失。

4. G1分区的大小

G1 收集器将堆划分成了一定数量的分区,每个分区的大小都是固定的。分区的大小不是动态变化的,具体的值是启动时,依据堆大小的最小值(即 Xms 的值)得出的。分区大小的最小值是 1 MB。如果堆的最小值超过 2 GB,分区的大小会依据下面的公式计算得出(使用基数为 2,取 log 的算法):

分区大小 = 1 << log(初始堆的大小 / 2048);

简言之,初始划分堆时,分区的大小是 2 的最小的 N 次幂,使其结果最接近于 2048 个分区。这里还有一些最小、最大值的限制;分区的大小最小是 1 MB,最大不能超过 32 MB。表 6-3 列出了所有的可能性。

表6-3:G1收集器的默认分区大小

堆的大小默认G1分区的大小
小于 4 GB1 MB
介于 4 GB 到 8 GB 之间2 MB
介于 8 GB 到 16 GB 之间4 MB
介于 16 GB 到 32 GB 之间8 MB
介于 32 GB 到 64 GB 之间16 MB
大于 64 GB32 MB

G1 分区的大小可以通过 -XX:G1HeapRegionSize=N 标志设置(正常情况下,默认值是 0,意味着使用刚才描述的动态算法计算)。设定的参数值应该是 2 的幂(譬如:1 MB 或者 2 MB);否则,这个值会向下圆整到最接近 2 的幂。

G1 收集的分区调整及大堆

通常情况下,G1 收集器分区的大小调整只有在处理巨型对象分配时才需要进行,但是也有一种例外的情形。

如果应用程序设定了一个非常大的堆区间,譬如 -Xms2G -Xmx32G,这种情况下分区的大小是 1 MB。当堆充分扩张时,G1 收集器的分区数可以高达 32 000 个。这是一个数量巨大的待处理分区,G1 收集算法最初的设计并没有针对这样大量的分区,它期望的分区数是 2048 个左右。这个例子中,增大 G1 收集器分区的大小能提高 G1 垃圾收集的效率;我们需要依据堆的大小选择合适的分区大小,让分区的数量尽量接近 2048 个。

5. 使用G1收集器分配巨型对象

如果 G1 收集器的分区大小是 1 MB,应用程序分配了个 2 百万字节的数组,这个数组是没有办法在一个 G1 分区中存放的。但是这些巨型对象又必须被保存在连续的 G1 分区内。如果 G1 分区的大小是 1 MB,程序分配了个 3.1 MB 的数组,G1 收集器必须在老年代内找到 4 个连续的分区才能完成这次分配工作(最后一个分区的剩余部分会保持空闲,导致 0.9 MB 的空间浪费)。这种做法成功地打败了 G1 收集器传统的收集方式,即压缩,它能够依据分区的满溢程度自主地选择回收哪些分区。通常,为了找到连续的分区,G1 收集器还不得不启动 Full GC。

由于巨型对象直接在老年代中分配,它们不会被新生代垃圾收集所回收。因此,如果短寿型对象采用这种方式分配,收集器的分代机制就不再生效。巨型对象只能在 G1 收集器的并发周期中回收。好消息是,巨型对象的回收会更迅速,因为它是所在分区唯一的对象。巨型对象会在并发周期中的清理阶段(而不是混合式 GC 阶段)被回收释放。

增大 G1 分区的大小,让其能够在一个分区内分配应用需要的所有对象能够提升 G1 收集的效率。为了判断应用的 Full GC 是否源于巨型对象的分配,我们需要开启自适应大小调整(Adaptive Size Policy)GC 的日志记录。应用程序分配巨型对象时,G1 收集器首先会尝试启动一个并发周期。

5.349: [G1Ergonomics (Concurrent Cycles) request concurrent cycle initiation,
    reason: occupancy higher than threshold, occupancy: 483393536 bytes,
    allocation request: 524304 bytes, threshold: 483183810 bytes (45.00 %),
    source: concurrent humongous allocation]
  ...
5.350: [GC pause (young) (initial-mark) 0.349: [G1Ergonomics
    (CSet Construction) start choosing CSet, _pending_cards:
    1624, predicted base time: 19.74 ms, remaining time: 180.26 ms,
    target pause time: 200.00 ms]

这行日志表明发生了巨型对象分配,触发了并发 G1 周期。在这个例子中,对象分配成功,没有堆垃圾收集的其他方面造成影响(G1 收集器刚好找到了需要的连续分区)。

如果 G1 收集器没有找到连续的空闲分区,就会启动一次 Full GC:

25.270: [G1Ergonomics (Heap Sizing) attempt heap expansion,
    reason: allocation request failed, allocation request: 48 bytes]
25.270: [G1Ergonomics (Heap Sizing) expand the heap,
    requested expansion amount: 1048576 bytes,
    attempted expansion amount: 1048576 bytes]
25.270: [G1Ergonomics (Heap Sizing) did not expand the heap,
    reason: heap expansion operation failed]
25.270: [Full GC 1535M->1521M(3072M), 1.0358230 secs]
      [Eden: 0.0B(153.0M)->0.0B(153.0M)
       Survivors: 0.0B->0.0B Heap: 1535.9M(3072.0M)->1521.3M(3072.0M)]
      [Times: user=5.24 sys=0.00, real=1.04 secs]

由于堆无法为适配新的巨型对象而进行扩展,因此为了给分配请求提供连续的分区,G1 收集器只能进行 Full GC。在这种情况下,一旦发生问题,即便是开启 PrintAdaptiveSizePolicy 标志也无法提供更多的垃圾回收日志,标准的 G1 垃圾收集日志也无法提供足够的信息,以诊断问题的根源。

为了避免发生这种 Full GC,首先是要确定导致问题的巨型对象大小(本例中,从垃圾收集日志中定位的对象大小是 524 304 字节)。接下来,需要判断是否有办法减少程序中这些对象的大小。下下策才是针对这些对象,调整 JVM。如果无法减少对象的大小,就需要计算容纳这些对象所需要的分区大小。如果对象占用的空间达到分区容量的 50% 以上,G1 收集器就认为这是个巨型对象。因此,这个例子中,如果被质疑的对象大小为 524 304 字节,G1 分区的大小至少应该是 1.1 MB。由于 G1 收集算法中,分区的大小总是 2 的幂,所以 G1 分区的大小应该为 2 MB,才能保持在标准的 G1 分区中完成这些对象的分配。

6.5 小结

在前面的这两章中,我们花费了大量的时间深入介绍了垃圾收集(以及各种垃圾收集方法)工作的细节。如果垃圾收集花费的时间超出了你的预期,了解垃圾收集的内部工作原理能帮你决定采取哪些必要的步骤进行性能调优。

现在我们已经了解了所有的细节,让我们回退一步,决定选择什么方法,采用什么标志来调优垃圾收集器。下面是一些问题集合,在调优之前,先试着回答这些问题,它们能帮你理清思路,选择合适的调优措施。

你的应用能够忍受 Full GC 的停顿吗?

如果答案是肯定的,选择 Throughput 收集器能获得最高的性能,同时,使用的 CPU 和堆的大小也比其他的垃圾收集器少。如果答案是否定的,你需要依据可用的堆大小做选择,如果可用的堆较小,你可以选择并发收集器,譬如 CMS 收集器或者 G1 收集器;如果可用的堆比较大,推荐使用 G1 收集器。

使用默认设置能达到你期望的性能目标吗?

尽量首先使用默认的设置。因为垃圾收集技术在不断发展成熟,自动调优大多数情况下取得的效果是最好的。如果使用默认设置没有达到你需要的性能目标,请确认垃圾收集是否是性能瓶颈。查看垃圾收集日志能帮我们了解 JVM 在垃圾收集上花费了多长时间、垃圾收集发生的频率是多少。对于负荷较高的应用,如果 JVM 花在垃圾收集上的时间不超过 3%,即使进行垃圾调优也不会得到太大的性能提升(不过,如果那些指标是你关注的方面,你仍然可以尝试通过调优缩短某些指标)。

应用的停顿时间与你预期的目标接近吗?

如果停顿时间与你预期的目标很接近,调整最大停顿时间的设定可能是你需要做的。如果不是,你需要进行其他的调整。如果停顿时间太长,但是应用的吞吐量正常,你可以尝试减小新生代的大小(如果瓶颈是 Full GC 的停顿,就减小老年代的大小);调整之后,停顿的频率会增加,但是单次停顿的时长会变短。

虽然 GC 的停顿时间已经非常短了,但应用的吞吐量依旧上不去?

这种情况下你需要增大堆的大小(或至少要增大新生代)。但是,这并不意味着堆越大越好:更大的堆会导致更长的停顿时间。即便是并发收集器,默认情况下,增大堆也还是意味着增大新生代,因此你会发现新生代的停顿时间变长了。即便是这样,如果有可能,还是应该增大堆的大小,或者增大代的相对大小。

你使用并发收集器吗?是否发生了由并发模式失败引起的 Full GC ?

如果你有足够的 CPU 资源,可以尝试增加并发 GC 线程的数量,或者通过调整 InitiatingHeapOccupancyPercent 参数在更早的时候启动后台清理线程。对于 G1 收集器,如果有混合式垃圾收集尚未完成,并发周期就不会启动。在这个时候,可以尝试降低混合式 GC 的回收目标(-XX:G1MixedGCCountTarget=N)。

你使用并发收集器吗?是否发生了由晋升失败引起的 Full GC ?

在 CMS 收集器中,发生晋升失败意味着堆发生了碎片化。这种情况下,我们能做的事情不多:使用更大的堆,或者尽早地启动后台回收都能在一定程度上缓解堆的碎片化。处理这种情况,更好的解决方法可能是使用 G1 收集器。G1 收集器中,疏散失败(to-space 溢出)表明遭遇了同样的情况,但是 G1 收集器能解决碎片化的问题,如果它的后台线程在更早的时候启动,且混合式 GC 的速度更快的话。你可以尝试通过增大并发 G1 收集线程的数目,调整 InitiatingHeapOccupancyPercent,或者降低混合式 GC 的目标来解决 G1 收集器中堆碎片化的问题。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值