优化Java 第八章 GC日志,监视器,调优和工具
本章,我们会介绍GC日志和监控的大量内容。这是Java性能调优可见部分最重要和最常被误解的部分。
介绍GC日志
GC日志是一个很好的信息来源。它对性能问题的“冷案例”分析特别有用,例如提供对崩溃发生原因的一些了解。即使没有实时应用程序进行诊断,它也可以让分析师工作。
每个重要的应用应始终:
- 生成GC日志。
- 将其保存在与应用程序输出不同的文件中。
对于生产应用尤其如此。正如我们将看到的,GC日志记录没有真正的可观察开销,因此对于任何重要的JVM进程应该始终处于开启状态。
打开GC日志
首先要做的是在应用程序启动时添加一些开关。这些最好被认为是“强制GC日志标记”,任何Java / JVM应用程序(可能是桌面应用程序除外)都应该使用它们。标志是:
-Xloggc:gc.log
-XX:+PrintGCDetails
-XX:+PrintTenuringDistribution
-XX:+PrintGCTimeStamps
-XX:+PrintGCDateStamps
让我们更详细地看一下这些标志。它们的用法如表8-1所示。
Flag | effect |
---|---|
-Xloggc:gc.log | GC事件记录文件 |
-XX:+PrintGCDetails | 记录GC事件详情 |
-XX:+PrintTenuringDistribution | 添加对工具至关重要的额外GC事件细节 |
-XX:+PrintGCTimeStamps | 打印发生GC事件的时间(自VM启动后的秒数) |
-XX:+PrintGCDateStamps | 打印GC事件发生的挂钟时间 |
性能工程师应该注意以下有关这些标志的详细信息:
-
PrintGCDetails标志替换旧的详细信息:gc。 应用程序应删除旧标志。
-
PrintTenuringDistribution标志与其他标志不同,因为它提供的信息不容易被人类直接使用。 该标志提供计算关键内存压力效果和事件(如过早晋升)所需的原始数据。
-
PrintGCDateStamps和PrintGCTimeStamps都是必需的,因为前者用于将GC事件与应用程序事件(在应用程序日志文件中)相关联,后者用于关联GC和其他内部JVM事件。
这种级别的日志记录细节不会对JVM的性能产生可测量的影响。当然,生成的日志量取决于许多因素,包括分配率,使用中的收集器和堆大小(更频繁地需要更小的堆,因此将更快地生成日志)。
为了给你一些主意,模型分配器示例的30分钟运行(如第6章所示)在30分钟的运行中产生约600 KB的日志,每秒分配50 MB。
除了强制标志之外,还有一些控制GC日志轮换的标志(如表8-2所示),许多应用程序支持团队认为这些标志在生产环境中很有用。
Flag | effect |
---|---|
-XX:+ UseGCLogFileRotation |
切换日志文件轮换 |
-XX:+ NumberOfGCLogFiles = <N> |
设置要保留的最大日志文件数 |
-XX:+ GCLogFileSize = <大小> |
设置旋转前每个文件的最大大小 |
建立合理的日志轮换策略应该与操作人员(包括devops)一起完成。这种策略的选项以及对适当的日志记录和工具的讨论超出了本书的范围。
GC Logs与JMX
在“JVM的监视和工具”中,我们遇到了VisualGC工具,它能够显示JVM堆状态的实时视图。该工具实际上依赖于Java Management eXtensions(JMX)接口来从JVM收集数据。有关JMX的完整讨论超出了本书的范围,但就JMX对GC的影响而言,性能工程师应了解以下内容:
- GC日志数据由实际垃圾收集事件驱动,而JMX源数据通过抽样获得。
- GC日志数据对捕获的影响极小,而JMX具有隐式代理和远程方法调用(RMI)成本。
- GC日志数据包含与Java内存管理相关的50多个性能数据方面,而JMX少于10个。
传统上,JMX作为性能数据源的日志的一个优点是JMX可以提供开箱即用的流数据。但是,jClarity Censum等现代工具(参见“Log Parsing Tools”)提供了API来传输GC日志数据,缩小了这一差距。
警告
对于基本堆使用情况的粗略趋势分析,JMX是一个相当快速和简单的解决方案;然而,为了更深入地诊断问题,它很快就会变得不足。
通过JMX提供的bean是一种标准,易于获取。 VisualVM工具提供了一种可视化数据的方法,市场上还有许多其他工具可供使用。
JMX的缺点
使用JMX监视应用程序的客户端通常依赖于对运行时进行采样以获取当前状态的更新。要获得连续的数据提要,客户端需要在该运行时中轮询JMX bean。
在垃圾收集的情况下,这会导致问题:客户端无法知道收集器何时运行。这也意味着每个收集周期之前和之后的内存状态是未知的。这排除了对GC数据执行一系列更深入,更准确的分析技术。
基于JMX数据的分析仍然有用,但仅限于确定长期趋势。但是,如果我们想要准确地调整垃圾收集器,我们需要做得更好。特别是,能够在每次收集之前和之后理解堆的状态是非常有用的。
此外,围绕内存压力(即分配率)存在一组极其重要的分析,由于从JMX收集数据的方式,这些分析根本不可能。
不仅如此,JMXConnector规范的当前实现还依赖于RMI。因此,JMX的使用受到与任何基于RMI的通信信道相同的问题的影响。这些包括:
- 在防火墙中打开端口,以便建立辅助套接字连接
- 使用代理对象来方便remove()方法调用
- 对Java finalization的依赖
对于一些RMI连接,关闭连接所涉及的工作量是微不足道的。然而,拆解依赖于finalization。这意味着垃圾收集器必须运行以回收对象。
JMX连接的生命周期的性质意味着,通常这将导致在完整GC之前不收集RMI对象。有关finalizaiton影响的详细信息以及应始终避免的原因,请参阅“避免 finalization”。
默认情况下,任何使用RMI的应用程序都会导致每小时触发一次完整的GC。对于已使用RMI的应用程序,使用JMX不会增加成本。但是,如果他们决定使用JMX,那么尚未使用RMI的应用程序必然会受到额外的打击。
GC日志数据的好处
现代垃圾收集器包含许多不同的移动部件,这些部件放在一起会导致极其复杂的实施。如此复杂的是,即使不是不可能预测,收集器的性能似乎也很难实现。这些类型的软件系统被称为emergent,因为它们的最终行为和性能是所有组件如何一起工作和执行的结果。不同的压力以不同的方式对不同的组件施加压力,导致非常流动的成本模型。
最初,Java的GC开发人员添加了GC日志记录以帮助调试他们的实现,因此,由近60个GC相关标志生成的大部分数据用于性能调试。
随着时间的推移,那些负责调整其应用程序中的垃圾收集过程的人开始认识到,鉴于调整GC的复杂性,他们也可以从运行时发生的事情中获得精确的图像。因此,能够收集和读取GC日志现在是任何调整制度的工具部分。
提示
GC日志记录是使用非阻塞写入机制在HotSpot JVM中完成的。它对应用程序性能有约0%的影响,应该为所有生产应用程序打开。
由于GC日志中的原始数据可以固定到特定的GC事件,我们可以对其进行各种有用的分析,这可以让我们深入了解收集的成本,从而哪些调整操作更有可能产生积极的结果。
日志解析工具
GC日志消息没有语言或VM规范标准格式。这样就可以将任何单个消息的内容留给HotSpot GC开发团队。格式可以并且确实从次要版本更改为次要版本。
事实上,尽管最简单的日志格式易于解析,但由于添加了GC日志标记,因此结果日志输出变得更加复杂。对于并发收集器生成的日志尤其如此。
通常,在对GC配置进行更改后,手动GC日志解析器的系统会在某个时刻出现中断,这会改变日志输出格式。当中断调查转向检查GC日志时,团队发现自制解析器无法处理更改的日志格式 - 在日志信息最有价值的确切时间点失败。
警告
建议开发人员不要尝试自己解析GC日志。相反,应该使用工具。
在本节中,我们将研究可用于此目的的两个主动维护的工具(一个商业和一个开源)。还有其他一些,例如GarbageCat,只是零星地维护,或者根本不维护。
Censum
Censum内存分析器是jClarity开发的商业工具。它既可以作为桌面工具(用于单个JVM的实际分析),也可以作为监视服务(适用于大型JVM组)。该工具的目的是提供最佳的GC日志解析,信息提取和自动分析。
在图8-1中,我们可以看到Censum桌面视图,显示运行[...]“的金融交易应用程序的分配率
先不看了
GCViewer
基本GC调优
当工程师考虑调整JVM的策略时,经常会出现“我应该何时调整GC?”的问题。 与任何其他调整技术一样,GC调整应构成整个诊断过程的一部分。 关于GC调整的以下事实非常有用:
- 消除或确认GC是导致性能问题的原因很便宜。
- 在UAT中打开GC标志很便宜。
- 设置内存或执行分析器并不便宜。
工程师还应该知道在调整过程中应该研究和测量这四个主要因素:
- 分配
- 暂停敏感度
- 吞吐量行为
- 对象寿命
其中,分配往往是最重要的。
注意
吞吐量可能受许多因素的影响,例如并发收集器在运行时占用核心。
让我们看一下表8-3中列出的一些基本堆大小调整标志。
表8-3。 GC堆大小标记
标志 | 影响 |
---|---|
-Xms<size> |
设置为堆保留的最小大小 |
-Xmx<size> |
设置为堆保留的最大大小 |
-XX:MaxPermSize=<size> |
设置PermGen允许的最大大小(Java 7) |
-XX:MaxMetaspaceSize=<size> |
设置Metaspace允许的最大大小(Java 8) |
-Xms<size> |
设置为堆保留的最小大小 |
MaxPermSize标志是遗留的,仅适用于Java 7及之前的版本。在Java 8及更高版本中,PermGen已被删除并被Metaspace取代。
注意
如果要在Java 8应用程序上设置MaxPermSize,则应该删除该标志。无论如何,它被JVM忽略了,所以它显然对你的应用程序没有影响。
关于调整的其他GC标志的主题:
- 一次只添加一个标记。
- 确保您完全理解每个标志的效果。
- 回想一些组合会产生副作用。
假设事件正在发生,检查GC是否是导致性能问题的原因相对容易。第一步是使用vmstat或类似工具查看机器的高级指标,如“基本检测策略”中所述。首先,登录到具有性能问题的框并检查:
- CPU利用率接近100%。
- 绝大多数时间(90 +%)用于用户空间。
- GC日志显示活动,表示GC当前正在运行。
这假设问题正在发生,工程师可以实时观察。对于过去的事件,必须提供足够的历史监控数据(包括CPU利用率和具有时间戳的GC日志)。
如果满足所有这三个条件,则应调查并调整GC作为当前性能问题的最可能原因。测试非常简单,并且有一个很好的清晰结果 - “GC没问题”或“GC不正常”。
“如果GC被指示为性能问题的根源,那么下一步是了解分配和暂停时间行为,然后调整GC并在需要时可能带出内存分析器。
了解分配
分配率分析不仅对于确定如何调整垃圾收集器非常重要,而且对于是否可以实际调整垃圾收集器以帮助提高性能也很重要。
我们可以使用年轻代收集事件中的数据来计算分配的数据量和两个集合之间的时间。然后,该信息可用于计算该时间间隔期间的平均分配率。
注意
与其花费时间和精力来手动计算分配率,通常最好使用工具来提供此数字。
经验表明,持续分配率大于1 GB / s几乎总是表明无法通过调整垃圾收集器来纠正的性能问题。在这些情况下提高性能的唯一方法是通过重构来消除应用程序关键部分的分配,从而提高应用程序的内存效率。
一个简单的内存直方图,如VisualVM所示(如“JVM的监视和工具”中所示)或甚至jmap(“引入标记和扫描”),可以作为理解内存分配行为的起点。一个有用的初始分配策略
是专注于四个简单的领域:
- 简单,可避免的对象分配(例如,日志调试消息)
- 封箱开销
- 域对象
- 大量非JDK框架对象
对于第一个,只需要发现并删除不必要的对象创建。过度装箱可以是这种形式,但其他示例(例如用于序列化/反序列化为JSON或ORM代码的自动生成的代码)也可能是浪费对象创建的来源。
域对象成为应用程序内存利用率的主要贡献者并不常见的。更常见的类型如:
- char []:包含字符串的字符
- byte []:原始二进制数据
- double []:计算数据
- 地图条目
- object[]
- 内部数据结构(例如methodOops和klassOops)
简单的直方图通常可以揭示泄漏或过度创建不必要的域对象,只需它们存在于堆直方图的顶部元素中。通常,所有必要的是快速计算以计算域对象的预期数据量,以查看观察到的体积是否符合预期。
在“线程局部分配”中,我们遇到了线程局部分配。该技术的目的是为每个线程提供一个私有区域来分配新对象,从而实现O(1)分配。
TLAB在每个线程的基础上动态调整大小,如果有空间,则在TLAB中分配常规对象。 如果不是,则线程从VM请求新的TLAB并再次尝试。
如果对象不适合空的TLAB,则VM将接下来尝试在任何TLAB之外的区域中直接在Eden中分配对象。 如果失败,则下一步是执行年轻的GC(可能会调整堆的大小)。 最后,如果失败并且仍然没有足够的空间,最后的办法是直接在Tenured中分配对象。
从中我们可以看到,真正可能最终在Tenured中直接分配的唯一对象是大型数组(尤其是byte和char数组)。
HotSpot有几个与TLAB相关的调整标志和大对象的预定:
-XX:PretenureSizeThreshold=<N>
-XX:MinTLABSize=<N>
与所有交换机一样,如果没有基准并且有确凿证据证明它们会产生影响,则不应使用这些交换机。在大多数情况下,内置的动态行为将产生很好的结果,任何变化都几乎没有真正的可观察的影响。
分配率也会影响提升为Tenured的对象数。如果我们假设短期Java对象具有固定的生命周期(以挂钟时间表示),则较高的分配率将导致 新生代的GC更加接近。如果收集过于频繁,那么段生命周期的对象可能没有时间去死亡,并且会被错误地提升为Tenured。
换句话说,分配峰值可能导致我们在“分配的角色”中遇到的过早提升问题。为了防范这种情况,JVM将动态调整surviving空间的大小,以容纳更多的幸存数据,而无需将其提升为Tenured。
一个JVM开关有时可以帮助解决期限问题和过早提升:
-XX:MaxTenuringThreshold = <N>
这可以控制对象必须存活的垃圾收集的次数才能提升为Tenured。它默认为4,但可以设置为1到15之间的任何值。更改此值表示两个问题之间的权衡:
- 阈值越高,真正长寿命对象的复制就越多。
- 如果阈值太低,将会提升一些短期对象,从而增加Tenured的内存压力。
阈值过低的一个后果可能是,由于更多的对象被提升为Tenured,导致其更快地填满,因此更频繁地发生Full GC。与往常一样,如果没有明确的基准测试表明您将通过将其设置为非默认值来提高性能,则不应更改开关。
了解暂停时间
开发人员经常遭受关于暂停时间的认知偏差。许多应用程序可以轻松容忍100多毫秒的暂停时间。人眼每秒只能处理单个数据项的5次更新,因此100-200 ms的暂停低于大多数面向人类的应用程序(例如Web应用程序)的可见性阈值。
暂停时间调整的一个有用的启发式方法是将应用程序划分为三个宽带。 这些频段基于应用程序对响应性的需求,表示为应用程序可以容忍的暂停时间。 他们是:
-
>
1秒:可以忍受超过1秒的暂停 - 1 s-100 ms:可以容忍超过200 ms但不到1 s的暂停
- <100 ms:无法忍受100 ms的暂停
如果我们将暂停灵敏度与应用程序的预期堆大小相结合,我们可以在合适的收集器上构建一个简单的最佳猜测表。 结果如表8-4所示。
初始收集选项
> 1s |
1s-100ms | < 100ms | <2GB |
---|---|---|---|
Parallel | Parallel | CMS | < 4GB |
Parallel | Parallel/G1 | CMS | < 4GB |
Parallel | Parallel/G1 | CMS | < 10GB |
Parallel/G1 | Parallel/G1 | CMS | < 20GB |
Parallel/G1 | G1 | CMS |
> 20GB 4GB |
这些是作为调整起点的指导方针和经验法则,而不是100%明确的规则。
展望未来,随着G1成为收集器的成熟,我们有理由期望它将扩展到涵盖ParallelOld目前涵盖的更多用例。它也可能,但也许不太可能,它也将扩展到CMS的用例。
小费
当使用并发收集器时,您仍应在尝试调整暂停时间之前减少分配。减少分配意味着并发收集器的内存压力更小;收集周期将更容易跟上分配线程。这将减少并发模式失败的可能性,这通常是暂停敏感的应用程序需要尽可能避免的事件。
收集器线程和GC根
一个有用的心理练习是”像GC线程一样思考。“这可以让我们深入了解收集器在各种情况下的行为。然而,正如GC的许多其他方面一样,有一些基本的权衡取舍。这些权衡包括定位GC根所需的扫描时间受以下因素的影响:
- 应用程序线程数
- 代码缓存中已编译代码的数量
- 堆的大小
即使对于GC的这个单一方面,这些事件中的哪一个占主导地位的GC根扫描将始终取决于运行时条件和可应用的并行化量。
例如,考虑在Mark阶段发现的大型Object []的情况。这将由单个线程扫描;没有工作偷窃是可能的。在极端情况下,这种单线程扫描时间将主导整个标记时间。
实际上,对象图越复杂,这种效果就越明显 - 意味着标记时间会变得更糟,图中的对象的“长链”就越多。
大量的应用程序线程也会对GC时间产生影响,因为它们代表了更多的扫描堆栈帧以及更多的时间来达到安全点。它们还对裸机和虚拟化环境中的线程调度程序施加了更大的压力。
除了GC根的这些传统示例之外,还有其他GC根,包括JNI帧和JIT编译代码的代码缓存(我们将在“代码缓存”中正确地满足)。
警告
代码缓存中的GC根扫描是单线程的(至少对于Java 8而言)。
在这三个因素中,堆栈和堆扫描相当好地并行化。分代收集器还使用诸如G1中的RSets和并行GC和CMS中的卡表之类的机制来跟踪源自其他内存池的根。
例如,让我们考虑在”弱代假设“中引入的卡表。 这些用于表示一个记忆块,它有一个从老年代到新生代的反向引用。 由于每个字节代表512字节的旧代,很明显,对于每1 GB的旧代内存,必须扫描2 MB的卡表。
要了解卡表扫描需要多长时间,请考虑一个简单的基准测试,模拟扫描卡表以获得20 GB的堆:
@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@OutputTimeUnit(TimeUnit.SECONDS)
@Fork(1)
public class SimulateCardTable {
// OldGen is 3/4 of heap, 2M of card table is required for 1G of old gen
private static final int SIZE_FOR_20_GIG_HEAP = 15 * 2 * 1024 * 1024;
private static final byte[] cards = new byte[SIZE_FOR_20_GIG_HEAP];
@Setup
public static final void setup() {
final Random r = new Random(System.nanoTime());
for (int i=0; i<100_000; i++) {
cards[r.nextInt(SIZE_FOR_20_GIG_HEAP)] = 1;
}
}
@Benchmark
public int scanCardTable() {
int found = 0;
for (int i=0; i<SIZE_FOR_20_GIG_HEAP; i++) {
if (cards[i] > 0)
found++;
}
return found;
}
}
运行此基准测试会产生类似于以下内容的输出:
Result "scanCardTable":
108.904 ±(99.9%) 16.147 ops/s [Average]
(min, avg, max) = (102.915, 108.904, 114.266), stdev = 4.193
CI (99.9%): [92.757, 125.051] (assumes normal distribution)
# Run complete. Total time: 00:01:46
Benchmark Mode Cnt Score Error Units
SimulateCardTable.scanCardTable thrpt 5 108.904 ± 16.147 ops/s
这表明扫描卡表需要大约10毫秒才能获得20 GB的堆。当然,这是单线程扫描的结果;但是,它确实的暂停时间提供了有用的粗略下限。
我们已经研究了一些适用于调整大多数收集器的一般技术,现在让我们继续讨论一些特定于收集器的方法。
调优并行GC
并行GC是最简单的收集器,因此它也是最容易调整的并不奇怪。但是,它通常需要最少的调整。 Parallel GC的目标和权衡是明确的:
- 完全STW
- 高GC吞吐量/计算上便宜
- 不可能部分收集
- 在堆大小中线性增长的暂停时间
如果您的应用程序可以容忍并行GC的特性,那么它可以是一个非常有效的选择 - 特别是在小堆上,例如~4 GB以下的堆。
较旧的应用程序可能已使用显式大小调整标志来控制各种内存池的相对大小。表8-5总结了这些标志。
表8-5。较旧的GC堆大小标记
> 1s |
1s-100ms |
---|---|
-XX:NewRatio=<n> |
(旧标志)设定新生代Heap的比率 |
-XX:SurvivorRatio=<n> |
(旧标志)设置survivor与新生代的比例 |
-XX:NewSize=<n> |
(旧标志)设置新生代的细小尺寸 |
-XX:MaxNewSize=<n> |
(旧标志)设置新生代的最大尺寸 |
-XX:MinHeapFreeRatio |
(旧标志)在GC之后设置最小堆百分比,避免扩展 |
-XX:MaxHeapFreeRatio |
(旧标志)在GC之后设置最小堆百分比,避免收缩 |
幸存者比率,新生代比率和总体堆大小通过以下公式连接:
标志设置:
-XX:NewRatio = N
-XX:SurvivorRatio = K
YoungGen = 1 /(N + 1)堆
OldGen =堆的N /(N + 1)
Eden =(K - 2)/ K的YoungGen
幸存者1 = YoungGen的1 / K.
幸存者2 = YoungGen的1 / K.
对于大多数现代应用,不应使用这些类型的显式尺寸,因为在几乎所有情况下,符合人体工程学的尺寸将比人类做得更好。对于并行GC,使用这些开关是最后的手段。
调优CMS
CMS收集器具有难以调整的声誉。这并非完全不值得:从CMS中获得最佳性能所涉及的复杂性和权衡不容小觑。
不幸的是,许多开发人员普遍认为简单的位置“暂停时间不好,因此并发标记收集器很好”。像CMS这样的低暂停收集器应该被视为最后的手段,只有在用例真正需要低STW暂停时间时才能使用。否则可能意味着团队遇到了难以调整的收集器,并且对应用程序性能没有任何实际的好处。
CMS具有非常多的标志(Java 8u131中几乎有100个),并且一些开发人员可能想要改变这些标志的值以试图提高性能。但是,这很容易导致我们在第4章中遇到的几个反模式,包括:
- 摆弄开关
- 民间传说调整
- 错过了更大的图片
认真的性能工程师应该抵制任何这些“堕落的诱惑”。
警告
使用CMS的大多数应用程序可能无法通过更改CMS命令行标志的值来看到任何真正可观察到的改进。
尽管存在这种危险,但在某些情况下需要进行调整以改善(或获得可接受的)CMS性能。 让我们从考虑吞吐量行为开始。
在CMS收集运行时,默认情况下有一半核心在运行GC。 这不可避免地导致整个应用吞吐降低。 一个有用的经验法则是在发生并发模式故障之前考虑收集器的情况。
在这种情况下,只要CMS集合完成,就会立即启动新的CMS集合。
在这种情况下,只要CMS收集一完成,就会立即启动新的CMS收集。这称为背对背收集,对于并发收集器,它表示并发集合即将崩溃的点。如果应用程序分配得更快,那么回收将无法跟上,结果将是CMF。
在背靠背的情况下,基本上整个应用程序运行的吞吐量将减少50%。在进行调整练习时,工程师应考虑应用程序是否可以容忍这种最坏情况的行为。如果不能,则解决方案可能是在具有更多可用核心的主机上运行。
另一种方法是减少CMS循环期间分配给GC的核心数。当然,这是危险的,因为它减少了可用于执行收集的CPU时间量,因此将使应用程序对分配峰值的抵抗力降低(反过来可能使其更容易受到CMF的攻击)。控制它的开关是:
-XX:ConcGCThreads = <N>
很明显,如果应用程序无法使用默认设置进行足够快速的回收,那么减少GC线程的数量只会使事情变得更糟。
CMS有两个独立的STW阶段:
- 初始标记
标记直接内部节点 - 由GC根直接指向的内部节点 - 重标记
使用卡表来标识可能需要修复工作的对象
这意味着所有应用程序线程必须停止,因此安全点,每个CMS周期两次。对于一些对安全性行为敏感的低延迟应用程序,此效果可能变得很重要。
有时一起看的两个标志是:
-XX:CMSInitiatingOccupancyFraction = <N>
-XX:+UseCMSInitiatingOccupancyOnly
这些标志非常有用。它们再次说明了不稳定的分配率带来的重要性和困境。
初始占用标志用于确定CMS应该在何时开始收集。需要一些堆空间,以便在CMS运行时可能出现的新生代收集中有对象被提升的备用空间。
与HotSpot GC方法的许多其他方面一样,此备用空间的大小由JVM自身收集的统计信息控制。但是,仍然需要对第一轮CMS进行估算。此初始猜测的净空大小由标志CMSInitiatingOccupancyFraction控制。此标志的默认值表示第一个CMS完整GC将在堆已满75%时开始。
如果还设置了UseCMSInitiatingOccupancyOnly标志,则关闭启动占用的动态大小调整。这不应该轻易做到,并且在实践中很少减少净空(将参数值增加到75%以上)。
但是,对于某些具有非常突发分配率的CMS应用,一种策略是增加净空(减小参数值),同时关闭自适应大小。这里的目标是尝试减少并发模式故障,而代价是CMS并发GC更频繁地发生。
由于碎片导致并发模式失败
让我们看一下另一种情况,即执行调优分析所需的数据仅在GC日志中可用。在这种情况下,我们希望使用空闲列表统计信息来预测JVM何时可能由于堆碎片而遭受CMF的影响。这种类型的CMF是由CMS维护的空闲列表引起的,我们在“JVM安全点”中遇到了这个列表。
我们需要打开另一个JVM开关才能看到额外的输出:
-XX:PrintFLSStatistics=1
GC日志中生成的输出如下所示(BinaryTreeDictionary基准测试显示的输出统计信息的详细信息):
Total Free Space: 40115394
Max Chunk Size: 38808526
Number of Blocks: 1360
Av. Block Size: 29496
Tree Height: 22
在这种情况下,我们可以从平均大小和最大块大小了解内存块的大小分布。如果我们没有足够大的块来支持将大型活动对象移动到Tenured中,那么GC升级将降级为此并发故障模式。
为了压缩堆并合并可用空间列表,JVM将回退到并行GC,可能导致STW暂停。这种分析在实时执行时非常有用,因为它可以表示即将进行长时间的暂停。您可以通过解析日志或使用可以自动检测即将到来的CMF的Censum等工具来观察它。
调整G1
调整G1的总体目标是允许最终用户简单地设置最大堆大小和MaxGCPauseMillis
,并让收集器处理其他所有事情。然而,目前的现实还有一段距离。
与CMS一样,G1提供了大量配置选项,其中一些仍然是实验性的,并未在VM中完全浮出水面(就可见的调整指标而言)。这使得很难理解任何调整更改的影响。如果调整需要这些选项(目前它们适用于某些调整方案),则必须指定此开关:
-XX:+UnlockExperimentalVMOptions
特别是,如果要使用选项-XX:G1NewSizePercent = <n>
或-XX:G1MaxNewSizePercent = <n>
,则必须指定此选项。在未来的某些时候,其中一些选项可能会变得更加主流并且不需要实验选项标志,但目前还没有这方面的路线图。
图8-7中可以看到G1堆的有趣视图。该图像由 regions
JavaFX应用程序生成。
[图片上传失败...(image-470030-1551495799162)]
这是一个小型的开源Java FX应用程序,它解析G1 GC日志,并提供了一种在GC日志的生命周期内可视化G1堆的区域布局的方法。该工具由Kirk Pepperdine编写,可以从GitHub(https://github.com/kcpeppe/regions)获得。在撰写本文时,它仍处于积极发展阶段。
G1调整的一个主要问题是内部在收集器的早期寿命中发生了很大变化。这导致了民间传说调整的一个重大问题,因为许多早期关于G1的文章现在的有效性有限。
随着G1成为Java 9的默认收集器,性能工程师肯定会被迫解决如何调整G1的问题,但在撰写本文时,它仍然是一个经常令人沮丧的任务,其中最佳实践仍在出现。
但是,让我们通过关注已取得一些进展的地方以及G1确实提供超过CMS的承诺来结束本节。回想一下,CMS不紧凑,因此随着时间的推移,堆可能会碎片化。这最终将导致并发模式失败,并且JVM将需要执行完全并行收集(可能会出现明显的STW暂停)。
在G1的情况下,如果收集器可以跟上分配率,则增量压缩提供了完全避免CMF的可能性。因此,具有高且稳定的分配率并且创建大多数短期选项的应用程序应该:
- 设定一个大的新生代
- 提高tenuring阈值,可能达到最大值(15)。
- 设置应用可以容忍的最长暂停时间。
以这种方式配置Eden和幸存者空间提供了最好的机会,即不提升真正的短期对象。 这减轻了老一代的压力,减少了清理旧区域的需要。 GC调优中几乎没有任何保证,但这是一个示例工作负载,其中G1可能比CMS明显更好,尽管以调整堆的一些努力为代价。
JHiccup
我们已经在”非正态统计“中遇到了HdrHistogram。相关工具是jHiccup(https://github.com/giltene/jHiccup),可从GitHub获得。这是一个仪器工具,旨在显示JVM无法连续运行的“间隔”。一个非常常见的原因是GC STW暂停,但其他OS和平台相关的影响也可能导致间隔。因此,它不仅适用于GC调整,而且适用于超低延迟工作。
事实上,我们也已经看到了jHiccup如何工作的一个例子。在“Shenandoah”中,我们介绍了Shenandoah收藏家,并展示了收藏家的表现与其他收藏家相比的图表。图7-9中的比较性能图由JHiccup产生。
注意
jHiccup是一个很好的工具,可以在调整HotSpot时使用,即使原作者(Gil Tene)高兴地承认它是为了显示HotSpot与Azul的Zing JVM相比的缺点。
jHiccup通常用作Java代理,通过-javaagent:jHiccup.jar Java命令行开关。它也可以通过Attach API使用(就像其他一些命令行工具一样)。形式是:
jHiccup -o <pid>
这有效地将jHiccup注入正在运行的应用程序中。
jHiccup以直方图日志的形式产生输出,可以通过HdrHistogram摄取。 让我们来看看这个实际操作,首先回顾一下“分配角色”中介绍的模型分配应用程序。
要使用一组体面的GC日志标记以及作为代理运行的jHiccup来运行它,让我们看一小段shell脚本来设置运行:
#!/bin/bash
# Simple script for running jHiccup against a run of the model toy allocator
CP=./target/optimizing-java-1.0.0-SNAPSHOT.jar
JHICCUP_OPTS=
-javaagent:~/.m2/repository/org/jhiccup/jHiccup/2.0.7/jHiccup-2.0.7.jar
GC_LOG_OPTS="-Xloggc:gc-jHiccup.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps -XX:+PrintTenuringDistribution"
MEM_OPTS="-Xmx1G"
JAVA_BIN=`which java`
if [ $JAVA_HOME ]; then
JAVA_CMD=$JAVA_HOME/bin/java
elif [ $JAVA_BIN ]; then
JAVA_CMD=$JAVA_BIN
else
echo "For this command to run, either $JAVA_HOME must be set, or java must be
in the path."
exit 1
fi
exec $JAVA_CMD -cp $CP $JHICCUP_OPTS $GC_LOG_OPTS $MEM_OPTS
optjava.ModelAllocator
这将产生GC日志和.hlog文件,适合作为jHiccup的一部分提供给jHiccupLogProcessor工具。 一个简单的,开箱即用的jHiccup视图如图8-8所示。
[图片上传失败...(image-e87d45-1551495799162)]
图8-8。 ModelAllocator的jHiccup视图
这是通过jHiccup的一个非常简单的调用获得的:
jHiccupLogProcessor -i hiccup-example2.hlog -o alloc-example2
还有一些其他有用的开关 - 查看所有可用的选项,只需使用:
jHiccupLogProcessor -h
通常情况下,性能工程师需要理解相同应用程序行为的多个视图,因此为了完整性,让我们看一下与Censum相同的运行,如图8-9所示。
[图片上传失败...(image-2885bf-1551495799162)]
GC回收后的堆大小图表显示HotSpot尝试调整堆大小并且无法找到稳定状态。这是一种常见的情况,即使对于像ModelAllocator这样简单的应用程序也是如此。 JVM是一个非常动态的环境,在大多数情况下,开发人员应该避免过分关注GC人体工程学的低级细节。
最后,关于HdrHistogram和jHiccup的一些非常有趣的技术细节可以在Nitsan Wakart的一篇很棒的博客文章(http://psy-lob-saw.blogspot.com/2015/02/hdrhistogram-better-latency-capture.html)找到。
摘要
在本章中,我们已经涉及了GC调整技术的表面。我们展示的技术大多是针对个体收藏家的高度特异性,但有一些基本技术通常适用。我们还介绍了处理GC日志的一些基本原则,并遇到了一些有用的工具。
接下来,在下一章中,我们将转向JVM的其他主要子系统之一:应用程序代码的执行。我们将首先概述解释器,然后从那里开始讨论JIT编译,包括它与标准(或“Ahead-of-Time”)编译的关系。