GC调整:实践

GC调整:工具

Posted by BY on November 21, 2019

GC调整:实践

应用程序中的错误可能是由于较差的JVM性能以及其他棘手的原因造成的。使用Plumbr找到根本原因。

本章介绍了垃圾收集可能遇到的几个典型性能问题。这里给出的示例源自实际应用,但为了清楚起见被简化。

高分配率

分配率是在传达每个时间单位分配的内存量时使用的术语。通常以MB /秒表示,但如果您愿意,可以使用PB /年。所有这一切 - 没有魔力,只是你在一段时间内测量的Java代码中分配的内存量。

分配率过高可能会给您的应用程序性能带来麻烦。在JVM上运行时,垃圾收集会带来很大的开销。

如何衡量分配率?

测量分配率的一种方法是通过为JVM 指定-XX:+ PrintGCDetails -XX:+ PrintGCTimeStamps标志来打开GC日志记录。JVM现在开始记录GC暂停,类似于以下内容:

0.291:[GC(分配失败)[PSYoungGen:33280K-> 5088K(38400K)] 33280K-> 24360K(125952K),0.0365286秒] [时间:用户= 0.11 sys = 0.02,实际= 0.04秒] 
0.446:[GC(分配失败)[PSYoungGen:38368K-> 5120K(71680K)] 57640K-> 46240K(159232K),0.0456796秒] [时间:用户= 0.15 sys = 0.02,实际= 0.04秒] 
0.829:[GC(分配失败)[PSYoungGen:71680K-> 5120K(71680K)] 112800K-> 81912K(159232K),0.0861795秒] [时间:用户= 0.23 sys = 0.03,实际= 0.09秒]

从上面的GC日志中,我们可以将分配率计算为最后一次收集完成后和下一次收集开始之前年轻一代的大小之间的差异。使用上面的示例,我们可以提取以下信息:

  • 一个牛逼291毫秒JVM推出后,33,280ķ对象的创建。第一次小型GC活动清理了年轻一代,之后剩下的年轻一代有5,088 K的物体。
  • 446推出后毫秒,年轻一代的入住率已经增长到38368ķ,引发了一批GC,其管理年轻一代占用减少到5,120ķ
  • 829个推出后MS,年轻一代的尺寸为71,680ķ和GC再次降低它5,120ķ

然后可以在下表中表示这些数据,将分配率计算为年轻入住率的增量:

事件 时间 年轻之前 年轻之后 分配期间 分配率
第一次GC 291ms 33,280KB 5,088KB 33,280KB 114MB /秒
第二次GC 446ms 38,368KB 5,120KB 33,280KB 215MB /秒
第3次GC 829ms 71,680KB 5,120KB 66,560KB 174MB /秒
829ms N / A N / A 133,120KB 161MB /秒

有了这些信息,我们可以说这个特定的软件在测量期间的分配率为161 MB /秒

我为什么要在乎?

在测量分配率之后,我们可以通过增加或减少GC暂停的频率来了解分配率的变化如何影响应用程序吞吐量。首先,您应该注意到只有轻微的GC暂停清洁年轻一代受到影响。GC的频率和持续时间暂停清洁老一代都不受分配率的直接影响,而是由促销率决定,我们将在下一节中单独介绍。

知道我们只关注Minor GC暂停,我们接下来应该研究年轻一代内部的不同内存池。由于分配发生在伊甸园,我们可以立即调查伊甸园的规模如何影响分配率。因此我们可以假设增加Eden的大小将减少次要GC暂停的频率,从而使应用程序能够维持更快的分配率。

事实上,当使用-XX:NewSize -XX:MaxNewSize&-XX:SurvivorRatio参数运行具有不同Eden大小的相同应用程序时,我们可以看到分配率的两倍差异。

  • 使用100 M的Eden重新运行会将分配率降低到100 MB /秒以下。
  • 将Eden大小增加到1 GB会将分配率提高到略低于200 MB /秒。

如果你仍然想知道这是怎么回事 - 如果你不经常停止GC的应用程序线程,你可以做更多有用的工作。更有用的工作也可以创建更多的对象,从而支持增加的分配率

