JVM调优与排查
开篇:线上故障了,你会怎么排查?
凌晨3点,手机突然响了。值班群里弹出一条消息:"生产环境告警,服务A频繁FullGC,接口响应超时!"
你揉了揉眼睛,打开电脑,ssh连上机器。接下来你会做什么?是先看日志?还是先看GC?又或者直接dump堆内存?
如果你对这个场景感到心里没底,那这篇文章就是为你准备的。我们会从JVM核心参数讲起,逐步深入到排查工具、OOM实战、FullGC排查流程,最后聊聊JIT编译优化。学完之后,下次再被叫醒,你至少知道该打出哪几行命令。
一、JVM 核心参数
调JVM就像调一台发动机——参数设错了,轻则性能低下,重则直接宕机。我们先把最常用的几组参数理清楚。
1.1 内存相关
| 参数 | 含义 | 典型值 |
|---|---|---|
-Xms | 堆初始大小 | -Xms4g |
-Xmx | 堆最大大小 | -Xmx4g |
-Xss | 每个线程的栈大小 | -Xss512k |
-XX:MetaspaceSize | 元空间初始大小 | -XX:MetaspaceSize=256m |
-XX:MaxMetaspaceSize | 元空间最大大小 | -XX:MaxMetaspaceSize=512m |
实战建议:生产环境中,-Xms 和 -Xmx 建议设成一样大。为什么?因为如果不一致,JVM在运行过程中会频繁地扩容和缩容堆内存,而每次调整都可能触发一次FullGC。设成一样就能避免这个问题。
举个例子,一个4C8G的机器,堆内存通常设置为物理内存的一半左右:
java -Xms4g -Xmx4g -Xss512k -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -jar your-app.jar1.2 GC 相关
GC参数的选择取决于你用的是哪个垃圾回收器。目前主流的选择是G1(Java 9+默认),更新的应用也可以考虑ZGC。
| 参数 | 含义 |
|---|---|
-XX:+UseG1GC | 使用G1垃圾回收器 |
-XX:MaxGCPauseMillis=200 | G1的目标最大停顿时间(毫秒) |
-XX:+UseZGC | 使用ZGC(Java 11+) |
-XX:+UseParallelGC | 使用并行垃圾回收器 |
-XX:ParallelGCThreads=8 | 并行GC的线程数 |
-XX:ConcGCThreads=4 | 并发GC的线程数 |
怎么选? 大多数后端服务直接用G1就好。如果你的应用对延迟极度敏感(比如交易系统),可以考虑ZGC。如果是批处理任务、对吞吐量要求高,ParallelGC也是个不错的选择。
1.3 常用调优参数速查表
下面这张表是你调优时的"速查手册",建议收藏:
| 参数 | 作用 | 推荐场景 |
|---|---|---|
-XX:+HeapDumpOnOutOfMemoryError | OOM时自动dump堆 | 必加,所有生产环境 |
-XX:HeapDumpPath=/tmp/heapdump.hprof | 指定dump文件路径 | 配合上一条使用 |
-XX:+PrintGCDetails | 打印GC详细日志 | 排查GC问题时开启 |
-XX:+PrintGCDateStamps | GC日志加时间戳 | 配合GC日志分析 |
-Xloggc:/var/log/gc.log | GC日志输出文件 | 生产环境建议开启 |
-XX:OnOutOfMemoryError="kill -9 %p" | OOM时自动kill进程 | 容器化部署常用 |
-XX:+ExitOnOutOfMemoryError | OOM时直接退出JVM | Java 8u92+ 可用 |
划重点:
-XX:+HeapDumpOnOutOfMemoryError这个参数,是你线上排查OOM问题的"救命稻草"。没有它,等到OOM发生了,你只能看着日志叹气,因为现场已经没了。加了它,OOM发生时JVM会自动把堆内存dump成文件,事后用MAT或VisualVM打开分析就行。
二、排查工具箱
工欲善其事,必先利其器。在排查JVM问题时,你需要掌握以下几组工具。
2.1 命令行三剑客:jps + jstack + jmap
这三个命令是JDK自带的,不需要额外安装,也是你在生产环境中最常用的工具。
jps — 找到Java进程
第一步永远是找到目标进程的PID:
$ jps -l
12345 com.example.MyApplication
12346 sun.tools.jps.Jps加 -l 参数可以显示完整的主类名,方便你快速定位目标进程。
jstack — 看线程在干什么
拿到PID后,用 jstack 生成线程快照。它能帮你排查死锁、线程阻塞、CPU飙高等问题:
$ jstack 12345 > /tmp/thread-dump.txt输出中你会看到每个线程的状态和调用栈。比如下面这段就说明线程在等待锁:
"http-nio-8080-exec-1" #15 daemon prio=5 os_prio=0 tid=0x00007f... nid=0x3a4c waiting for monitor entry [0x00007f...]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.Service.process(Service.java:42)
- waiting to lock <0x000000076b2a1234> (a java.lang.Object)实战技巧:如果CPU飙高,你可以先用 top -Hp <PID> 找到占CPU最高的线程ID,转成16进制后在jstack输出中搜索,直接定位是哪段代码在疯狂消耗CPU。
# 找到CPU最高的线程
$ top -Hp 12345
# 假设线程ID是12378,转16进制
$ printf "%x\n" 12378
305a
# 在jstack中搜索 nid=0x305a
$ jstack 12345 | grep -A 20 "nid=0x305a"jmap — 看堆内存
jmap 用来查看堆内存使用情况,或者手动触发一次堆dump:
# 查看堆内存概览
$ jmap -heap 12345
# 查看对象统计(按占用内存排序)
$ jmap -histo 12345 | head -20
# 手动dump堆内存(会造成短暂停顿,线上慎用)
$ jmap -dump:format=b,file=/tmp/heapdump.hprof 12345注意:在生产环境对大堆执行
jmap -dump可能造成几秒甚至几十秒的停顿。如果你已经配置了-XX:+HeapDumpOnOutOfMemoryError,通常不需要手动dump。
2.2 jstat:GC 监控
jstat 是GC监控的利器,可以实时查看GC的频率和耗时:
# 每秒采集一次GC数据,共采集10次
$ jstat -gcutil 12345 1000 10
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 45.23 67.89 34.56 95.12 92.34 156 2.345 3 0.789 3.134
0.00 45.23 72.11 34.56 95.12 92.34 156 2.345 3 0.789 3.134各列含义速查:
| 列名 | 含义 |
|---|---|
| S0/S1 | Survivor区0/1使用率 |
| E | Eden区使用率 |
| O | Old区使用率 |
| M | Metaspace使用率 |
| YGC/YGCT | YoungGC次数/总耗时 |
| FGC/FGCT | FullGC次数/总耗时 |
怎么看? 重点关注FGC和FGCT。如果FGC数字一直在涨,并且FGCT每次都很长(比如超过1秒),说明你的应用正在频繁FullGC,需要立即排查。
2.3 Arthas:线上利器
Arthas是阿里巴巴开源的Java诊断工具,堪称线上排查的"瑞士军刀"。它不需要重启应用,直接attach到运行中的JVM进程上。
# 启动Arthas
$ java -jar arthas-boot.jar
# 选择要attach的进程
[1]: 12345 com.example.MyApplication常用命令:
# 查看仪表盘(实时CPU、内存、GC等)
dashboard
# 反编译运行中的类
jad com.example.Service
# 监控方法调用耗时
trace com.example.Service process
# 查看方法的入参和返回值
watch com.example.Service process "{params, returnObj}" -x 2
# 查看JVM信息
jvm工具对比速查表:
| 工具 | 用途 | 是否需要安装 | 是否会影响性能 |
|---|---|---|---|
| jps | 查看Java进程 | JDK自带 | 无影响 |
| jstack | 线程快照 | JDK自带 | 极低 |
| jmap | 堆内存分析/dump | JDK自带 | dump时有停顿 |
| jstat | GC监控 | JDK自带 | 无影响 |
| jhat | 分析堆dump文件 | JDK自带 | 无(离线分析) |
| Arthas | 全方位诊断 | 需下载 | 低(按需使用) |
| VisualVM | 可视化监控 | 需下载 | 低 |
| MAT | 堆dump分析 | 需下载 | 无(离线分析) |
三、OOM 排查实战
OutOfMemoryError大概是Java开发者最怕看到的错误之一。但OOM并不是只有一种,不同类型的OOM原因完全不同,排查方式也不一样。
3.1 堆内存溢出
这是最常见的OOM,错误信息长这样:
java.lang.OutOfMemoryError: Java heap space常见原因:
- 内存泄漏:对象被创建后一直被引用着,无法被GC回收。比如往一个静态的List里不断塞数据。
- 堆太小:业务量增长了,但堆内存没跟着调大。
- 大对象:一次性加载了一个巨大的数据集到内存中。
排查步骤:
- 确保启动参数中有
-XX:+HeapDumpOnOutOfMemoryError,拿到dump文件。 - 用MAT(Eclipse Memory Analyzer)打开dump文件。
- 查看 "Leak Suspects" 报告,MAT会自动分析出可疑的内存泄漏点。
- 查看 "Dominator Tree",找到占内存最大的对象,顺着引用链往上追。
# 也可以用jmap查看哪些对象占内存最多
$ jmap -histo 12345 | head -20
num #instances #bytes class name
1: 2345678 112345678 [B
2: 1234567 56789012 java.lang.String
3: 567890 45678901 com.example.OrderDTO如果 com.example.OrderDTO 的实例数异常地多,那十有八九就是它的问题。
3.2 元空间溢出
java.lang.OutOfMemoryError: Metaspace常见原因:
- 动态生成大量类(如大量使用CGLib代理、反射、Groovy动态编译等)。
- 没有设置
-XX:MaxMetaspaceSize,在Java 8中元空间默认不受限,可能会把操作系统的内存吃光。
排查方式:
# 用jstat观察Metaspace使用率
$ jstat -gcutil 12345 1000
# 看M列(Metaspace使用率)是否持续增长
# 用jmap查看类加载统计
$ jmap -clstats 12345解决方案:设置合理的 -XX:MaxMetaspaceSize,同时排查是否有不合理的动态类生成。
3.3 栈溢出
java.lang.StackOverflowError常见原因:
- 递归调用没有正确的终止条件。
- 方法调用层级过深(罕见)。
这个相对好排查——StackOverflowError的堆栈信息里就有完整的调用链,直接看最后重复出现的那几行方法调用就知道是哪里出了问题。
// 典型的递归导致栈溢出
public int factorial(int n) {
return n * factorial(n - 1); // 忘了写 if (n <= 1) return 1;
}解决方案:修复递归逻辑;如果确实需要很深的调用栈,可以通过 -Xss 调大线程栈大小(但这是治标不治本)。
3.4 直接内存溢出
java.lang.OutOfMemoryError: Direct buffer memory常见原因:
- 大量使用NIO的DirectByteBuffer(如Netty框架)。
- 直接内存分配后没有及时释放。
排查方式:
# 查看直接内存使用情况(Arthas)
$ dashboard
# 关注 direct memory 那一行解决方案:通过 -XX:MaxDirectMemorySize 限制直接内存大小,排查是否有DirectByteBuffer的泄漏。
关于OOM和JVM退出的关系:很多人以为OOM了JVM就一定会挂。其实不是这样。JVM被设计成能够容忍单个线程的OOM——当一个线程抛出OOM时,JVM会尝试把问题限制在该线程内。也就是说,子线程OOM了,其他线程可以继续运行。只不过在实际场景中,OOM往往意味着整个系统已经不健康了,所以很多团队会配置
-XX:+ExitOnOutOfMemoryError或-XX:OnOutOfMemoryError="kill -9 %p"来主动重启。
四、FullGC 频繁怎么排查?
"FullGC频繁"是线上最常见的性能问题之一。那么,多频繁算"频繁"?先给一个经验参考值。
经验基准(4C8G机器,日常QPS 5000+):
- 日常:FullGC不超过一周一次
- 业务高峰:FullGC不超过2小时一次
- 单次FullGC耗时:400-700ms,不超过1秒
- YoungGC:100+次/分钟,单次耗时约20ms
- 堆内存使用率:维持在50%以下
如果你的应用FullGC频率明显超出上述范围,就需要排查了。下面是一个标准的排查流程:
具体排查步骤:
第一步:确认现状
$ jstat -gcutil <PID> 1000 10观察Old区(O列)使用率是否在持续增长。如果每次FullGC后Old区都能降下来,说明不是内存泄漏,可能是Young区太小导致对象过早晋升到Old区。
第二步:如果怀疑内存泄漏
# 先对比两次jmap -histo的结果
$ jmap -histo <PID> > /tmp/histo1.txt
# 等几分钟
$ jmap -histo <PID> > /tmp/histo2.txt
$ diff /tmp/histo1.txt /tmp/histo2.txt如果某个类的实例数和占用内存一直在涨,那它很可能就是泄漏点。
第三步:dump堆分析
$ jmap -dump:format=b,file=/tmp/heapdump.hprof <PID>用MAT打开,重点看 Leak Suspects 和 Dominator Tree。
第四步:常见的泄漏模式
- 静态集合类不断添加元素但不清理
- 数据库连接、IO流没有正确关闭
- 缓存没有设置上限和淘汰策略
- ThreadLocal使用后没有调用remove()
- 监听器注册后没有注销
五、JIT 编译优化
聊完了排查,我们来看看JVM为了让你的代码跑得更快,在背后做了哪些事情。
5.1 方法内联
JVM在运行时会发现,某些小方法被频繁调用,每次调用都要创建栈帧、压入参数、跳转执行、弹出栈帧——这些开销甚至比方法本身的逻辑还大。于是JIT编译器会把这些方法的代码直接"嵌入"到调用者中,省去调用开销。这就是方法内联。
// 内联前
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public void compute() {
int result = add(5, 3); // 每次调用都有栈帧开销
}
}
// JIT内联后(等价效果)
public class Calculator {
public void compute() {
int result = 5 + 3; // 直接计算,没有方法调用了
}
}内联的条件:
- 必须是热点代码(调用次数达到阈值,可通过
-XX:CompileThreshold调整)。 - 方法体不能太大——热点方法小于325字节、非热点方法小于35字节。
- 尽量用
private、static、final修饰方法,这样JVM可以直接内联。public/protected方法可能被子类覆盖,JVM需要额外的类型判断。
5.2 逃逸分析
逃逸分析不是优化手段本身,而是一种代码分析技术。它的核心问题是:一个对象是否会"逃出"创建它的方法?
如果一个对象只在方法内部使用,从不被外部引用,那它就"没逃逸"。JVM就可以对它做一系列优化:
优化一:栈上分配
正常情况下对象都分配在堆上,需要GC来回收。但如果对象没有逃逸,JVM可以直接在栈上分配它——方法结束,栈帧弹出,对象就自动回收了,完全不需要GC介入。
优化二:锁消除
如果一个对象只在单线程内使用,那它上面的 synchronized 就是多余的。JIT编译器会直接把锁去掉:
// 原始代码
public void process() {
Object lock = new Object();
synchronized(lock) { // lock对象不会逃逸
System.out.println(lock);
}
}
// JIT优化后(等价效果)
public void process() {
Object lock = new Object();
System.out.println(lock); // 锁被消除了
}优化三:标量替换
如果一个对象没有逃逸,并且可以被拆解,JVM不会真的创建这个对象,而是把它的成员变量拆开,直接分配在栈帧或寄存器上。
什么是标量? 不可再分解的量,比如
int、long等基本类型。与之对应的是聚合量,如Java对象。标量替换就是把聚合量拆成标量。
关于TLAB:补充一个相关知识。即使对象确实要分配在堆上,JVM也不是让所有线程抢着在堆上分配。它给每个线程分配了一小块专用区域,叫TLAB(Thread Local Allocation Buffer)。线程在自己的TLAB里分配对象时不需要加锁,大大提升了效率。
5.3 AOT 编译
传统的Java代码执行路径是:源代码 → javac编译成字节码 → JVM解释执行 → 热点代码被JIT编译成机器码。
这个过程有个明显的缺点:应用刚启动时,所有代码都要经过解释执行,性能较差。这就是为什么很多Java应用刚发布重启后会出现大量超时——JIT还没来得及优化热点代码。
AOT(Ahead-of-Time)编译走了一条不同的路:在编译期就把字节码直接翻译成机器码,省去了运行时的解释和JIT编译环节。
AOT的优势:
- 启动速度极快(毫秒级)
- 内存占用更小
- 不需要JVM运行时
- 非常适合云原生、Serverless场景
AOT的局限:
- 封闭性假设:所有运行时的内容必须在编译时可见。但Java中的反射、动态代理、序列化等特性天然违反这个假设,需要额外适配。
- 平台相关性:AOT编译后的产物是特定平台的机器码,Java引以为豪的"一次编写,到处运行"就不再成立了。
JIT预热问题的解决方案:
如果你暂时用不了AOT,但又被JIT预热问题困扰,有两个思路:
- 提前预热:应用启动后,通过负载均衡只给它分配少量流量,让JIT有时间编译热点代码。等预热完成后再逐步放大流量。
- 使用JwarmUp(Dragonwell JDK):记录上一次运行的编译信息,下次启动时直接复用,跳过解释阶段。
六、常见面试题精选
Q1:FullGC多久一次算正常?
没有绝对标准,取决于应用特性和硬件配置。经验值是:日常情况下FullGC不应超过一周一次。以一个QPS 5000+、100台4C8G机器的核心应用为例——日常一周不超过一次FullGC,高峰期最多2小时一次,单次耗时不超过1秒。如果远超这个频率,就要排查了。
Q2:Java发生了OOM一定会导致JVM退出吗?
不一定。JVM被设计成能隔离单个线程的问题。当一个线程发生OOM时,JVM会尝试将问题限制在该线程内,不影响其他线程。OOM、StackOverflowError这些异常都是可以被catch的,catch之后程序可以继续运行。
不过,有些配置会让JVM在OOM时主动退出,比如 -XX:OnOutOfMemoryError="kill -9 %p" 或 -XX:+ExitOnOutOfMemoryError。
Q3:内存泄漏和内存溢出有什么区别?
内存泄漏是指已经不再需要的对象没有被释放,它们的引用还在,GC无法回收。常见场景:未关闭的连接、不断增长的静态集合、ThreadLocal没有remove。
内存溢出是指程序需要分配内存但已经没有可用空间了,直接抛出OOM。
它们的关系是因果关系——内存泄漏积累到一定程度,最终会导致内存溢出。
Q4:什么情况会导致JVM退出?
- 所有非守护线程执行完毕(正常退出)
- 调用
System.exit()或Runtime.getRuntime().halt() - JVM遇到无法恢复的系统错误(如JVM自身的bug)
- 接收到终止信号(
kill -15触发优雅关闭,kill -9强制终止)
注意:kill -15(默认的kill命令)会触发JVM的ShutdownHook,允许应用做清理工作。kill -9 则完全不给机会,直接杀死进程。所以生产环境中,正常重启应该用 kill -15,只有在进程无响应时才用 kill -9。
Q5:JIT优化可能带来什么问题?怎么解决?
JIT优化是在运行期进行的,应用刚启动时还没有热点数据,所有代码都要走解释执行,性能较低。如果此时大量请求涌入,会导致CPU和Load飙高,出现大量超时。
解决方案:一是通过负载均衡做流量预热,先给小流量让JIT有时间编译;二是使用Dragonwell JDK的JwarmUp技术,复用上次的编译信息。
Q6:对JDK进程执行 kill -9 有什么影响?
kill -9 发送的是SIGKILL信号,进程会被强制终止,不会执行ShutdownHook。这意味着:
- RPC服务来不及从注册中心下线,其他服务会继续调用这个已经死掉的节点
- 正在执行的事务会被强制中断
- 临时文件和资源不会被清理
正确的做法是先用 kill -15(或不带参数的 kill),等进程优雅退出。如果等了一段时间进程仍然没退出,再考虑 kill -9。
小结
回到开篇的场景——凌晨3点收到告警,你现在应该知道怎么做了:
jps找到目标进程jstat -gcutil看GC情况,确认是不是FullGC频繁- 如果是FullGC问题,观察Old区回收情况判断是泄漏还是配置问题
- 如果是OOM,找到HeapDump文件(前提是你加了
-XX:+HeapDumpOnOutOfMemoryError),用MAT分析 - 如果是CPU飙高,
top -Hp+jstack定位热点线程 - 需要更深入的诊断,上Arthas
JVM调优不是玄学,而是一套有章可循的排查方法论。核心就是三个字:看、找、调——看监控数据定位方向,找到具体的问题代码,调整参数或修复代码。掌握了这套方法,下次再被凌晨的告警叫醒,你至少能淡定地喝口水再开始干活。