JVM
线上 Java 服务出问题时,最容易听到一句话:是不是 JVM 参数没调好?
有时候确实是参数问题,但更多时候不是。接口慢可能是线程池排队,下游超时可能把请求线程占满,内存涨可能是缓存没有边界,CPU 飙高可能是日志、正则、序列化、加密、锁竞争或频繁 GC。JVM 排障最忌讳一上来就改 -Xmx、换 GC、重启服务。先保住现场,再拿证据判断。
这篇按线上排障顺序来写:先划清 JDK 版本边界,再把 JVM 从类加载到执行、从对象分配到回收的主链路装进脑子里,看运行时数据区和 Java 内存模型,看内存结构、GC、参数基线,然后用 jcmd、jmap、jstack、JFR、MAT 把 OOM 和 CPU 飙高定位清楚,最后补齐容器内存、堆外内存、线程数、监控告警和容量评估。
本文命令以 Linux 和容器环境为主。开发机可以用 Windows 或 macOS 验证思路,但生产排障最好回到目标运行环境里执行。截至 2026-07,Oracle 官方当前最新 Java SE 是 26.0.1,最新 LTS 是 JDK 25;生产常见 LTS 仍包括 JDK 17、21、25。如果还在使用 JDK 8 或 11,要特别注意 GC 日志参数、默认 GC、容器感知和部分工具行为差异。
本文范围
这篇文章覆盖后端开发者必须能落地的 JVM 主线:JDK 版本差异、类加载与初始化、运行时数据区、Java 内存模型边界、对象分配、GC Roots、引用类型、安全点、类卸载、HotSpot 常见内存结构、G1/ZGC 选择、GC 日志、JVM 参数基线、线上诊断工具、OOM/CPU 排障、虚拟线程边界、容器内存和容量评估。
这篇不展开 JVM 源码逐行分析、JIT 编译器内部 IR、GC 源码实现、JVM 规范逐条翻译、JDK 构建流程,也不把部署运维里的集群发布、Kubernetes HPA、主机内核调优展开成独立章节。这里的目标是让你能看懂现象、拿到证据、做出生产上可回滚的判断。
前置条件
建议准备一台能运行 JDK 17+ 的 Linux 或容器环境,并安装完整 JDK 而不是精简 JRE,这样才能使用 jcmd、jmap、jstack、jstat、JFR 等工具。生产排障时需要确认当前用户有权限附加到目标 Java 进程,容器环境还要确认是否允许 ptrace、是否有可写日志目录、是否有足够磁盘保存 GC 日志和 heap dump。
最小验证命令如下:
java -version
javac -version
which jcmd jmap jstack jstat零、先按版本划清边界
场景说明:JVM 很容易被“经验”带偏。比如 JDK 8 的 GC 日志参数不能照搬到 JDK 17,PermGen 在现代 HotSpot 里已经不是调参对象,CMS 在新版本里也不是可选方案。排障前先问清版本,比背一堆参数更重要。
先用这几条命令拿到真实版本和运行参数:
java -version
jcmd "$PID" VM.version
jcmd "$PID" VM.command_line
jcmd "$PID" VM.flags常见生产版本可以按这张表建立第一印象:
| 版本 | 生产判断重点 | JVM/GC 关键变化 | 排障注意点 |
|---|---|---|---|
| JDK 8 | 老系统仍常见,升级成本高 | HotSpot 移除永久代,类元数据进入 Metaspace;GC 日志仍是老参数体系 | 关注 MaxMetaspaceSize、-Xloggc、PrintGCDetails、容器支持差异 |
| JDK 11 | 很多企业的过渡 LTS | G1 已是服务端默认选择;CMS 已废弃但未移除 | 迁移时重点看 GC 日志格式、模块化封装、非法反射告警 |
| JDK 17 | 目前常见稳定 LTS | 强封装更严格,JFR、NMT、统一日志更适合生产排障 | 老依赖、反射、字节码增强、代理框架要提前压测 |
| JDK 21 | 上一个 LTS,低延迟和虚拟线程生态更常见 | 分代 ZGC 可用;虚拟线程进入正式能力 | 线程排障要区分平台线程和虚拟线程,ZGC 参数要按版本校准 |
| JDK 25 | 2026-07 的最新 LTS | Oracle 下载页标记为最新 LTS | 新 LTS 上线要重点验证依赖兼容、GC 日志口径和镜像工具链 |
| JDK 26 | 2026-07 的最新 Java SE | 当前短期版本,适合跟踪新特性和文档口径 | 生产采用要看企业支持策略、镜像来源和回滚方案 |
版本演进可以先记成这条线:
验证结果:你应该能把事故服务归到明确的 JDK 版本、GC 版本口径和参数体系里。比如同样是“打开 GC 日志”,JDK 8 和 JDK 17 的参数不是一套;同样是“换低延迟 GC”,JDK 21、23、24 之后的 ZGC 语义也不能混着讲。
常见坑:
- 把“JDK 8 时代能跑”当成“JDK 17/21 一定兼容”。反射、非法访问、字节码增强、类加载隔离都可能在升级时暴露。
- 把 CMS 当现代调优方案。CMS 已在 JDK 14 移除,新版本里要回到 G1、ZGC、Shenandoah 或默认选择。
- 把
MaxPermSize写进现代启动脚本。JDK 8 起永久代已经移除,相关参数只会产生警告或被忽略。 - 看到短期版本最新就直接上生产。JVM 版本选择还要看 LTS、企业支持、基础镜像、依赖兼容和回滚链路。
生产建议:后端服务的发布单里必须记录 JDK 大版本、小版本、镜像 tag、GC 选择、核心 JVM 参数和诊断工具是否可用。JDK 升级应该单独建压测和灰度窗口,不要和业务大版本、数据库变更、线程池调整混在一次发布里。
一、先把 JVM 主链路装进脑子里
场景说明:线上排障时,你不需要先背完整虚拟机规范,但必须知道“证据落在哪一层”。OutOfMemoryError: Metaspace 不是堆问题,线程数暴涨不一定是业务对象泄漏,CPU 高也不一定是代码死循环。先把主链路打通,后面看命令输出才不会乱。
一条 Java 请求背后的 JVM 主线可以这样理解:
类加载本身不要只记“双亲委派”四个字,要把规范动作和工程动作分清:
直接看这张图时要注意几个边界:
- 加载阶段解决“字节从哪来”。它可能来自 jar、模块、网络、加密包、动态生成字节码,也可能来自插件框架自己的类加载器。
- 验证阶段解决“字节码能不能进 JVM”。非法字节码、版本不匹配、栈映射帧异常,通常会表现为
VerifyError、ClassFormatError或UnsupportedClassVersionError。 - 准备阶段不是执行 Java 赋值语句。
static int x = 10在准备阶段先是0,到初始化阶段执行<clinit>后才是10。 - 解析可以早做也可以懒做。线上看到
NoSuchMethodError、NoSuchFieldError、IncompatibleClassChangeError,经常是编译期和运行期依赖不一致。 - 类身份由“类全名 + 定义它的类加载器”共同决定。同名类被两个加载器定义,强转时可能出现看起来很怪的
ClassCastException。
直接做法:从一个最小程序验证这条链路。
javac -g Demo.java
javap -v -p Demo.class | sed -n '1,160p'
java -Xlog:class+load=info,class+init=info -cp . Demo线上进程可以看这些入口:
jcmd "$PID" VM.classloaders
jcmd "$PID" VM.metaspace basic
jcmd "$PID" GC.heap_info
jcmd "$PID" Compiler.codecache验证结果:
javap -v能看到常量池、字段、方法、字节码指令和 class 文件版本。class+load能看到类从哪里加载,适合排查重复依赖、插件类加载器和容器类路径问题。VM.classloaders、VM.metaspace能帮助判断类加载器数量和元空间占用。Compiler.codecache能看到 JIT 编译后的代码缓存是否紧张。
常见坑:
- 类加载不是“加载完就能用”。完整过程至少要经过加载、链接、初始化;链接又包含验证、准备和解析。静态变量在准备阶段先获得默认值,执行类初始化方法时才进入业务赋值。
- 类初始化有触发条件,例如
new、访问或设置静态字段、调用静态方法、反射、方法句柄等。只看源码顺序,很容易误判静态初始化副作用。 - JDK 9 之后常见类加载器口径是 bootstrap、platform、application;还用 JDK 8 的 Extension ClassLoader 去解释新 JDK,容易把问题说偏。
- 同一个类名被不同类加载器加载,在 JVM 里不是同一个类型。插件化、脚本引擎、热部署和应用服务器里,类加载器泄漏会直接拖高 Metaspace。
生产建议:遇到奇怪的 ClassNotFoundException、NoSuchMethodError、ClassCastException、Metaspace 增长或发布后行为不一致,不要只看 Maven 依赖树。把运行时类加载来源、类加载器层级、最终 JVM 参数和发布包内容一起比对。
二、运行时数据区:看到指标时要知道它落在哪块
场景说明:JVM 不是只有堆。一次线上内存事故,可能来自堆对象、元空间、直接内存、线程栈、代码缓存、JNI/native 分配、mmap 文件或容器页缓存。诊断时先把指标归位,后面才知道该抓 heap dump、线程栈、NMT 还是 JFR。
可以先按这个表建立地图:
| 区域 | 线程共享 | 常见证据 | 典型问题 |
|---|---|---|---|
| 程序计数器 | 否 | 一般不直接排查 | 当前线程执行位置 |
| Java 虚拟机栈 | 否 | jstack、Thread.print、-Xss、线程数 | 栈溢出、线程过多、递归过深 |
| 本地方法栈 | 否 | native 栈、JNI、hs_err | native 调用异常 |
| 堆 | 是 | GC.heap_info、heap dump、MAT | 堆泄漏、大对象、缓存无边界 |
| 方法区 / Metaspace | 是 | VM.metaspace、类加载器统计 | 动态类、类加载器泄漏 |
| 运行时常量池 | 是 | javap -v、类元数据 | 符号引用、动态链接 |
| 直接内存 | 进程级资源 | NMT、BufferPool、Netty 指标 | 堆外泄漏、容器 RSS 超限 |
| 代码缓存 | 是 | Compiler.codecache | JIT 编译代码缓存不足 |
运行时数据区可以画成下面这张排障地图:
每次方法调用都会创建栈帧。栈帧里最关键的东西是局部变量表、操作数栈、指向运行时常量池的动态链接和方法返回信息。局部变量表里可能放对象引用,线程栈里的这些引用也可能成为 GC Roots,所以一个看似“已经不用了”的大对象,可能因为线程局部变量、ThreadLocal 或还没返回的调用栈而活着。
对象布局也要有边界感。JVM 规范不强制规定对象在堆里的内部布局;在 HotSpot 常见实现里,对象大致由对象头、实例数据和对齐填充组成。对象头里包含 Mark Word、类型指针等信息,压缩普通对象指针和压缩类指针会影响对象大小。要看具体对象占用,优先用 JOL 这类专门工具,而不是靠“字段大小相加”拍脑袋。
用 JOL 把对象布局从猜测变成证据
场景说明:容量评估、缓存对象评审和大批量 DTO 改造时,经常会有人用“字段大小相加”估算内存。这个算法漏掉对象头、引用宽度、字段重排、对齐填充、数组头和对象图引用,很容易把真实占用低估一大截。JOL 的价值不是替你背一个固定对象头大小,而是在同一套 JDK、架构和 JVM 参数下,把对象布局打印出来。
直接做法:在压测或本地实验工程里加入 JOL,只对代表性对象做测量。
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
<scope>test</scope>
</dependency>import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.info.GraphLayout;
record Money(long cents, String currency) {}
public final class JolProbe {
static final class OrderLine {
long id;
int count;
boolean gifted;
Object ref;
}
public static void main(String[] args) {
OrderLine line = new OrderLine();
System.out.println(ClassLayout.parseClass(OrderLine.class).toPrintable());
System.out.println(ClassLayout.parseInstance(line).toPrintable());
System.out.println(GraphLayout.parseInstance(new Money(9900L, "CNY")).toFootprint());
}
}mvn -q -DskipTests package
mvn -q -DincludeScope=test dependency:build-classpath -Dmdep.outputFile=cp.txt
# 对比压缩指针开关对对象头、引用字段和实例大小的影响。
java -XX:+UseCompressedOops -XX:+UseCompressedClassPointers \
-cp "target/classes:$(cat cp.txt)" JolProbe
java -XX:-UseCompressedOops -XX:-UseCompressedClassPointers \
-cp "target/classes:$(cat cp.txt)" JolProbe验证结果:重点看对象头、字段偏移、padding、instance size 和 footprint。ClassLayout 看单个类或实例的布局,适合解释“为什么这个对象不是字段大小之和”;GraphLayout 看从根对象可达的对象图,适合估算缓存条目、批量消息、会话对象和反序列化结果的真实 footprint。
常见坑:
- 把 JOL 输出当 JVM 规范。它反映的是当前 HotSpot、当前架构、当前参数下的实现事实,换 JDK、换平台、换压缩指针配置都可能变。
- 只测对象本体,不测对象图。一个 DTO 本身很小,但里面的
String、数组、集合和嵌套对象可能才是大头。 - 在生产高峰期做会附加进程、依赖 Serviceability Agent 的重型实验。容量评估应该在同版本同参数的压测环境完成。
生产建议:给核心缓存对象、批量消息对象、会话对象建立一组 JOL 样例,并把结果写进容量评估。评审 Xmx、缓存条数和批处理大小时,至少用“单对象 footprint × 峰值对象数 × 安全系数”来估算,而不是只看业务字段。
对象从 new 到可用:不只是“放到堆里”
场景说明:很多内存和性能问题,根子都在对象分配路径上。接口一次返回几十 MB JSON、循环里创建临时集合、日志拼接大字符串、批量任务一次性构造大数组,都会把分配速率打高。GC 调优前先看对象怎么产生,比先改收集器更靠谱。
一个普通对象创建可以按这条链路理解:
直接做法:怀疑分配速率太高时,不要只看 used heap,还要看分配热点和对象生命周期。
# 短时间采样对象分配、锁、CPU 和 GC 事件
jcmd "$PID" JFR.start name=alloc settings=profile duration=120s filename=/var/log/myapp/app-alloc.jfr
# 看堆对象大类,适合快速判断方向
jcmd "$PID" GC.class_histogram | head -n 40
# 看 TLAB / 分配相关日志,先在压测环境验证
java -Xlog:gc+tlab=debug -jar app.jar验证结果:如果 JFR 里热点对象集中在 DTO、byte[]、char[]、String、JSON 节点、临时集合或日志参数,优先优化对象规模和创建频率。逃逸分析、标量替换、锁消除属于 JIT 优化结果,不要把它当成“所有小对象都不进堆”的承诺;对象一旦跨方法、跨线程、进入集合、被返回或被字段持有,就很可能逃逸。
常见坑:
- 把“栈上分配”当成生产调优手段。HotSpot 里更常见的是逃逸分析支撑标量替换,不是让你依赖对象一定分配到栈上。
- 看到 TLAB 就以为没有分配成本。TLAB 只是减少多线程同步分配开销,分配出来的对象仍然要被 GC 管理。
- 批量接口一次性构造大列表,再指望调大堆解决。堆越大只是事故来得晚一些,响应体、分页、流式处理和背压才是根因。
生产建议:把“分配速率”纳入压测报告。对于高吞吐接口,至少记录 QPS、P99、分配速率、Young GC 次数、Young GC 平均停顿、老年代回收后存活量。没有这些证据,不要宣称某个 JVM 参数“优化了性能”。
JVM 运行时数据区和 Java 内存模型不是一回事
很多文章会把“JVM 内存结构”和“Java 内存模型”混成一张图,这会误导排障。运行时数据区回答“对象、栈帧、类元数据放在哪里”,Java 内存模型回答“多线程读写共享变量时,什么结果是允许的,什么同步关系能建立可见性和顺序性”。
直接做法:排查并发可见性问题时,不要只看对象是否在堆里,要看读写之间有没有同步边。常见同步边包括同一线程内的程序次序、volatile 写先行发生于后续读、同一个 monitor 的 unlock 先行发生于后续 lock、Thread.start() 先行发生于被启动线程内动作、线程结束先行发生于其他线程成功 join() 返回。
验证结果:如果一个共享字段既不是 volatile,读写也不在同一把锁内,又没有通过并发容器、阻塞队列、Future、CompletableFuture 等同步工具传递,那么“我本地没复现”不能说明它没有可见性问题。JIT、CPU 缓存和编译器优化都可能让错误同步代码出现反直觉结果。
生产建议:JMM 的细节在多线程与 JUC 文章里会展开,但 JVM 篇至少要建立边界:内存占用问题看运行时数据区和 NMT,数据可见性问题看 happens-before,锁竞争和阻塞问题看线程栈、锁对象、JFR 和业务临界区。
GC Roots、安全点和类卸载:对象为什么还活着
场景说明:排查内存泄漏时,真正要问的不是“哪个类对象最多”,而是“谁把它从 GC Roots 上牵住了”。如果不知道 GC Roots、安全点和类卸载,MAT 的 Dominator Tree 很容易被看成对象排行榜。
常见 GC Roots 可以按这张表看:
| GC Roots 来源 | 典型例子 | 排障入口 |
|---|---|---|
| 线程栈中的引用 | 局部变量、方法参数、未返回调用链 | Thread.print、heap dump 的 Thread Overview |
| 静态字段 | 全局缓存、单例、注册表、工具类静态集合 | MAT Path To GC Roots、代码搜索 |
| JNI/native 引用 | native 方法、直接内存包装对象 | NMT、hs_err、JFR native 事件 |
| 活跃类元数据 | Class 对象、类加载器、运行时常量池引用 | VM.classloaders、VM.metaspace |
| 同步锁相关对象 | 正在持有 monitor 的对象 | jcmd Thread.print -l、JFR lock 事件 |
引用强度也会影响对象生命周期:
| 引用类型 | 回收语义 | 常见坑 |
|---|---|---|
| 强引用 | 只要可达就不会被回收 | 无界缓存、静态集合、队列积压 |
| 软引用 | 内存紧张时才倾向回收 | 拿软引用做缓存,容易在压力下抖动 |
| 弱引用 | 下次 GC 发现弱可达即可回收 | ThreadLocalMap 的 key 弱引用不代表 value 自动清理 |
| 虚引用 | 主要用于回收通知和资源跟踪 | 必须配合 ReferenceQueue,不能用来取对象 |
安全点可以理解成“JVM 能让所有线程停到可观测位置”的地方。GC、偏向/锁相关操作、类卸载、代码反优化、线程栈采样等都可能需要线程到达安全点。线上看到业务停顿,不要只看 GC;也要看 safepoint 日志。
# JDK 9+ 可以把 safepoint 打进日志
-Xlog:gc*,safepoint:file=/var/log/myapp/gc.log:time,uptime,level,tags:filecount=10,filesize=100m
# 看类加载器和元空间,判断类是否可能卸不掉
jcmd "$PID" VM.classloaders
jcmd "$PID" VM.metaspace basic类卸载要满足一个很朴素的条件:加载它的类加载器也要不可达。插件框架、脚本引擎、热部署、动态代理、反射缓存、线程上下文类加载器、静态字段持有业务对象,都可能让类加载器活着,从而让 Metaspace 持续增长。
生产建议:Metaspace 增长不要只调 MaxMetaspaceSize。先看类加载器数量、动态类生成速率、热部署生命周期、线程上下文类加载器是否清理、线程池是否持有旧应用类。修复后要验证 class loaded/unloaded、Metaspace used/committed、Full GC 后 Metaspace 是否回落。
直接做法:
jcmd "$PID" GC.heap_info
jcmd "$PID" GC.class_histogram | head -n 30
jcmd "$PID" VM.metaspace basic
jcmd "$PID" VM.native_memory summary scale=MB
jcmd "$PID" Compiler.codecache
cat /proc/"$PID"/status | grep -E 'VmRSS|VmHWM|Threads'验证结果:
- 堆对象多,优先看 heap dump、类直方图、GC 回收后老年代是否下降。
- Metaspace 高,优先看类加载器、动态代理、脚本、热加载、插件生命周期。
- RSS 高但 heap 不高,优先看直接内存、线程栈、代码缓存、native 分配和 mmap。
- 线程数高,
-Xss乘以线程数就是一块不能忽略的内存预算。
生产建议:给 JVM 做容量评估时,要写成 容器内存 = heap + metaspace + direct + thread stacks + code cache + native + 系统余量。只按 -Xmx 评估,是容器时代最常见的误判之一。
三、先确认你排查的是哪个 JVM
场景说明:线上一台机器经常不止一个 Java 进程。排障第一步不是抓 dump,而是确认进程、启动参数、JDK 版本和运行边界。如果连 PID 都找错,后面的证据全是噪声。
直接做法:
ps -ef | grep java | grep -v grep
PID=$(pgrep -f 'app.jar' | head -n 1)
echo "$PID"
java -version
jcmd "$PID" VM.version
jcmd "$PID" VM.command_line
jcmd "$PID" VM.flags
jcmd "$PID" VM.system_properties | grep -E 'java.version|java.home|user.timezone|file.encoding'
ps -o pid,ppid,%cpu,%mem,rss,vsz,nlwp,cmd -p "$PID"jcmd 是现代 JDK 里非常实用的诊断入口,能看启动命令、JVM flags、线程、堆、类直方图、JFR 等信息。生产镜像如果只带 JRE 或精简运行时,可能没有这些工具,所以核心服务的排障镜像、宿主机工具包或旁路诊断方案要提前准备。
验证结果:
VM.command_line能看到真实启动命令。VM.flags能看到最终生效的 JVM 参数,不只看脚本里写了什么。nlwp是进程线程数,后面判断线程膨胀会用到。rss是进程实际驻留内存,容器 OOM 时不能只看 Java heap。
常见坑:
pgrep -f app.jar可能命中部署脚本、备份进程或旧进程,关键环境要用端口、启动目录、进程树一起确认。- 容器里看到的 PID 可能是命名空间内 PID;宿主机上抓诊断要对应宿主机 PID。
jcmd、jmap、jstack通常要求同用户或具备相应权限,容器还可能受到ptrace、只读文件系统、安全策略限制。- 不要把脚本里的
JAVA_OPTS当作最终生效参数,最终以jcmd VM.flags和启动命令为准。
生产建议:为每个 Java 服务保留启动参数快照。发布单里至少记录 JDK 版本、镜像 tag、JVM 参数、容器 CPU/内存限制、端口、健康检查和回滚版本。排障时先比对“故障版本”和“上一稳定版本”的这些差异。
四、把 JVM 内存结构分成几块看
场景说明:线上看到“内存高”时,很多人只看 -Xmx。但 Java 进程的 RSS 不只包含堆,还包括元空间、线程栈、直接内存、代码缓存、JIT、JNI/native 分配、mmap 文件和 GC 自身开销。容器里最常见的事故,就是 -Xmx 看起来没满,但进程 RSS 已经顶到 cgroup 限制。
先用这几条命令拆开看:
jcmd "$PID" GC.heap_info
jcmd "$PID" GC.class_histogram | head -n 30
jcmd "$PID" VM.metaspace basic
jcmd "$PID" Compiler.codecache
jcmd "$PID" VM.native_memory summary scale=MB
cat /proc/"$PID"/status | grep -E 'VmRSS|VmHWM|Threads'
pmap -x "$PID" | tail -n 1这里要注意,VM.native_memory 需要进程启动时打开 Native Memory Tracking:
-XX:NativeMemoryTracking=summary如果要看更细,可以用:
-XX:NativeMemoryTracking=detaildetail 信息更多,也有额外开销。生产上一般先用 summary,只有遇到堆外或 native 泄漏疑点时再提高粒度。
把这些证据合在一起时,先用下面这张图做 RSS 预算拆分。它的用途不是精确记账,而是帮你决定下一步抓什么证据:heap 高就抓 dump,direct 高就看 BufferPool 和 Netty,thread 高就看线程栈和 -Xss,RSS 高但 NMT 解释不了时再看 pmap、mmap、第三方 native 库和容器事件。
排障时不要把 VM.native_memory summary 当成 RSS 的完整替代品。NMT 更适合拆 JVM 自身的 native 分类;进程 RSS、系统 OOM killer、容器 working set 和 pmap 才能告诉你“这个进程实际顶到了哪条限制线”。容量评估也要按上图留系统余量,不要让 -Xmx 独占容器预算。
验证结果:
GC.heap_info看 Java 堆的使用情况。class_histogram看对象数量和类型,适合快速判断大对象方向。VM.metaspace能看到元空间保留、提交和使用情况。Compiler.codecache能看到 JIT 编译代码缓存是否接近上限。/proc/$PID/status的Threads能看到线程数,线程数越多,线程栈和调度成本越高。VmRSS接近容器限制时,即使堆没满,也可能被系统 OOM killer 终止。
常见坑:
- Java 8 之后没有永久代,类元数据主要进入 Metaspace;还在说“调大 PermGen”通常是过时经验。
- 直接内存、Netty buffer、文件 mmap、JNI 分配不在 Java 堆里,
jmap -heap看不全。 - 每个线程都有栈空间,
-Xss和线程数相乘后会变成真实成本。 -XX:MaxMetaspaceSize不设上限时,元空间会按需增长;设置太小又容易触发OutOfMemoryError: Metaspace。
生产建议:不要把容器内存全留给堆。一个常见起点是让最大堆占容器内存的 60% 到 75%,剩余给 Metaspace、直接内存、线程栈、代码缓存和系统开销。高并发 Netty、文件处理、压缩、加密或大量线程的服务,要把堆比例再压低,并显式评估 -XX:MaxDirectMemorySize、-Xss 和线程池上限。
五、给生产服务一套 JVM 参数基线
场景说明:参数基线不是为了“炫技”,而是为了出事时能留下证据、能控制资源边界、能回滚。没有 GC 日志、没有 heap dump、没有 hs_err 文件,线上 OOM 后往往只剩一句“服务没了”。
JDK 17+ 可以先从这套基线开始,再按服务类型调整:
JAVA_OPTS="
-Xms2g
-Xmx2g
-XX:MaxMetaspaceSize=256m
-Xss512k
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/myapp/heapdump
-XX:ErrorFile=/var/log/myapp/hs_err_pid%p.log
-Xlog:gc*,safepoint:file=/var/log/myapp/gc.log:time,uptime,level,tags:filecount=10,filesize=100m
"
java $JAVA_OPTS -jar app.jar如果运行在容器里,而且希望 JVM 根据容器限制计算堆,可以用百分比参数:
JAVA_OPTS="
-XX:InitialRAMPercentage=50.0
-XX:MaxRAMPercentage=70.0
-XX:MaxMetaspaceSize=256m
-Xss512k
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/myapp/heapdump
-Xlog:gc*,safepoint:file=/var/log/myapp/gc.log:time,uptime,level,tags:filecount=10,filesize=100m
"如果容器 CPU 限制、JVM 识别和业务线程池计算不一致,可以显式指定:
-XX:ActiveProcessorCount=2验证结果:
jcmd "$PID" VM.flags | grep -E 'MaxHeapSize|MaxRAMPercentage|InitialRAMPercentage|MaxMetaspaceSize|ThreadStackSize|Use.*GC'
ls -lh /var/log/myapp/你应该能看到堆大小、Metaspace 上限、线程栈大小、GC 选择和 GC 日志文件。目录权限要提前验证,OOM 时 JVM 如果写不进去,配置等于没有。
常见坑:
- JDK 8 的 GC 日志参数不是
-Xlog。老版本通常用-Xloggc、-XX:+PrintGCDetails、-XX:+PrintGCDateStamps和日志轮转参数,迁移 JDK 时必须重新校准。 -Xms和-Xmx固定相等能减少堆伸缩抖动,但也会让启动后内存占用更稳定地顶上去;小容器或多实例环境要结合密度评估。HeapDumpPath指向容器内临时目录,容器重启后可能丢失现场。- GC 日志文件不轮转,会把磁盘打满;轮转太小,又会覆盖事故窗口。
生产建议:参数变更必须能灰度、能对比、能回滚。不要一次同时改 GC、堆大小、线程池和容器资源,否则出了问题无法判断是哪一项导致的。推荐一次只改一类变量,并在发布单里写清楚预期指标变化。
六、GC 不要先背算法,先看现象
场景说明:接口慢、P99 抖动、CPU 飙高、内存锯齿,都可能和 GC 有关,也可能完全不是。判断 GC 问题要看三件事:停顿时间是否影响业务,回收后内存是否下降,老年代或活跃数据是否持续增长。
先看当前 GC 和堆:
jcmd "$PID" VM.flags | grep -E 'Use.*GC|MaxGCPauseMillis|InitiatingHeapOccupancyPercent'
jcmd "$PID" GC.heap_info
jstat -gcutil "$PID" 1000 10再看 GC 日志:
tail -n 200 /var/log/myapp/gc.log
grep -E 'Pause|Full|Concurrent|OutOfMemory|Humongous|to-space exhausted' /var/log/myapp/gc.log | tail -n 100JDK 9+ 的统一日志可以按标签读。下面是一段简化后的 G1 日志样例,重点不是背格式,而是知道每行回答什么问题:
[12.345s][info][gc,start] GC(42) Pause Young (Normal) (G1 Evacuation Pause)
[12.346s][info][gc,heap ] GC(42) Eden regions: 128->0(96)
[12.346s][info][gc,heap ] GC(42) Survivor regions: 12->18(18)
[12.346s][info][gc,heap ] GC(42) Old regions: 420->438
[12.346s][info][gc,heap ] GC(42) Humongous regions: 8->8
[12.352s][info][gc ] GC(42) Pause Young (Normal) (G1 Evacuation Pause) 960M->520M(2048M) 7.123ms这几行要这样读:
GC(42)是一次 GC 事件编号,用它把 start、heap、phases、safepoint 相关日志串起来。Pause Young说明这是年轻代疏散停顿,不等于 Full GC。960M->520M(2048M)表示本次回收前、回收后、总堆容量。关键看“回收后”是否持续上涨。Eden 128->0说明 Eden 被清空;Survivor 12->18说明存活对象进入 Survivor;Old 420->438说明有对象晋升到老年代。Humongous 8->8说明大对象 region 没减少。如果这个值长期高,要查超大数组、超大字符串、一次性大响应和文件读入内存。
如果看到下面这种关键词,要提高优先级:
| GC 日志关键词 | 常见含义 | 下一步 |
|---|---|---|
to-space exhausted / to-space overflow | G1 疏散时目标空间不足,存活对象或晋升压力过大 | 查老年代、Survivor、humongous、堆是否太满 |
Full GC | 进入重型回收或回退路径 | 立刻对齐业务慢窗口、heap dump、发布变更 |
Concurrent Cycle 连续触发 | 并发标记追不上分配或触发阈值过晚 | 看分配速率、IHOP、CPU 余量和活跃数据 |
Allocation Stall | 应用线程等 GC 腾空间 | 低延迟收集器也可能撑不住分配速率 |
Metadata GC Threshold | Metaspace 触发 GC | 查类加载器、动态类、代理和热部署 |
排障时可以按这个方向判断:
验证结果:
- Young GC 频繁但停顿短,不一定是事故。
- Full GC 频繁、停顿长、回收后老年代仍不下降,更像泄漏或长期缓存失控。
- CPU 高同时 GC 日志密集,可能是分配速率过高或堆压力过大。
- 慢请求时间线和 GC 停顿时间线不重合时,不要硬把锅甩给 GC。
常见坑:
- 只看一次 Full GC 就下结论。GC 要结合时间线、业务高峰、发布变更和堆趋势看。
- 把 CMS 参数带到新 JDK。CMS 已在 JDK 14 移除,现代 JDK 不应该再依赖 CMS。
- 看到
MaxGCPauseMillis就以为可以精确控制停顿。它是目标,不是保证。 - 只看堆,不看分配速率。接口批量返回大对象、日志打印大 JSON、频繁创建临时集合,都可能把 GC 压力打上去。
生产建议:普通后端服务优先从默认 GC 和清晰日志开始,不要过早追求“调优参数大全”。大多数服务先把对象生命周期、缓存边界、批处理大小、响应体大小和线程池隔离治理好,收益比改 GC 参数更稳定。只有证据指向 GC 停顿或堆布局问题时,再进入 GC 参数调优。
G1 和 ZGC 的选择要按目标看:
- G1 把堆切成 region,年轻代、老年代和 humongous 对象都以 region 形式管理。它是多数现代服务的默认选择,
MaxGCPauseMillis是停顿目标,不是实时保证。 - ZGC 面向低延迟,主要重活尽量并发完成,适合对停顿特别敏感且能接受更多并发开销的服务。JDK 24 之后 ZGC 已是分代模式,老的
ZGenerational选项已经移除;JDK 21 使用分代 ZGC 时才需要额外关注-XX:+ZGenerational。 - 不要把“更低停顿”理解成“更少内存”。低延迟收集器通常更需要给堆和 CPU 留余量,否则会出现 allocation stall、并发周期追不上分配速率等问题。
G1 可以先按 region 模型理解。
官方文档里,G1 的 heap layout 原图先看这个。这里保留官方原图,是为了让你知道 Oracle 文档强调的重点:G1 不是把年轻代、老年代做成连续大块,而是把堆拆成一组等大小 region,再给 region 赋予 Eden、Survivor、Old、Humongous 等角色。