现在,在你得出“更大的伊甸园更好”的结论之前,你应该注意到分配率可能并且可能与你的应用程序的实际吞吐量没有直接关系。这是一种有助于提高吞吐量的技术指标。分配率可以并将影响您的次要GC暂停停止应用程序线程的频率,但要查看总体影响,您还需要考虑主要的GC暂停并测量吞吐量而不是以MB /秒为单位,而是在业务运营中你的申请提供。

给我一个例子

认识演示应用程序。假设它适用于提供数字的外部传感器。应用程序在专用线程中持续更新传感器的值(在本例中为随机值),而在其他线程中,有时会使用最近的值在processSensorValue()方法中对其执行有意义的操作:

public class BoxingFailure {
  private static volatile Double sensorValue;

  private static void readSensor() {
    while(true) sensorValue = Math.random();
  }

  private static void processSensorValue(Double value) {
    if(value != null) {
      //...
    }
  }
}

正如班级名称所暗示的那样,问题在于拳击。可能为了适应空检查,作者将sensorValue字段设为大写D Double。这个例子是基于最新值处理计算的一种常见模式,当获得该值是一项昂贵的操作时。而在现实世界中,它通常比获得随机值更昂贵。因此,一个线程连续生成新值,计算线程使用它们,避免了昂贵的检索。

演示应用程序受GC影响,无法跟上分配率。验证和解决问题的方法将在下一节中给出。

我的JVM可能会受到影响吗?

首先,您应该担心应用程序的吞吐量是否开始下降。由于应用程序正在创建几乎立即丢弃的太多对象,因此次要GC的频率会暂停。在足够的负载下,这可能导致GC对吞吐量产生显着影响。

当您遇到这种情况时,您将面对一个类似于从上一节介绍的演示应用程序的GC日志中提取的以下短片段的日志文件。该应用程序与-XX:+ PrintGCDetails -XX:+ PrintGCTimeStamps -Xmx32m命令行参数一起启动:

2.808:[GC(分配失败)[PSYoungGen:9760K-> 32K(10240K)],0.0003076秒]
2.819:[GC(分配失败)[PSYoungGen:9760K-> 32K(10240K)],0.0003079秒]
2.830:[GC(分配失败)[PSYoungGen:9760K-> 32K(10240K)],0.0002968秒]
2.842:[GC(分配失败)[PSYoungGen:9760K-> 32K(10240K)],0.0003374秒]
2.853:[GC(分配失败)[PSYoungGen:9760K-> 32K(10240K)],0.0004672秒]
2.864:[GC(分配失败)[PSYoungGen:9760K-> 32K(10240K)],0.0003371秒]
2.875:[GC(分配失败)[PSYoungGen:9760K-> 32K(10240K)],0.0003214秒]
2.886:[GC(分配失败)[PSYoungGen:9760K-> 32K(10240K)],0.0003374秒]
2.896:[GC(分配失败)[PSYoungGen:9760K-> 32K(10240K)],0.0003588 secs]

什么应该立即引起你的注意是次要GC事件频率 。这表明分配了大量的对象。此外,年轻一代后GC占用率仍然很低,并且没有发生完整的收集。这些症状表明GC对手头应用的吞吐量有显着影响。

解决办法是什么?

在某些情况下,减少高分配率的影响可以像增加年轻一代的规模一样容易。这样做不会降低分配率本身,但会导致收集频率降低。当每次只有少数幸存者时,这种方法的好处就开始了。由于较小GC暂停的持续时间受到幸存对象数量的影响,因此这里不会明显增加。

当我们使用-Xmx64m参数运行具有增加的堆大小的相同演示应用程序以及年轻代的大小时,结果是可见的:

2.808:[GC(分配失败)[PSYoungGen:20512K-> 32K(20992K)],0.0003748秒]
2.831:[GC(分配失败)[PSYoungGen:20512K-> 32K(20992K)],0.0004538秒]
2.855:[GC(分配失败)[PSYoungGen:20512K-> 32K(20992K)],0.0003355 secs]
2.879:[GC(分配失败)[PSYoungGen:20512K-> 32K(20992K)],0.0005592秒]

