到目前为止,我们已经了解了内存的不同区域以及它是如何被释放的,但还没有讨论如何优化 JVM(Java 虚拟机)执行这些工作的方式。JVM 管理内存的方法是可配置的,而且有多种路径可选。
不过,JVM 并不存在“一刀切”的最佳配置。最合适的配置取决于具体应用及其需求。找到更优配置能提升应用性能并尽量降低内存占用。对性能与内存进行监控,能让你在用户发现之前就定位问题。
在本章中,我们将看看如何配置 JVM 并监控内存管理。修改 JVM 配置通常采用“调优”的方式进行:先基于经验设定一个起点,然后做小步调整,并且仔细度量这些调整带来的影响。我们将讨论以下主题:
- JVM 内存管理调优基础
- 获取与内存管理相关的关键指标
- Java 应用的性能剖析(Profiling)
- JVM 配置项的调优
技术要求
本章示例代码见 GitHub:github.com/PacktPublis…。
JVM 内存管理调优基础
JVM 调优提升性能的第一法则:它应该是最后的手段。看下面这段代码:
int i = 0;
List<Integer> list = new ArrayList<>();
while(i < 100) {
list.add((int)Math.ceil(Math.random()*1000));
}
JVM 调优有用吗?没有,因为这段代码陷入了无限循环——i 从未递增。现实中还有许多没有这么直观的例子,但只要代码本身还能优化,就应该先优化代码,再考虑 JVM 调优。
如果硬件能现实可行地升级,也应该先升级硬件,再做 JVM 调优。这里并不是说通过加内存来“解决”内存泄漏——那当然不是修复。但如果你的应用突然非常成功、负载变高导致变慢,升级硬件往往比立刻深入 JVM 调优更划算。当影响性能的其它因素都已优化后,才轮到借助 JVM 调优进一步提升性能。
进行 JVM 调优,本质上是在设置参数,并且必须配合严密监控。在改动任何设置之前,务必先掌握应用当前的度量数据。改完参数后要密切观察:如果性能提升,可以再小步推进;如果变差,应回退到上一个较优点,再评估差异。
听起来有点“试错”,本质上确实如此——但这是有方法的专业试错。接下来看看为了调 JVM 的内存管理,需要关注哪些关键指标。
获取与内存管理相关的关键指标
要判断应用内存状况,有几类重要指标。首先要理解三个界定应用性能健康度的核心概念:
- 健康的内存状态
- 正常的延迟(Latency)
- 合理的吞吐量(Throughput)
我们分别说明。
健康的内存状态
当你对某个应用有经验后,通常会知道它的稳定内存使用水平。为 Java 应用预留的可用内存应高于这个稳定点,且不能长期逼近上限。反过来,给 Java 进程分配过多内存也不合适,因为操作系统以及其他进程同样需要内存。
了解应用在“表现良好”时的基线内存指标,能帮助你在后续调整参数时评估成效。
正常的延迟(Latency)
延迟可理解为应用的响应速度。延迟正常意味着按预期做出响应。例如,处理一个传入的 HTTP 请求所需的时间。
当然,延迟测量并不总是容易。对于独立运行的 Java 程序,测量它自身的延迟较为直接;而在企业级应用中就复杂得多:你必须确保测到的是你的应用的延迟,而不是网络问题、对端服务的延迟,或系统内其他层的影响。在这些情况下,延迟异常往往与本应用的内存管理无关。
吞吐量(Throughput)
吞吐量是单位时间内应用能完成的工作量。通常我们希望吞吐量越高越好,但更高的吞吐量往往需要更多内存,并且可能影响延迟。
Java 应用的性能剖析(Profiling)
Profiling 用于分析应用在运行时的性能表现。由于 Profiling 通常会对被测应用本身产生影响,因此需要谨慎进行;能在开发环境完成的,尽量不要在生产环境做。本节我们将使用命令行工具 jstat、jmap,以及图形化工具 VisualVM 进行剖析。前两者随 JDK 一起提供;VisualVM 早期随 JDK 提供,但如今需单独下载。
重要说明
你可以在此下载 VisualVM:visualvm.github.io/download.xh…。
当然,还有其他分析器(profiler);一些 IDE 也内置了类似的分析器,工作方式相近。
使用 jstat 与 jmap 进行剖析
借助命令行工具 jstat 与 jmap,我们可以分析与剖析内存。下面演示如何操作。
先看一个简单的 Java 应用:
package chapter6;
import java.util.ArrayList;
import java.util.List;
public class ExampleAnalysis {
public static List<String> stringList = new ArrayList<>();
public static void main(String[] args) {
for(int i = 0; i < 1000000000; i++) {
stringList.add("String " + i);
System.out.println(stringList.get(i));
}
}
}
这个程序没做什么复杂事情,只是不断向静态列表 stringList 里添加大量 String 对象。
我们先运行程序,再观察内存情况。首先需要编译:
javac ExampleAnalysis.java
上面的命令假定你当前就在源码所在目录(命令行能直接访问该文件)。编译后会生成 ExampleAnalysis.class。接着运行(确保当前目录在 Chapter 6 的上一级):
java chapter6.ExampleAnalysis
接下来,为了用 jstat 分析,需要先拿到进程 ID(PID) 。用下面的命令列出所有 Java 进程的 PID:
jps
可能输出如下:
35169 Launcher
35397 Jps
30565
35384 ExampleAnalysis
34846
带有类名的那行就是我们的程序,因此 PID 为 35384。
现在就可以用 jstat 了。这个工具有多种选项,先这样运行:
jstat -gc -t 35384 1000 10
这将针对 PID 35384 打印 GC 相关统计信息。-gc 选项表示输出垃圾回收堆的行为统计。其他常用标志包括:
gccapacity:各代(generations)的容量信息gcnew:新生代的行为数据gcnewcapacity:新生代的容量大小gcold:老年代与 Metaspace 的数据gcoldcapacity:老年代容量gcutil:GC 数据概要
-t 表示打印时间戳;1000 表示每 1000 ms 输出一次;10 表示输出 10 次。
输出(如图 6.1 所示)包含很多列。具体值对讨论并不关键,下面按从左到右说明各列含义:
- Timestamp:程序启动以来的时间。会按秒增长,符合我们 1000ms 间隔的设置。
- S0C:幸存区 S0 的当前容量(KB)。
- S1C:幸存区 S1 的当前容量(KB)。
- S0U:S0 已用容量(KB)。
- S1U:S1 已用容量(KB)。
- EC:Eden 区当前容量(KB)。可见 Eden 变满时容量会扩大。
- EU:Eden 已用容量(KB)。在第 7 行出现回落,数据被移动到老年代。
- OC:老年代当前容量(KB)。
- OU:老年代已用容量(KB),可见随程序运行而增加。
- MC:Metaspace 当前容量(KB)。
- MU:Metaspace 已用容量(KB)。
- CCSC:压缩类空间(Compressed Class Space)容量(KB)。
- CCSU:压缩类空间已用(KB)。
- YGC:发生过的年轻代 GC 次数。
- YGCT:年轻代 GC 总耗时。
- FGC:发生过的Full GC 次数。
- FGCT:Full GC 总耗时。
- CGC:并发 GC。
- CGCT:并发 GC 总耗时。
- GCT:GC 总时间。
使用 jmap 可以进一步查看堆使用情况(Java 9 及之后):
jhsdb jmap --heap --pid 35384
jhsdb 是能附着到运行中 Java 进程、做快照调试、并检查崩溃 JVM core dump 的 JDK 工具。该命令会输出当前堆的配置与使用概况。下面再看一个更直观的可视化工具——VisualVM。
使用 VisualVM 进行剖析
有不少分析器能提供内存的可视化展示,其中 VisualVM 是一款易用的工具,能查看本机正在运行的 Java 应用的详细信息。它现在需要单独安装:visualvm.github.io/。如果你的 IDE 也支持 Profiling,也可以用 IDE 自带工具;本节以 VisualVM 为例,因为它免费且易于获取。
使用 VisualVM 进行应用剖析非常简单。启动 VisualVM 后,会看到类似图 6.2 的起始界面。
在界面左上方纵向的 Applications(应用)面板中,可以看到本机正在运行的 Java 进程(见图 6.3)。
选中要分析的进程(例如 PID 为 6450),即可进入该进程的概览页(见图 6.4),显示被分析的进程、JVM 与 Java 版本、启动参数等信息。
顶部有多个标签页:Overview、Monitor、Threads、Sampler、Profiler。我们已看过 Overview;切换到 Monitor(监控)页(见图 6.5),这里有四个图表:
- 左上:CPU 使用率 与 GC 活动。对于本示例,CPU 使用较高,而 GC 活动整体较低,这也合理:几乎没有可回收的对象。
- 右上:内存(Heap)曲线。将其与左上的 GC 活动结合能判断健康度:如果 GC 活动频繁但 Used heap 仍持续上升,通常意味着内存泄漏。一般来说,若 GC 过于频繁,需要排查 GC 与内存问题;如果排查后仍频繁且内存不降,就是红色警报。实际上,当 JVM 将超过 98% 的时间花在 GC 上且回收不到 2% 的堆时,会抛出
OutOfMemoryError: GC Overhead limit exceeded。 - 左下:已加载的 Java 类 数量曲线。
- 右下:线程 数量曲线。更详细的线程信息可在 Threads 标签查看(见图 6.6),左侧显示线程名,条形图表示一段时间内的线程状态(如 Running、Waiting)及持续时间。
Sampler(采样)页(见图 6.7)可查看 CPU 或内存采样。下图展示的是内存采样:列出了存活对象的数量与各类对象的占用空间。可以看到 byte[] 最大——这很合理,因为 String 的值存放在 byte[] 中。你也可以按线程过滤,或切换观察 CPU。
最后是 Profiler(分析器)页。Profiling 与 Sampling 目标类似,但方法不同:Sampling 通过周期性线程快照来分析;Profiling 则通过在应用中注入探针逻辑来捕获事件,这会显著影响性能,因此不建议在生产环境使用,但能提供更细致的洞察。图 6.8 展示了对全部类进行 Profiling 的结果,和 Sampling 类似(当时分配量更少)。在这种场景下,仅用 Sampling 也足够。
总之,VisualVM 能快速地、可视化地洞察应用的内存行为——这在调 JVM并验证调整效果时尤其有用。下一节我们就来做这件事:学习如何调整 JVM 配置并观察这些调整带来的影响。
调优堆大小与线程栈大小
堆的大小是可以调整的。通用的最佳实践是:不要把堆设置为超过服务器可用内存的一半。因为服务器还会运行其他进程,堆占比过大可能导致整体性能问题。
默认大小因系统而异。下面的命令可在 Windows 上查看默认值:
java -XX:+PrintFlagsFinal -version | findstr HeapSize
在 macOS 上可用:
java -XX:+PrintFlagsFinal -version | grep HeapSize
输出单位为字节。图 6.9 展示了在我的电脑上的输出示例。
堆大小会影响垃圾回收(GC)。这听起来有点反直觉,但做个小小的思想实验就明白了:如果我们有无限的堆内存,还需要 GC 吗?显然不需要——既然不必释放内存,就没必要运行这样一个开销昂贵的过程。
堆越小,越需要频繁地进行 GC,因为空间更容易被填满,GC 必须更“卖力”地腾挪可用空间;但堆越大,一次完整 GC 的耗时会越长——因为需要扫描的范围更大。一个经验法则是:让 GC 占用的应用执行时间低于 5% 。
实际调优在不同服务器上的操作方式可能不同。这里展示的是在启动应用时通过命令行设置。请注意:各选项名在不同服务器上是一致的,但设置的位置或方式可能有所差异。
启动 Java 应用时,可以设置内存池的初始大小、最大大小以及线程栈大小。下面示例将三者都设为 1024 MB:
-Xms1024m (初始堆大小)
-Xmx1024m (最大堆大小)
-Xss1024m (线程栈大小)
需要不同大小就相应修改数值。示例(在 64 位系统上):
java -Xms4g -Xmx6g ExampleAnalysis
这会以 初始堆 4GB、最大堆 6GB 启动示例应用。
类似于用 -Xmx 与 -Xms 绑定总堆大小,你也可以用下列参数绑定新生代大小:
-XX:MaxNewSize=1024m (新生代最大值)
-XX:NewSize=1024m (新生代最小值)
这里把最小和最大都设为 1024 MB。若内存不足,会抛出 OutOfMemoryError。接下来看看发生这种错误时如何获取堆转储(heap dump)以便排查。
低内存日志与堆转储
当应用因 OOM 退出时,获取一个堆转储快照非常有用。堆转储是应用在某一时刻内存中对象的快照;当 OOM 发生时的堆转储能帮助我们定位哪些对象导致内存溢出。
若希望在出现 OutOfMemoryError 时由 JVM 自动生成堆转储,可在启动时加入:
java -XX:+HeapDumpOnOutOfMemoryError ExampleAnalysis
也可指定堆转储路径:
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/some/path/to/dumps ExampleAnalysis
这样堆转储会写入指定目录。除此之外,你还可以使用 jmap 为仍在运行(且并未因 OOM 崩溃)的应用生成堆转储。
调优 Metaspace
Metaspace 的默认行为看起来像有一个“上限”。容易被误解的是:这个“上限”并非真正的硬性限制。达到该阈值时,JVM 会先尝试做一次 GC,然后再扩容。因此要谨慎设置以下变量:
-
最大大小:
-XX:MaxMetaspaceSize=2048m -
触发 GC 的阈值:
-XX:MetaspaceSize=1024m -
最小与最大空闲比例(Free Ratio):
-XX:MinMetaspaceFreeRatio=50 -XX:MaxMetaspaceFreeRatio=50
当你计划动态加载大量类时,合适的 Free Ratio 很有帮助。通过确保有足够空闲空间,可以加快类的加载速度——因为为新类腾挪空间(释放或扩展)也需要 CPU 时间。上例把两者都设为 50%。
垃圾回收调优
如你所见,GC 是一项成本不低的工作;优化它能明显改善应用性能。你不能强制触发 GC——是否执行 GC 由 JVM 决定。你也许见过下面的“建议 GC”的写法:
System.gc();
它不能保证立刻发生 GC。因此我们不能直接触发 GC,但可以影响 JVM 的 GC 策略。
在调整 GC 之前,务必确保你明白自己在做什么:这需要对所用的垃圾回收器有扎实的理解。同时,在动任何参数前,请先观察内存使用情况:哪些空间何时被填满。健康的堆曲线在 VisualVM 中通常像一把“锯齿”——使用量上升到某个水平,GC 发生,曲线回落到一个基线;随后再次上升、再次回落……形成有规律的“锯齿”。
若你看到使用内存基线不断抬高(每次 GC 后的“底部”越来越高),很可能是内存泄漏。正如第 4 章所述,JVM 提供了多种 GC 实现;启动 JVM 时可以指定要使用的回收器:
-XX:+UseSerialGC
-XX:-UseParallelGC
-XX:+UseConcMarkSweepGC
-XX:+G1GC
-XX:+UseZGC
并非所有系统都支持全部选项,而且每种回收器都有一套额外的细粒度参数。例如,选择并行 GC 并指定 GC 线程数:
java -XX:+UseParallelGC -XX:ParallelGCThreads=4 ExampleAnalysis
这会以并行 GC 启动应用,并给 GC 分配 4 个工作线程。各回收器的完整参数过多,本文不一一展开;可参考你所用 Java 实现的官方文档。Oracle 实现的参考文档(你阅读时可能已有新版):
docs.oracle.com/javase/9/gc…
小结
本章介绍了调优 JVM 时需要关注的要点:内存健康度、延迟与吞吐。
我们展示了如何使用 jstat 命令行工具与 VisualVM 可视化工具来监控应用运行状况。随后说明了如何调整堆、Metaspace 以及垃圾回收器的配置,并观测其对示例应用的影响。
请再次牢记:优化 JVM 配置应是最后一步。在此之前,优先考虑更直接的措施(例如优化代码)。完成这些,你就可以继续学习下一章:如何避免内存泄漏。