下面这张是本文重绘后的排障视角:官方图告诉你 region 长什么样,重绘图把 region 类型和日志、现象、下一步动作接起来。
再看 G1 收集周期。Oracle 原图把 Young-Only phase、Concurrent Start、Remark、Cleanup、Space-Reclamation 和 Mixed collection 放在同一条环上,适合用来理解“为什么不是每次 GC 都处理老年代”。

本文重绘时把这条环翻译成线上判读顺序:先看 Young GC 是否只是正常疏散,再看是否进入 Concurrent Start,然后盯 Remark、Cleanup 和 Mixed collection 是否密集,最后判断是否要回到分配速率、存活对象和 IHOP。
ZGC 则先按目标理解:它追求极低停顿,把标记、整理和重定位的大部分工作并发化。代价是需要 CPU 和堆余量支撑并发 GC 追上业务分配速率。JDK 21 的分代 ZGC、JDK 23 默认分代、JDK 24 移除非分代模式,是同一条演进线,不要拿旧文章里的 -XX:+ZGenerational 当现代固定模板。
GC 调优不要从参数开始,要从证据链开始:
调参时可以按这张表处理:
| 证据 | 优先动作 | 参数动作 |
|---|---|---|
| Young GC 很频繁但停顿短 | 查分配速率、批处理大小、日志大对象、JSON 序列化 | 必要时增大堆或降低批量峰值 |
| 老年代回收后不下降 | 查缓存、集合、队列、ThreadLocal、类加载器 | 先修引用链,别先加 -Xmx |
| G1 humongous region 很多 | 查超大数组、超大字符串、批量响应、文件读入内存 | 必要时调 G1HeapRegionSize,但先减小对象 |
| 并发标记追不上分配 | 查流量尖峰、CPU 余量、对象生命周期 | 评估 InitiatingHeapOccupancyPercent、堆大小和并发线程 |
| 低延迟目标压不住 | 评估业务是否真的需要亚毫秒级停顿 | 再考虑 ZGC,并给 CPU/内存留余量 |
生产调参优先顺序:先确认对象分配和存活规模,再确认容器内存与 CPU 余量,最后才调 MaxGCPauseMillis、InitiatingHeapOccupancyPercent、region 相关行为或切换收集器。
七、jmap、jstack、MAT 怎么配合使用
场景说明:怀疑内存泄漏时,top 和监控只能告诉你“内存涨了”,不能告诉你谁持有对象。怀疑线程阻塞时,接口日志只能告诉你“慢了”,不能告诉你线程卡在哪里。这个时候需要 heap dump、类直方图和线程栈。
先抓轻量信息:
jcmd "$PID" GC.class_histogram > class-histogram.txt
jcmd "$PID" Thread.print -l > thread-dump-1.txt
sleep 10
jcmd "$PID" Thread.print -l > thread-dump-2.txt
sleep 10
jcmd "$PID" Thread.print -l > thread-dump-3.txt如果必须导出堆:
jcmd "$PID" GC.heap_dump /var/log/myapp/heapdump/app-$(date +%Y%m%d%H%M%S).hprof也可以使用 jmap:
jmap -dump:format=b,file=/var/log/myapp/heapdump/app.hprof "$PID"
jmap -histo:live "$PID" | head -n 40jstack 的基本用法:
jstack -l "$PID" > thread-dump.txt如果已经在持续录 JFR,可以在怀疑泄漏时导出一份带 GC Roots 路径的记录。这个动作可能让应用短暂停顿,生产上要避开峰值窗口:
jcmd "$PID" JFR.check
jcmd "$PID" JFR.dump name=profile filename=/var/log/myapp/leak.jfr path-to-gc-roots=true拿到 .jfr 后,优先用 JDK Mission Control 看内存、方法采样、锁、线程、GC 和异常事件。没有图形环境时,先把 JFR 文件拉到分析机;不要为了打开 JMC 在生产机器上装桌面环境。短采样排障关注“事件是否覆盖故障窗口”,长期常开录制关注磁盘轮转、权限和脱敏。
拿到 .hprof 后,用 Eclipse MAT 离线分析。建议按这个顺序看:
- Leak Suspects:先看 MAT 自动推断的可疑泄漏点。
- Histogram:看对象数量和浅堆大小,定位大类。
- Dominator Tree:看谁支配了大块内存。
- Path To GC Roots:看对象为什么没被回收。
- Thread Overview:如果 dump 里包含线程信息,看线程局部变量、ThreadLocal、队列任务是否持有大对象。
验证结果:真正的泄漏不是“某个类对象多”,而是“对象持续增长,并且有稳定引用链导致 GC 回收不掉”。MAT 里要看 Retained Heap 和 GC Roots,不能只盯 Shallow Heap。
常见坑:
- Heap dump 可能非常大,也可能让进程暂停。高峰期直接 dump 可能雪上加霜。
- dump 里可能包含用户数据、token、请求参数和业务敏感信息,不能随意外传。
jmap -histo:live会触发一次 Full GC,生产高峰慎用。- 只抓一次线程栈容易误判。线上阻塞、死锁、慢下游要连续抓 3 次以上,看同一批线程是否卡在同一位置。
生产建议:为核心服务提前准备 dump 目录、磁盘容量、权限和清理策略。发生 OOM 时让 JVM 自动生成 heap dump,但要配套脱敏、访问控制和保留周期。分析 dump 尽量在离线环境完成,不要在生产机器上打开 MAT。
八、OOM 按错误类型拆,不要只说“内存溢出”
场景说明:OutOfMemoryError 有很多类型,每种排查入口不同。把所有 OOM 都当成堆不够,会导致堆越调越大,容器越容易被杀。
先收集现场:
grep -i "OutOfMemoryError" /var/log/myapp/app.log | tail -n 50
ls -lh /var/log/myapp/heapdump
dmesg -T | grep -E 'Killed process|Out of memory|oom-killer' | tail -n 20
cat /proc/"$PID"/status | grep -E 'VmRSS|VmHWM|Threads'
jcmd "$PID" GC.heap_info
jcmd "$PID" VM.native_memory summary scale=MB如果在 Kubernetes 里,还要看容器是否被系统杀掉:
kubectl describe pod app-xxx | grep -A8 -E 'Last State|OOMKilled|Exit Code'
kubectl top pod app-xxx --containers常见 OOM 类型可以这样分:
| 现象 | 常见原因 | 先看什么 |
|---|---|---|
Java heap space | 堆对象增长、缓存无边界、大查询、大响应体 | heap dump、Histogram、Dominator Tree |
GC overhead limit exceeded | GC 大量耗时但回收很少 | GC 日志、老年代趋势、对象引用链 |
Requested array size exceeds VM limit | 一次性创建超大数组或集合扩容超过虚拟机限制 | 异常栈、入参大小、分页大小、响应体大小 |
Metaspace | 动态类、类加载器泄漏、插件热加载 | class loading 指标、Metaspace、类加载器 |
Direct buffer memory | NIO/Netty/文件处理直接内存失控 | NMT、BufferPool 指标、MaxDirectMemorySize |
unable to create native thread | 线程数过多、ulimit、PID 限制、栈过大 | Threads、ulimit -u、/proc/$PID/limits |
容器 OOMKilled | RSS 超过 cgroup 限制,未必是堆满 | 容器事件、RSS、堆外、线程数、cgroup 限制 |
更实用的做法是先按错误入口分流,再决定要不要扩大堆。下面这张决策树可以直接放进应急手册:它把“JVM 自己抛的 OOM”和“容器把进程杀掉”拆开,避免一看到内存事故就盲目调大 -Xmx。
验证结果:修复后不是只看“没有再 OOM”,还要看根因指标是否下降。比如缓存条目数是否有上限,线程数是否稳定,直接内存是否可控,老年代是否能回落,容器 RSS 是否远离限制线。
常见坑:
- 看到
Requested array size exceeds VM limit就只调堆。它通常是单次数组或集合尺寸不合理,先查分页、批量导出、聚合结果和响应体。 - 看到容器
OOMKilled就去调大-Xmx。如果 RSS 已经超限,调大堆可能更快触发 OOMKilled。 - 只保留一份 dump,下一次 OOM 覆盖上一份。
HeapDumpPath目录要支持多文件命名或按发布版本归档。 - OOM 后自动重启,但没有把 OOM 原因、dump 路径和容器事件打进告警。
生产建议:核心服务要同时监控 heap used、non-heap、direct buffer、thread count、process RSS、container memory、GC pause、Full GC、OOM count。只监控堆使用率是不够的。
九、CPU 飙高:从热点线程反推代码位置
场景说明:CPU 飙高时,不要先猜死循环。先把“哪个线程在烧 CPU”找出来,再把操作系统线程 ID 对到 Java 线程栈。
直接做法:
top -Hp "$PID"
pidstat -t -p "$PID" 1 5记下 CPU 高的线程 ID,比如十进制线程 ID 是 12345,转成十六进制:
printf "%x\n" 12345抓线程栈:
jcmd "$PID" Thread.print -l > thread-cpu.txt
grep -n "nid=0x3039" -A40 -B5 thread-cpu.txt如果问题持续,可以用 JFR 做一段短采样:
jcmd "$PID" JFR.start name=cpu settings=profile duration=120s filename=/var/log/myapp/app-cpu.jfr线程状态要先翻译成排障语言:
| Java 线程状态 | 常见现场 | 判断方式 |
|---|---|---|
RUNNABLE | 正在运行、可运行、native 调用或系统调用 | 结合 top -Hp 看是否真的烧 CPU |
BLOCKED | 等 monitor 锁 | 看 Thread.print -l 里的 blocked on 和持锁线程 |
WAITING | 等 LockSupport.park、Object.wait、Future.get、队列 | 看等待对象、线程名前缀和业务调用链 |
TIMED_WAITING | sleep、带超时等待、连接池等待、定时任务 | 看是否大量堆积在同一依赖或队列 |
NEW / TERMINATED | 一般不是线上主要状态 | 更多看线程创建速率和生命周期 |
JDK 21+ 如果使用虚拟线程,还要多看一层:
# 传统线程视角,适合平台线程和锁排查
jcmd "$PID" Thread.print -l > thread-platform.txt
# 虚拟线程很多时,优先导出结构化线程 dump
jcmd "$PID" Thread.dump_to_file -format=json /var/log/myapp/thread-vt.json虚拟线程排障的关键是区分“任务很多”和“承载线程被占住”。JDK 21 的虚拟线程已经正式可用,但如果虚拟线程在 synchronized 或 native/VM 帧里阻塞,可能把 carrier 平台线程一起占住;JDK 24 的 JEP 491 已经让 synchronized 里的阻塞大多可以释放 carrier,但 native/VM 帧、类初始化等待等剩余 pinning 仍要用 JFR 和结构化 dump 观察。
虚拟线程不是“更快的线程”。它适合大量等待型任务,尤其是网络 I/O、数据库 I/O、RPC 等阻塞等待;CPU 密集型任务不会因为换成虚拟线程就变快。虚拟线程也不应该像平台线程那样池化,限制下游并发要用连接池、信号量、限流器,而不是固定大小虚拟线程池。
常见坑:
- 看到虚拟线程数量很大就恐慌。虚拟线程数量和 OS 线程数量不是一回事,OS 只能看到承载它们的 carrier/platform threads。
- 把虚拟线程当成数据库连接池的替代品。虚拟线程能让等待成本降低,但不能让数据库承受无限并发。
- 用
ThreadLocal在虚拟线程里缓存昂贵对象。虚拟线程通常短生命周期、数量巨大,每个线程一份资源可能把内存打爆。 - 只用传统平铺 thread dump 看虚拟线程。虚拟线程很多时要用结构化 dump,并配合 JFR 看阻塞、锁、I/O 和 pinned 相关事件。
验证结果:
- 热点线程栈停在业务循环、JSON 序列化、正则、加解密、日志格式化、集合遍历,通常是业务 CPU。
- 热点线程是 GC 线程,同时 GC 日志密集,可能是 GC 压力。
- 大量线程 BLOCKED 在同一把锁,更像锁竞争。
- 大量线程 WAITING/TIMED_WAITING 在连接池、Future、队列或下游调用,要继续看线程池和依赖耗时。
常见坑:
- Java 线程状态为 RUNNABLE 不等于一定在消耗 CPU,可能在 native 调用或系统调用里。
- 只看应用线程,不看 GC、JIT、日志异步线程、Netty event loop、ForkJoinPool。
- CPU 高峰过了才抓现场,只能看到恢复后的状态。高风险服务要提前准备自动采样或应急脚本。
生产建议:CPU 排障要把线程栈、接口时间线、GC 日志、发布记录、下游耗时、日志量和机器负载放在一起看。修复后至少验证 CPU 水位、上下文切换、接口 P99、错误率和线程池队列是否同时恢复。
十、JIT 和代码缓存:CPU 高不一定都在业务代码
场景说明:Java 服务启动后,解释执行、C1/C2 分层编译、热点探测、方法内联、逃逸分析等都会影响性能曲线。服务刚启动时 CPU 高、延迟抖动,不一定是业务代码变差,也可能是预热不足或 JIT 编译活动密集。运行很久后如果代码缓存打满,热点代码可能无法继续编译,性能也会突然变差。
先看 JIT 和代码缓存状态:
jcmd "$PID" Compiler.codecache
jcmd "$PID" Compiler.codelist | head -n 40
jcmd "$PID" VM.flags | grep -E 'TieredCompilation|ReservedCodeCacheSize|InitialCodeCacheSize|CICompilerCount'可以用这张热路径图判断 CPU 高是不是只该回到业务代码。启动或流量切入早期,解释执行、分层编译和代码缓存写入会带来预热成本;稳定运行后,如果 code cache 接近上限,新的热点方法进不去编译产物区,也会让延迟和 CPU 曲线变差。
如果需要更完整的 CPU 画像,优先用 JFR 短采样:
jcmd "$PID" JFR.start name=profile settings=profile duration=120s filename=/var/log/myapp/app-profile.jfr启动期或压测环境可以临时打开编译日志:
java -XX:+PrintCompilation -jar app.jar验证结果:
- 热点线程是
C1 CompilerThread、C2 CompilerThread,并且服务刚启动或流量刚切入,可能是预热和分层编译。 Compiler.codecache显示代码缓存接近满,可能出现编译受限,延迟曲线会变差。- JFR 里热点集中在业务方法、序列化、正则、加密、日志格式化,要回到代码和数据规模排查。
常见坑:
- 为了“立刻稳定”盲目关 JIT 或使用
-Xint。这会让服务长期吞吐显著下降,通常只适合极短时间验证问题归因。 - 把启动预热期指标当成稳定期容量。Java 服务要区分冷启动、预热、稳定流量和流量突增。
- 看到 CPU 高只抓一次线程栈,不看 JFR、GC 和编译线程,容易漏掉 JIT、GC 或 native 热点。
生产建议:延迟敏感服务要设计预热流量、发布后观察窗口和 JFR 应急采样脚本。代码缓存、编译线程、GC 线程和业务线程都要放进 CPU 排障视野,不要只盯应用线程。
十一、落地工程深水区
这一节只讲真实生产里容易把 JVM 方案拖垮的部分。
1. 容器内存不是堆内存
容器限制是 JVM 进程和系统开销的总预算。生产上不要这样配:
container memory = 2Gi
-Xmx = 2g更稳的起点是:
container memory = 2Gi
heap max = 1200m ~ 1500m
reserved = metaspace + direct memory + thread stacks + code cache + native + filesystem/cache上线前验证:
jcmd "$PID" GC.heap_info
cat /proc/"$PID"/status | grep -E 'VmRSS|VmHWM|Threads'
jcmd "$PID" VM.native_memory summary scale=MB如果容器 RSS 长期超过限制的 85%,不要只调堆,先拆堆外、线程、Metaspace 和直接内存。
2. GC 日志和 heap dump 要提前设计
GC 日志要轮转,dump 目录要可写、容量足够、权限受控。一次 4G 堆的 dump 文件通常接近数 GB,磁盘只剩 2G 时,OOM 现场可能写不完整。
检查:
df -h /var/log/myapp
test -w /var/log/myapp/heapdump && echo ok
ls -lh /var/log/myapp/gc.log*生产建议:dump 是事故证据,也是敏感数据。要限制下载权限,分析后按保留策略清理。
3. 线程数要按内存和调度成本算账
线程不是免费的。线程越多,线程栈越多,上下文切换越多,排障越难。看到 unable to create native thread 时,不要只怪系统限制,先看是不是线程池无边界、定时任务泄漏或每个请求都创建线程。
检查:
cat /proc/"$PID"/status | grep Threads
cat /proc/"$PID"/limits | grep -E 'processes|open files|stack'
ps -L -p "$PID" | wc -l生产建议:每个业务线程池都要有名字、上限、队列上限和拒绝策略。线程数告警不要只看总数,还要按线程名前缀分组。
4. 容量评估看活跃数据,不看拍脑袋堆大小
初始容量可以用压测和 GC 日志估算。稳定流量下观察 Full GC 或老年代回收后的存活对象规模,再结合峰值流量、对象生命周期和安全余量估算堆。
判断方式:
jstat -gcutil "$PID" 1000 30
grep -E 'Pause Full|Old|Humongous' /var/log/myapp/gc.log | tail -n 100如果回收后老年代仍持续升高,先怀疑对象生命周期和缓存边界;如果回收后下降明显但 GC 很频繁,才考虑堆大小、分配速率和批处理拆分。
5. 监控告警要覆盖 JVM 与业务边界
最低监控项:
- JVM:heap、non-heap、Metaspace、direct buffer、GC pause、Full GC、thread count、class loaded。
- 进程:RSS、CPU、文件句柄、上下文切换、打开连接数。
- 容器:memory working set、OOMKilled、CPU throttling、重启次数。
- 业务:QPS、P95/P99、错误率、下游耗时、线程池队列、拒绝次数。
生产建议:JVM 指标只能解释进程状态,不能替代业务指标。一次稳定性事故要能串起“用户慢请求 -> 应用线程 -> JVM 状态 -> 下游依赖 -> 变更记录”。
十二、故障排查速查
| 现象 | 第一组命令 | 重点判断 |
|---|---|---|
| 接口 P99 突增 | jstat -gcutil $PID 1000 10、jcmd $PID Thread.print -l | GC 停顿是否覆盖慢请求窗口,线程是否卡在下游或锁 |
| 堆内存持续上涨 | jcmd $PID GC.class_histogram、heap dump + MAT | 对象是否持续增长,Retained Heap 和 GC Roots 是什么 |
| 容器被 OOMKilled | kubectl describe pod、cat /proc/$PID/status、NMT | RSS 是否超限,堆外和线程是否失控 |
| CPU 100% | top -Hp $PID、printf "%x\n" tid、Thread.print | 热点线程栈属于业务、GC、锁竞争还是 native |
| 启动后 CPU/延迟抖动 | Compiler.codecache、JFR、线程栈 | 是否 JIT 预热、编译线程密集或代码缓存紧张 |
| 线程数暴涨 | /proc/$PID/status、jcmd Thread.print | 哪类线程名前缀增长,是否线程池无界或每请求建线程 |
| Full GC 频繁 | GC 日志、jstat、heap dump | 回收后老年代是否下降,是否缓存/大对象/类加载器泄漏 |
| Direct Memory OOM | NMT、BufferPool 指标、Netty 指标 | 是否有直接内存上限,ByteBuf 是否释放,文件处理是否限流 |
十三、上线检查清单
- JDK 版本、镜像版本、启动命令和 JVM 参数已经记录。
- GC 日志已打开并轮转,日志目录有容量监控。
- OOM heap dump、hs_err 文件路径可写,保留和脱敏策略明确。
- 容器内存大于 heap + native + direct + metaspace + threads 的总预算。
- JIT 预热、代码缓存和 JFR 应急采样方案已经演练过。
- 线程池有名字、上限、队列上限、拒绝策略和指标。
- 监控覆盖 JVM、进程、容器、业务和下游依赖。
- 变更 JVM 参数时有灰度、对比指标和回滚命令。
- 线上排障脚本已经演练过,确认当前运行镜像能执行
jcmd或有替代诊断方式。
十四、常用命令模板
# 找 Java 进程
ps -ef | grep java | grep -v grep
# 看最终 JVM 参数
jcmd "$PID" VM.command_line
jcmd "$PID" VM.flags
jcmd "$PID" VM.classloaders
# 看堆、类直方图、线程栈
jcmd "$PID" GC.heap_info
jcmd "$PID" VM.metaspace basic
jcmd "$PID" VM.native_memory summary scale=MB
jcmd "$PID" Compiler.codecache
jcmd "$PID" GC.class_histogram > class-histogram.txt
jcmd "$PID" Thread.print -l > thread-dump.txt
# 导出 heap dump
jcmd "$PID" GC.heap_dump /var/log/myapp/heapdump/app.hprof
# 看 GC 概况
jstat -gcutil "$PID" 1000 10
# 查 CPU 热点线程
top -Hp "$PID"
printf "%x\n" "$TID"
jcmd "$PID" Thread.print -l | grep -A40 -B5 "nid=0x$HEX_TID"
# 看进程 RSS 和线程数
cat /proc/"$PID"/status | grep -E 'VmRSS|VmHWM|Threads'官方文档入口
- Oracle Java Downloads:当前 JDK 与 LTS 下载
- Oracle Java SE Support Roadmap
- Oracle Java 26
java命令与 JVM 参数 - Oracle Java 26
jcmd - Oracle Java 26
jmap - Oracle Java 26
jstack - Oracle Java 26
jstat - Oracle JFR Diagnostic Command Reference
- Java SE 25 JVM 规范:ClassFile 格式
- Java SE 25 JVM 规范:加载、链接和初始化
- Java SE 25 JVM 规范:运行时数据区和栈帧
- Java SE 25 JLS:Threads and Locks / Java Memory Model
- Java SE 25
ClassLoaderAPI - Oracle Java 21 Virtual Threads
- Oracle Java 26 Virtual Threads
- Oracle Java 25 可用垃圾收集器
- Oracle Java 25 G1 GC 调优指南
- Oracle Java 26 G1 GC 调优建议
- Oracle Java 26 ZGC 调优指南
- Oracle JDK Mission Control
- OpenJDK Code Tools:JOL
- OpenJDK JEP 122:移除永久代
- OpenJDK JEP 248:G1 成为默认垃圾收集器
- OpenJDK JEP 271:统一 GC 日志
- OpenJDK JEP 363:移除 CMS GC
- OpenJDK JEP 439:Generational ZGC
- OpenJDK JEP 474:ZGC 默认使用分代模式
- OpenJDK JEP 490:移除 ZGC 非分代模式
- OpenJDK JEP 444:Virtual Threads
- OpenJDK JEP 491:Synchronize Virtual Threads without Pinning
- Eclipse Memory Analyzer