但是,只是为它投入更多内存并不总是一个可行的解决方案。配备上一章中关于分配剖析器的知识,我们可以找出大部分垃圾产生的地方。具体来说,在这种情况下,99%是使用readSensor方法创建的双打。作为一个简单的优化,对象可以用原始double替换,null可以用Double.NaN替换。由于原始值实际上不是对象,因此不会产生垃圾,也没有任何东西可以收集。不是在堆上分配新对象,而是直接覆盖现有对象中的字段。

简单的变化DIFF)会,在演示应用程序,几乎完全去除GC暂停。在某些情况下,JVM可能足够聪明,可以通过使用转义分析技术自行删除过多的分配。简而言之,JIT编译器可能在某些情况下证明创建的对象永远不会“逃避”它创建的范围。在这种情况下,实际上不需要在堆上分配它并以这种方式产生垃圾,所以JIT编译器就是这样做的:它消除了分配。请参阅此基准测试以获取示例。

过早推广

在解释过早推广的概念之前,我们应该熟悉它所建立的概念 - 促销率。促销率是以每个时间单位从年轻一代传播到老一代的数据量来衡量的。它通常以MB /秒为单位,与分配率类似。

从年轻一代到老年人推广长期存在的对象是JVM的预期表现。回顾一代假设,我们现在可以构建一种情况,不仅长寿命对象最终会在老一代中出现。这种情况,即年轻一代没有收集预期寿命短的物品,并被提升为老一代,被称为过早促销。

现在,清理这些短期对象成为主要GC的工作,GC不是为频繁运行而设计的,导致GC暂停时间更长。这显着影响了应用程序的吞吐量。

如何衡量促销率

衡量促销率的方法之一是通过为JVM 指定-XX:+ PrintGCDetails -XX:+ PrintGCTimeStamps标志来打开GC日志记录。JVM现在开始记录GC暂停,就像在下面的代码片段中一样:

0.291:[GC(分配失败)[PSYoungGen:33280K-> 5088K(38400K)] 33280K-> 24360K(125952K),0.0365286秒] [时间:用户= 0.11 sys = 0.02,实际= 0.04秒] 
0.446:[GC(分配失败)[PSYoungGen:38368K-> 5120K(71680K)] 57640K-> 46240K(159232K),0.0456796秒] [时间:用户= 0.15 sys = 0.02,实际= 0.04秒] 
0.829:[GC(分配失败)[PSYoungGen:71680K-> 5120K(71680K)] 112800K-> 81912K(159232K),0.0861795秒] [时间:用户= 0.23 sys = 0.03,实际= 0.09秒]

从上面我们可以在收集事件之前和之后提取年轻代的大小和总堆。了解年轻一代和总堆的消耗量,很容易将旧一代的消费量计算为两者之间的差值。将GC日志中的信息表示为:

事件 时间 年轻人减少了 总数减少了 提拔 促销率
第一次GC 291ms 28,192K 8,920K 19,272K 66.2 MB /秒
第二次GC 446ms 33,248K 11,400K 21,848K 140.95 MB /秒
第3次GC 829ms 66,560K 30,888K 35,672K 93.14 MB /秒
829ms     76,792K 92.63 MB /秒

将允许我们提取测量期间的促销率。我们可以看到平均促销率为92 MB /秒,暂时达到140.95 MB /秒。

请注意,您只能从较小的GC暂停中提取此信息。完整GC暂停不会公开促销率,因为GC日志中旧一代使用的更改还包括主要GC清理的对象。

我为什么要在乎?

与分配率类似,促销率的主要影响是GC暂停的频率变化。但与影响次要GC事件发生频率的分配率相反,促销率会影响主要GC事件的发生频率。让我解释一下 - 你向老一代推销的东西越多,你填的越快。更快地填充旧一代意味着清洁旧一代的GC事件的频率将增加。

正如我们在前面的章节中所示,完整的垃圾收集通常需要更多的时间,因为它们必须与更多的对象进行交互,并执行其他复杂的活动,例如碎片整理。

给我一个例子

让我们看看一个遭受过早推广的演示应用程序。此应用程序获取数据块,累积它们,并在达到足够数量时立即处理整个批处理:

public class PrematurePromotion {

   private static final Collection<byte[]> accumulatedChunks = new ArrayList<>();

   private static void onNewChunk(byte[] bytes) {
       accumulatedChunks.add(bytes);

       if(accumulatedChunks.size() > MAX_CHUNKS) {
           processBatch(accumulatedChunks);
           accumulatedChunks.clear();
       }
   }
}

演示应用程序被过早推广由GC的影响。验证和解决问题的方法将在下一节中给出。

我的JVM可能会受到影响吗?

一般来说,过早晋升的症状可以采取以下任何一种形式:

  • 该应用程序在短时间内经常进行完整的GC运行。
  • 每个完整GC后的老一代消耗量很低,通常低于老一代总量的10-20%。
  • 面对促销率接近分配率。

在一个简短易懂的演示应用程序中展示这一点有点棘手,所以我们会通过使对象的使用时间比默认情况下更早一些来作弊。如果我们使用一组特定的GC参数运行演示(-Xmx24m -XX:NewSize = 16m -XX:MaxTenuringThreshold = 1),我们会在垃圾收集日志中看到这一点:

2.176:[全GC(人体工程学)[PSYoungGen:9216K-> 0K(10752K)] [ParOldGen:10020K-> 9042K(12288K)] 19236K-> 9042K(23040K),0.0036840秒]
2.394:[全GC(人体工程学)[PSYoungGen:9216K-> 0K(10752K)] [ParOldGen:9042K-> 8064K(12288K)] 18258K-> 8064K(23040K),0.0032855秒]
2.611:[全GC(人体工程学)[PSYoungGen:9216K-> 0K(10752K)] [ParOldGen:8064K-> 7085K(12288K)] 17280K-> 7085K(23040K),0.0031675秒]
2.817:[全GC(人体工程学)[PSYoungGen:9216K-> 0K(10752K)] [ParOldGen:7085K-> 6107K(12288K)] 16301K-> 6107K(23040K),0.0030652秒]

乍一看,似乎过早的促销不是问题。实际上,老一代的占用似乎在每个周期都在减少。但是,如果很少或没有对象被提升,我们就不会看到很多完整的垃圾收集。

这个GC行为有一个简单的解释:当许多对象被提升到旧代时,会收集一些现有对象。这给人的印象是老一代的使用率在下降,而事实上,有些对象不断被提升,触发了完整的GC。

解决办法是什么?

简而言之,要解决这个问题,我们需要让缓冲的数据适合年轻一代。这样做有两种简单的方法。第一种是通过在JVM启动时使用-Xmx64m -XX:NewSize = 32m参数来增加年轻代的大小。使用此配置更改运行应用程序将使Full GC事件的频率降低,同时几乎不会影响次要集合的持续时间:

2.251:[GC(分配失败)[PSYoungGen:28672K-> 3872K(28672K)] 37126K-> 12358K(61440K),0.0008543 secs]
2.776:[GC(分配失败)[PSYoungGen:28448K-> 4096K(28672K)] 36934K-> 16974K(61440K),0.0033022秒]

在这种情况下,另一种方法是简单地减小批量大小,这也会产生类似的结果。选择正确的解决方案在很大程度上取决于应用程序中真正发生的事情。在某些情况下,业务逻辑不允许减少批量大小。在这种情况下,增加可用内存或重新分配以支持年轻一代可能是可能的。

如果两者都不是可行的选项,那么可能优化数据结构以消耗更少的内存。但在这种情况下的总体目标仍然是相同的:使瞬态数据适合年轻一代。

弱,软和幻像参考

影响GC的另一类问题与在应用程序中使用非强引用有关。虽然这可能有助于在许多情况下避免不必要的OutOfMemoryError,但是大量使用此类引用可能会显着影响垃圾收集影响应用程序性能的方式。

我为什么要在乎?

使用弱引用时,您应该了解弱引用的垃圾收集方式。每当GC发现一个对象是弱可达的时,也就是说,对象的最后一个剩余引用是弱引用,它被放到相应的ReferenceQueue上,并有资格进行最终化。然后,可以轮询该引用队列并执行相关的清理活动。这种清理的典型示例是从缓存中删除现在丢失的密钥。

这里的诀窍是,此时您仍然可以创建对该对象的新强引用,因此在它最终完成并回收之前,GC必须再次检查它是否可以执行此操作。因此,弱引用的对象不会被回收用于额外的GC循环。

弱引用实际上比你想象的要常见得多。许多缓存解决方案使用弱引用构建实现,因此即使您没有在代码中直接创建任何实现,您的应用程序仍有可能仍在大量使用弱引用对象。

使用软引用时,您应该记住,软引用的收集要比弱引用少得多。未指定它发生的确切位置,具体取决于JVM的实现。通常,软引用的集合仅在内存耗尽之前作为最后的努力才发生。这意味着你可能会发现自己处于比预期更频繁或更长时间完整GC停顿的情况,因为在旧一代中有更多的物体停留。

使用幻像引用时,您必须在标记此类符合垃圾回收的引用方面进行手动内存管理。这是危险的,因为对javadoc的肤浅一瞥可能会让人相信它们是完全安全的:

为了确保可回收对象保持不变,可能无法检索幻像引用的引用 :幻像引用的get* *方法* *始终返回null

令人惊讶的是,许多开发人员跳过同一个javadoc中的下一段(强调添加):

与软引用和弱引用不同,垃圾收集器在排队时*不会**自动清除*幻像引用** 通过幻像引用可访问 对象将保持不变,直到所有 此类引用都被清除或自身无法访问。

这是对的,我们必须手动清除()幻像引用或面临JVM开始因OutOfMemoryError而死的情况。Phantom引用首先出现在这里的原因是,这是通过常规手段找出对象实际上无法访问的唯一方法。与软引用或弱引用不同,您无法复活幻像可达对象。

给我一个例子

让我们看看另一个演示应用程序,它分配了很多对象,这些对象在次要垃圾收集期间成功回收。考虑到改变上一节关于促销率的暂停阈值的技巧,我们可以使用-Xmx24m -XX运行此应用程序:NewSize = 16m -XX:MaxTenuringThreshold = 1并在GC日志中看到这一点:

2.330:[GC(分配失败)20933K-> 8229K(22528K),0.0033848秒]
2.335:[GC(分配失败)20517K-> 7813K(22528K),0.0022426秒]
2.339:[GC(分配失败)20101K-> 7429K(22528K),0.0010920秒]
2.341:[GC(分配失败)19717K-> 9157K(22528K),0.0056285秒]
2.348:[GC(分配失败)21445K-> 8997K(22528K),0.0041313秒]
2.354:[GC(分配失败)21285K-> 8581K(22528K),0.0033737秒]
2.359:[GC(分配失败)20869K-> 8197K(22528K),0.0023407秒]
2.362:[GC(分配失败)20485K-> 7845K(22528K),0.0011553秒]
2.365:[GC(分配失败)20133K-> 9501K(22528K),0.0060705秒]
2.371:[全GC(人体工程学)9501K-> 2987K(22528K),0.0171452秒]

在这种情况下,完整的收藏品非常少见。但是,如果应用程序也开始为这些创建的对象创建弱引用(-Dweak.refs = true),则情况可能会发生巨大变化。执行此操作可能有很多原因,从将对象用作弱哈希映射中的键并以分配概要分析结束。在任何情况下,在这里使用弱引用可能会导致:

2.059:[全GC(人体工程学)20365K-> 19611K(22528K),0.0654090秒]
2.125:[全GC(人体工程学)20365K-> 19711K(22528K),0.0707499秒]
2.196:[全GC(人体工程学)20365K-> 19798K(22528K),0.0717052秒]
2.268:[全GC(人体工程学)20365K-> 19873K(22528K),0.0686290秒]
2.337:[全GC(人体工程学)20365K-> 19939K(22528K),0.0702009秒]
2.407:[全GC(人体工程学)20365K-> 19995K(22528K),0.0694095秒]

我们可以看到,现在有很多完整的集合,集合的持续时间要长一个数量级!另一个过早推广的案例,但这次有点棘手。当然,根本原因在于弱引用。在我们添加它们之前,应用程序创建的对象在被提升到旧代之前就已经死了。但是,通过添加,他们现在正在寻找额外的GC轮,以便可以对它们进行适当的清理。像以前一样,一个简单的解决方案是通过指定-Xmx64m -XX来增加年轻代的大小:NewSize = 32m

2.328:[GC(分配失败)38940K-> 13596K(61440K),0.0012818秒]
2.332:[GC(分配失败)38172K-> 14812K(61440K),0.0060333秒]
2.341:[GC(分配失败)39388K-> 13948K(61440K),0.0029427秒]
2.347:[GC(分配失败)38524K-> 15228K(61440K),0.0101199秒]
2.361:[GC(分配失败)39804K-> 14428K(61440K),0.0040940秒]
2.368:[GC(分配失败)39004K-> 13532K(61440K),0.0012451秒]

现在,在小型垃圾收集过程中,对象再次被回收。

当使用软引用时,情况会更糟,如下一个演示应用程序所示。在应用程序冒险获取OutOfMemoryError之前,不会回收可轻松访问的对象。在演示应用程序中用软引用替换弱引用会立即显示更多的Full GC事件:

2.162:[全GC(人机工程学)31561K-> 12865K(61440K),0.0181392秒]
2.184:[GC(分配失败)37441K-> 17585K(61440K),0.0024479秒]
2.189:[GC(分配失败)42161K-> 27033K(61440K),0.0061485秒]
2.195:[全GC(人体工程学)27033K-> 14385K(61440K),0.0228773秒]
2.221:[GC(分配失败)38961K-> 20633K(61440K),0.0030729秒]
2.227:[GC(分配失败)45209K-> 31609K(61440K),0.0069772秒]
2.234:[全GC(人体工程学)31609K-> 15905K(61440K),0.0257689秒]

这里的国王是幻影参考,第三个演示应用程序所示。使用与以前相同的参数集运行演示将给我们的结果与弱引用的情况下的结果非常相似。事实上,完整GC暂停的次数会小很多,因为本节开头所述的最终确定存在差异。

但是,添加一个禁用幻像引用清除的标志(-Dno.ref.clearing = true)会很快给出这样的信息:

4.180:[全GC(人体工程学)57343K-> 57087K(61440K),0.0879851秒]
4.269:[全GC(人体工程学)57089K-> 57088K(61440K),0.0973912秒]
4.366:[全GC(人体工程学)57091K-> 57089K(61440K),0.0948099秒]
线程“main”中的异常java.lang.OutOfMemoryError:Java堆空间

在使用幻像引用时必须非常谨慎,并且要始终及时清除幻像可达对象。如果不这样做,最终可能会出现OutOfMemoryError。当我们说这很容易失败时,请相信我们:处理引用队列的线程中有一个意外的异常,并且您将手头有一个死的应用程序。

我的JVM可能会受到影响吗?

作为一般建议,请考虑启用-XX:+ PrintReferenceGC JVM选项以查看不同引用对垃圾回收的影响。如果我们从WeakReference示例中将其添加到应用程序中,我们将看到:

2.173:[全GC(人机工程学)2.234:[SoftReference,0 refs,0.0000151 secs] 2.234:[WeakReference,2648 refs,0.0001714 secs] 2.234:[FinalReference,1 refs,0.0000037 secs] 2.234:[PhantomReference,0 refs,0 refs,0.0000039 secs] 2.234:[JNI弱参考,0.0000027秒] [PSYoungGen:9216K-> 8676K(10752K)] [ParOldGen:12115K-> 12115K(12288K)] 21331K-> 20792K(23040K),[Metaspace:3725K- > 3725K(1056768K)],0.0766685秒] [时间:用户= 0.49 sys = 0.01,实际= 0.08秒]
2.250:[全GC(人机工程学)2.307:[SoftReference,0 refs,0.0000173 secs] 2.307:[WeakReference,2298 refs,0.0001535 secs] 2.307:[FinalReference,3 refs,0.0000043 secs] 2.307:[PhantomReference,0 refs,0 refs,0.0000042 secs] 2.307:[JNI弱参考,0.0000029秒] [PSYoungGen:9215K-> 8747K(10752K)] [ParOldGen:12115K-> 12115K(12288K)] 21331K-> 20863K(23040K),[Metaspace:3725K- > 3725K(1056768K)],0.0734832秒] [时间:用户= 0.52 sys = 0.01,实际= 0.07秒]
2.323:[全GC(人机工程学)2.383:[SoftReference,0 refs,0.0000161 secs] 2.383:[WeakReference,1981 refs,0.0001292 secs] 2.383:[FinalReference,16 refs,0.0000049 secs] 2.383:[PhantomReference,0 refs,0 refs,0.0000040 secs] 2.383:[JNI弱参考,0.0000027秒] [PSYoungGen:9216K-> 8809K(10752K)] [ParOldGen:12115K-> 12115K(12288K)] 21331K-> 20925K(23040K),[Metaspace:3725K- > 3725K(1056768K)],0.0738414秒] [时间:用户= 0.52 sys = 0.01,实际= 0.08秒]

与往常一样,只有在您确定GC对应用程序的吞吐量或延迟有影响时,才应分析此信息。在这种情况下,您可能需要检查日志的这些部分。通常,在每个GC循环期间清除的参考数量非常低,在许多情况下恰好为零。但是,如果情况并非如此,并且应用程序花费了相当长的一段时间来清除引用,或者只是清除了大量引用,则需要进一步调查。

解决办法是什么?

当您确认应用程序实际上正在遭受弱,软或幻像引用的错误,过度使用或过度使用时,解决方案通常涉及更改应用程序的内在逻辑。这是非常特定于应用的,因此难以提供通用指南。但是,要记住的一些通用解决方案是:

  • 弱引用 - 如果问题是由特定内存池的消耗增加引起的,则相应池(以及可能的总堆)的增加可以帮助您解决问题。如示例部分所示,增加总堆积和年轻代大小可以减轻疼痛。
  • 幻像引用 - 确保您实际清除引用。很容易解除某些极端情况并使清除线程无法跟上队列填充的速度或完全停止清除队列,给GC带来很大压力并产生最终结束的风险一个OutOfMemoryError异常
  • 软引用 - 当软引用被识别为问题的根源时,减轻压力的唯一真正方法是更改应用程序的内在逻辑。

其他例子

前面的章节介绍了与表现不佳的GC相关的最常见问题。遗憾的是,有一长串更具体的案例,您无法应用前几章的知识。本节介绍了您可能面临的一些不寻常的问题。

RMI和GC

当您的应用程序通过RMI发布或使用服务时,JVM会定期启动完整的GC,以确保本地未使用的对象也不占用另一端的空间。请记住,即使您没有在代码中通过RMI明确发布任何内容,第三方库或实用程序仍然可以打开RMI端点。一个常见的罪魁祸首是例如JMX,如果附加到远程,它将使用下面的RMI来发布数据。

看似不必要的和定期的完整GC暂停会暴露出这个问题。当您检查旧代的消耗时,内存通常没有压力,因为旧代中有足够的可用空间,但是触发了完整的GC,停止了应用程序线程。

这种通过System.gc()删除远程引用的行为是由sun.rmi.transport.ObjectTable类触发的,该类请求定期运行sun.misc.GC.requestLatency()方法中指定的垃圾收集。

对于许多应用,这不是必需的或完全有害的。要禁用此类定期GC运行,可以为JVM启动脚本设置以下内容:

java -Dsun.rmi.dgc.server.gcInterval = 9223372036854775807L -Dsun.rmi.dgc.client.gcInterval = 9223372036854775807L com.yourcompany.YourApplication

这将System.gc()运行的时间段设置为Long.MAX_VALUE ; 对于所有实际问题,这等于永恒。

该问题的另一种解决方案是通过在JVM启动参数中指定-XX:+ DisableExplicitGC来禁用对System.gc()的显式调用。但是,我们不建议使用此解决方案,因为它可能会产生其他副作用。

JVMTI标记和GC

只要应用程序与Java代理(-javaagent)一起运行,代理就有可能使用JVMTI标记标记堆中的对象。代理可以出于本手册范围之外的各种原因使用标记,但是如果标记应用于堆内的大量对象,则存在与GC相关的性能问题,该问题可能开始影响应用程序的延迟和吞吐量。

问题隐藏在本机代码中,其中JvmtiTagMap :: do_weak_oops在每个垃圾收集事件期间迭代所有标记,并为所有这些操作执行许多不那么便宜的操作。更糟糕的是,此操作是按顺序执行的,不是并行化的。

对于大量标签,这意味着GC过程的很大一部分现在在单个线程中执行,并行性的所有好处都消失了,可能会使GC暂停的持续时间增加一个数量级。

要检查特定代理是否可能是扩展GC暂停的原因,您需要打开-XX:+ TraceJVMTIObjectTagging的诊断选项。启用跟踪将允许您估计标记映射消耗的本机内存量以及堆行走所花费的时间。

如果您自己不是代理商的作者,那么解决问题通常无法实现。除了联系特定代理商的供应商外,您无法做多少事情。如果你最终遇到这样的情况,建议供应商清理不再需要的标签。

Humongous Allocations

每当您的应用程序使用G1垃圾收集算法时,称为大量分配的现象会影响您在GC方面的应用程序性能。总而言之,大量分配的分配大于G1中区域大小的50%。

考虑到G1处理此类分配的方式,频繁的大量分配可能会触发GC性能问题:

  • 如果区域包含巨大的物体,则该区域中最后一个巨大物体与该区域末端之间的空间将不被使用。如果所有巨大的对象只是比区域大小的因素大一点,这个未使用的空间可能导致堆碎片化。
  • 巨大物体的收集不像G1那样与常规物体一样优化。早期的Java 8版本特别麻烦 - 直到Java 1.8u40,只有在完整的GC事件期间才能进行大型区域的回收。更新版本的Hotspot JVM在清理阶段的标记周期结束时释放了大量区域,因此对于较新的JVM,问题的影响已显着降低。

要检查您的应用程序是否在非常大的区域中分配对象,第一步是打开类似于以下内容的GC日志:

java -XX:+ PrintGCDetails -XX:+ PrintGCTimeStamps -XX:+ PrintReferenceGC -XX:+ UseG1GC -XX:+ PrintAdaptiveSizePolicy -Xmx128m MyClass

现在,当您检查日志并发现以下部分时:

 0.106:[G1Ergonomics(Concurrent Cycles)请求并发周期启动,原因:占用率高于阈值,占用率:60817408字节,分配请求:1048592字节,阈值:60397965字节(45.00%),源:并发大量分配]
 0.106:[G1Ergonomics(Concurrent Cycles)请求并发循环启动,原因:GC原因请求,GC原因:G1 Humongous Allocation]
 0.106:[G1Ergonomics(Concurrent Cycles)启动并发周期,原因:请求并发周期启动]
 0.106:[GC暂停(G1 Humongous Allocation)(年轻)(初始标记)0.106:[G1Ergonomics(CSet Construction)开始选择CSet,_pending_cards:0,预测基准时间:10.00 ms,剩余时间:190.00 ms,目标暂停时间:200.00毫秒]

你有证据表明应用程序确实分配了大量的对象。在GC暂停被识别为G1 Humongous Allocation的原因和“分配请求:1048592字节”部分中可以看到证据,我们可以看到应用程序正在尝试分配大小为1,048,592字节的对象,其中比为JVM指定的大区域的2 MB大小的50%大16个字节。

大规模分配的第一个解决方案是改变区域大小,以便(大部分)分配不会超过触发大区域分配的50%限制。区域大小由JVM在启动期间根据堆的大小计算。您可以通过在启动脚本中指定-XX:G1HeapRegionSize = XX来覆盖大小。指定的区域大小必须介于1到32 MB之间,并且必须是2的幂。

此解决方案可能会产生副作用 - 增加区域大小会减少可用区域的数量,因此您需要小心并运行额外的测试集,以查看是否实际上提高了应用程序的吞吐量或延迟。

更耗时但可能更好的解决方案是了解应用程序是否可以限制分配的大小。在这种情况下,工作的最佳工具是分析器。他们可以通过显示堆栈跟踪的分配源来为您提供有关大量对象的信息。

结论

由于可以在JVM上运行的大量可能的应用程序,再加上可能为GC调整的数百个JVM配置参数,因此GC可能会有很多方式影响应用程序的性能。

因此,没有真正的银弹方法来调整JVM以匹配您必须实现的性能目标。我们在这里尝试做的是引导您完成一些常见的(并非常见的)示例,以便您大致了解如何处理这些问题。结合工具概述并充分了解GC的工作原理,您可以成功调整垃圾收集,从而提高应用程序的性能。