第 08 章(节选) 排错元方法与异常栈读法
本篇来自《Go 程序员的 Java 修炼之路》第 08 章「第 08 章 排错方法论:从异常栈到根因(一套能复用的 SOP)」。 返回:第 08 章索引
08.1 排错的元方法:为什么”猜”是低效的
先说一件可能冒犯你的事:你过去五年在 Go 里练出来的排错习惯,搬到 Java 里会让你崩溃,而且你不会立刻意识到是习惯的问题。
Go 里你排查一个 bug 的标准动作是什么?大概是:看日志 → 猜一个可能 → 改一行 → go run → 三秒钟后看到结果 → 不对就再猜一次。这套”快速试错”在 Go 里是最优解,因为它的反馈周期是 3 秒。
现在把这套动作搬到 Java:改一行 → mvn compile(30 秒)→ 重启 Spring Boot(40 秒)→ 等 JIT 预热(1 分钟)→ 造一次请求(10 秒)→ 看结果。一轮两分钟起步。
两分钟一轮,你试 20 次,40 分钟过去了,你还不知道根因在哪。更要命的不是慢 —— 而是重启这个动作本身会制造伪结果。
问题 1: 你改了一个配置,重启,问题消失了。这能证明你的改动是修复吗?
不能。重启顺带做了这些事:清空了本地缓存、重建了数据库连接池、重置了所有静态状态、重新做了一次类加载、让 JVM 回到解释执行状态(所以慢的问题也”消失”了)。你引入了一个巨大的混淆变量,却以为自己做了一次受控实验。
这就是新手和科学家的区别,也是本节标题里”猜”这个字的含义:
- 新手:改一个地方,看好不好。不好就再改一个地方。这是在解空间里随机游走。
- 科学家:先观察现象,提出一个可以被证伪的假设,设计一个实验去验证它,根据结果修正假设。这是在解空间里做二分查找。
假设一个服务有 10 个可能的故障点。随机游走平均要试 5 次(运气差要 10 次),每次 2 分钟;二分查找只要 3~4 次,而且每次验证都能排除掉一半的可能性 —— 更重要的是,排除掉的一半是有证据支撑的排除,不是”我猜不是这里”。
三条元原则
原则一:先定位,后修复。
没找到根因就改代码,等于在制造新 bug。这句话听着像废话,但它是生产事故二次扩大的头号原因 —— 第一次故障是 bug 造成的,第二次故障是”你以为修好了但实际没修好 + 你的改动引入了新问题”造成的。
判据:你能不能用一句话说清楚”第几行的什么东西,在什么条件下,变成了什么状态,导致了什么后果”?说不清楚,就没定位完。
原则二:二分法 / 最小化复现。
删掉一半代码(一半配置、一半中间件、一半请求特征),问题还在吗?在,说明根因在剩下那一半;不在,说明在你删掉的那一半。这个动作可以递归做下去,log₂(1000) ≈ 10 次就能从一千个嫌疑点收敛到一个。
在 Java 里,“二分”不一定体现在删代码上,也可以体现在:
- 只保留一个 Bean、一个自动配置类(
@SpringBootApplication(exclude = ...)) - 把流量从 100% 灰度到 1%,看问题是否还出现
- 把请求参数逐个删掉,看哪个参数触发了问题
- 把
-Dspring.profiles.active里的配置逐项关掉
原则三:相信证据,不相信直觉。
“我觉得不可能是这里” —— 往往就是这里。人的直觉是基于过往经验训练出来的模式匹配,而你面对的是一个前任写的、跑了三年的、你刚接手两天的系统。你的经验分布和这个系统的真实分布根本不匹配。
我见过太多这样的对话:
“不可能是数据库,监控显示 DB 很闲。” —— 然后查了三天,最后发现是连接池里的 20 个连接全被一个慢查询占着,从 DB 侧看确实”很闲”。
直觉的作用应该是排优先级,不是下结论。 你的直觉说”八成是数据库”,那你就先看数据库 —— 但你要拿证据来证明或否掉它,不能拿直觉当证据。
【思考】为什么”我改了一下重启试试”在 Java 里特别低效?(提示:不要只说”因为慢”,想想重启这个动作本身对”实验结论的可靠性”有什么影响。)
你先想三十秒。这一节的答案会直接改变你对 Java 项目的日常动作序列。
参考答案
直接答案:因为它同时犯了两个错 —— 反馈周期太长,以及引入了无法剥离的混淆变量。
第一层:反馈周期。
Go 的试错循环:go run 编译 + 启动通常在 1~5 秒(小项目),大型项目十几秒。你可以把一个想法在 30 秒内验证 3 次。
Java Spring Boot 的试错循环:
mvn -q compile # 增量编译,冷启动 20~60s,增量 3~15s
mvn spring-boot:run # 启动:JVM 自身 1~2s + classpath 扫描 + Bean 装配,通常 20~60s
curl localhost:8080/... # 造请求
# 一轮 ≈ 1~3 分钟差了 20~60 倍。反馈周期从 3 秒变成 3 分钟,人的行为模式会发生质变:3 秒的周期里你会大胆试;3 分钟的周期里你会攒一批改动一起试 —— 而”攒一批”恰恰违反了单变量原则,于是你更不知道是哪个改动起的作用。
第二层(更本质):重启不是”干净的实验”,它是一次状态重置。
看一眼重启都会干掉什么:
// 这些状态会在重启中被清空,任何一个都可能是"看起来好了"的真实原因
static Map<String, Object> localCache = new HashMap<>(); // 本地缓存(泄漏源)
HikariDataSource pool; // 连接池(泄漏的连接被回收)
ThreadLocal<UserContext> ctx; // 脏的线程局部变量
volatile boolean circuitOpen = true; // 打开的熔断器被重置于是产生一个致命的混淆:问题消失可能是因为你的改动,也可能是因为内存被清空了。 如果你的根因是内存泄漏,那么任何一次重启都会让它”好起来”,然后几小时后复发 —— 你会得到一个错误的结论”上次那个改动是对的”。
第三层:JIT 让”慢”这类问题在重启后彻底观测不到。
刚启动的 JVM 是解释执行的,跑够次数(C2 默认 10000 次调用)才编译成本地代码。所以重启后的头几分钟,你的服务本来就慢,而 JIT 预热完成后又不慢了。你在这个窗口里观测到的任何性能数据都是噪声。
这带来一条硬性纪律:压测或复现性能问题前必须预热,否则你测的是解释器。
第四层:Java 生态为什么会为这件事专门造工具。
因为所有人都痛。所以有了:
- Spring DevTools:只重启你改动的那部分(用两个 ClassLoader 隔离”不变的基础类”和”你改的业务类”),把重启时间从 40s 压到 3~5s
- JRebel(商业):直接热替换方法体,连 3 秒都不用
- Testcontainers + 本地集成测试:把”依赖能不能起来”从”重启服务才知道”变成”跑一个测试就知道”
- Arthas 的
watch/trace:直接在运行中的 JVM 上看数据,连一次重启都不需要
看到没有?这整条工具链的驱动力,就是”重启太贵”。
更深一层:这是”反馈周期决定方法论”的一个标本。
Go 程序员养成的”快速试错”习惯,不是因为 Go 程序员更聪明或者更浮躁,而是因为Go 的反馈周期允许这么做。你的方法论是被工具链的反馈周期塑造出来的,你只是没意识到。
所以真正的教训不是”你要更有耐心”,而是:换一个反馈周期差一个数量级的环境,你必须主动换一套方法论。 在 Java 里,这套方法论的名字叫”先假设,后验证”—— 因为验证一次要 3 分钟,你输不起随机游走。
顺便说一句:这条规律反过来也成立。等你读完这本书回去写 Go,你会发现自己开始习惯”先想清楚再动手”,因为 Java 已经把”猜测的成本”刻进你的肌肉记忆了。这是学第二门语言最实际的收益之一。
一个通用的排错决策树
这是本章的核心资产。把它抄下来贴在显示器上。 它的价值不在于告诉你”用哪个命令”,而在于强制你在动手前先给现象分类——分类本身就是提出假设的第一步。
服务有问题
│
┌──────────────┬──────────────┼──────────────┬──────────────┐
│ │ │ │ │
完全不可用 部分请求失败 整体/局部变慢 偶发时好时坏 数据不对但没报错
│ │ │ │ │
进程还在吗? 有异常栈吗? 线程在干什么? 有周期性吗? 事务边界对吗?
端口通不通? 什么异常类型? CPU 高不高? GC 有长停顿? 缓存脏了吗?
被 OOM kill? 栈最下面那行 GC 频繁吗? 外部依赖抖动? 并发覆盖了吗?
磁盘满了吗? 是哪一行? 外部依赖慢吗? 只有个别机器? 走错分支了吗?
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
先看进程和日志 → 08.2 → 08.4 场景一/二 → 08.4 场景四 → 查 DB 与缓存
尾部,再看 按异常表找 三连 jstack JFR 常开录制 对比缓存和库
dmesg 里的 第一反应, top -Hp 换算 async-profiler 里的数据
oom-kill 记录 定位到类+行 交叉验证 火焰图看热点
这张图怎么用?三步。
- 分类:现象属于五类中的哪一类?(这一步 30 秒,但能砍掉 80% 的搜索空间)
- 提假设:这一类下面通常有 3~5 个候选根因,按你的直觉排个序
- 做验证:按验证成本从低到高依次排除。注意是”验证成本”,不是”可能性排序”
第三步是大部分人做错的地方。他们会先去验证”最可能的”,而不是”最容易验证的”。但排错的目标是尽快缩小解空间,所以应该先做 30 秒能出结果的检查(看一眼监控、grep 一下日志),把便宜的可能性批量清掉,再去做贵的操作(堆转储、压测复现)。
一张反馈周期对照表
| 维度 | Go | Java(Spring Boot) |
|---|---|---|
| 改一行到看到结果 | 1~5 秒(go run) | 1~3 分钟(编译 + 启动 + 预热) |
| 试错 20 次的代价 | 1 分钟 | 40~60 分钟 |
| 热替换 | 无(不需要) | DevTools / JRebel / Arthas retransform |
| 重启会清掉什么 | 全部进程状态(但你无所谓,因为起得快) | 缓存、连接池、静态状态、JIT profile |
| 因此形成的排错风格 | 改了就试(试错驱动) | 先想清楚再动手(假设驱动) |
| 对”能复现”的依赖 | 低(复现不了也能靠快速试错逼近) | 高(复现不了基本就瞎了) |
08.2 异常栈的正确读法
先说结论:异常栈是 Java 给你的一份免费的、自动生成的、精确到行的现场报告。 在 Go 里你要靠 pkg/errors 手动 wrap 每一层才能勉强拿到类似的东西(第 00 章讲过这个差异:Go 的 error 默认不带栈,Java 的异常默认带栈)。
所以你接手的那个 Java 服务,哪怕它一行日志都没打好,光靠异常栈你也能定位到具体行号。这是你的巨大优势,但也带来一个错觉:以为”有栈”就等于”能看懂栈”。
栈的结构
一个完整的异常栈长这样(这是真实结构,不是示意):
java.lang.IllegalStateException: Failed to load ApplicationContext ← ① 最外层异常
at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(...)
at org.springframework.test.context.support.DefaultTestContext.getApplicationContext(...)
... ← 中间全是框架
Caused by: org.springframework.beans.factory.BeanCreationException: ← ② 中间层 cause
Error creating bean with name 'orderService'
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(...)
... 37 more ← ④ 省略的公共栈帧
Caused by: java.lang.NullPointerException ← ③ 最内层 cause(根因)
at com.example.order.OrderServiceImpl.init(OrderServiceImpl.java:47) ← ⑤ 你自己的代码
... 12 more
五个组成部分:
- 异常类型 + message:
IllegalStateException: Failed to load ApplicationContext - 栈帧序列:最上面一行是抛出点(这是最容易搞反的地方 —— 调用顺序是自下而上,但抛出点在最上面)
Caused by链:一层层剥下去,每一层都是”上一层的直接原因”... 37 more:表示”下面还有 37 行,但跟上一段栈帧重复了,省略”。要看完整内容,往上翻一段Suppressed异常:try-with-resources里close()失败的异常会被附加到主异常上(见下)
四个关键技巧
技巧一:先看最后一个 Caused by。
根因往往在最底下。上面的例子里,最外层是”加载 ApplicationContext 失败”(这是废话,它只告诉你结果),真正的原因是 OrderServiceImpl.java:47 的 NPE。
但有个重要例外:有些异常链是被框架”包装”过的,最底下那层反而是噪声。判断方法是看最底下那一行是不是你认识的包名。如果最底下是 sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method),那说明真正的业务栈被反射切断了,你要往上找。
技巧二:从上往下找第一行你自己的包名。
框架的包(org.springframework.*、org.apache.*、sun.*、com.sun.*、com.fasterxml.*、net.sf.*、io.netty.*)全部跳过 —— 那不是你的问题,除非是配置错误。
怎么区分”框架的 bug”和”你的配置错误”?一个简单的判据:如果这个异常只在特定请求/特定数据下出现,是你传进去的东西不对;如果启动就失败或者所有请求都失败,是配置不对。
技巧三:... 23 more 不是”被截断的日志”。
很多人以为是日志框架把栈截断打印了,其实不是 —— 是 JVM 自己算出来”下面这 23 行跟刚才那段栈帧一模一样”,就省略了。完整信息一直都在,只是分散在上下两段里。 展开的方法:找到紧邻的上一段栈,把它的底部 23 行接上去。
技巧四:Suppressed 异常只出现在 try-with-resources 里。
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
return stmt.executeQuery(sql); // 这里抛了 SQLException(主异常)
} // 自动 close(),如果 close() 也抛异常 → 被塞进主异常的 getSuppressed()打印时它会显示成 Suppressed: java.sql.SQLException: ...。这个异常经常被忽略,但它往往才是最要命的那个 —— 比如主异常是”查询失败”,而 suppressed 异常是”连接归还池子时失败了”,后者才是连接池泄漏的真凶。
常见异常类型 → 第一反应对照表
这张表按”我看到这个异常,脑子里第一个念头应该是什么”来写。不是解释含义,是给你条件反射。
| 异常 / Error | 第一反应 |
|---|---|
NullPointerException | 找哪个对象是 null。如果日志里没有栈,先想是不是 -XX:-OmitStackTraceInFastThrow 没开 |
ClassCastException(两个类名一模一样) | 跨 ClassLoader 传对象了(Tomcat 多应用 / 热部署 / 插件) |
ClassCastException(类名不同) | 泛型擦除导致强转失败,或者反序列化到了错误类型 |
NoSuchMethodError | 依赖冲突,99%。别查代码,去查 classpath(sc -d 看类从哪个 jar 来) |
NoSuchMethodException | 反射调用方法名/参数类型写错(注意 int.class 和 Integer.class 不同) |
NoClassDefFoundError | 编译时有、运行时没有。往上翻日志开头,找第一次的 ExceptionInInitializerError(类初始化失败过一次,之后全变成这个) |
ClassNotFoundException | 依赖没打进包 / ClassLoader 找不到(反射写类名时是全限定名) |
UnsupportedClassVersionError | 用高版本 JDK 编译,跑在低版本 JRE 上。看 major version 数字 |
ExceptionInInitializerError | 静态代码块或静态字段初始化抛异常了。真正的异常在它的 Caused by 里 |
ConcurrentModificationException | 遍历中修改集合。注意:多线程下也可能报这个,不只是 for-each 里 remove |
IllegalStateException | 框架在告诉你”你的用法/时机不对”(最常见:response 已经提交、连接已关闭、Bean 未初始化完) |
IllegalArgumentException | 参数不合法。看 message,框架通常会写清楚哪个参数 |
IllegalMonitorStateException | wait()/notify() 没在 synchronized 块里调用 |
RejectedExecutionException | 线程池满了(队列满 + 最大线程数到顶)。去看拒绝策略和队列容量配置 |
TimeoutException(CompletableFuture) | 异步任务超时。看是 get(timeout) 超时还是业务逻辑真慢 |
InterruptedException | 必须恢复中断状态:Thread.currentThread().interrupt()。吞掉它会导致线程池无法关闭 |
OutOfMemoryError: Java heap space | 内存泄漏 或 -Xmx 太小。看 jstat 老年代 Full GC 后降不降 |
OutOfMemoryError: unable to create new native thread | 线程数超限,不是内存问题。查 ulimit -u、线程池配置、-Xss |
OutOfMemoryError: Metaspace | 类加载器泄漏 / 动态类生成过多(CGLIB、Groovy、反射)。查 jcmd VM.classloader_stats |
OutOfMemoryError: Direct buffer memory | 堆外内存没释放。Netty 优先怀疑;查 -XX:MaxDirectMemorySize |
OutOfMemoryError: GC overhead limit exceeded | JVM 的自我保护:“我 98% 的时间在 GC 但只回收了 2% 的堆”。本质是内存不够,不是 GC 参数问题 |
StackOverflowError | 递归没出口 / Bean 循环依赖 / JSON 序列化循环引用(最常见) |
SocketTimeoutException: Read timed out | 对方连上了但没在超时时间内返回。调 readTimeout 或查下游为什么慢 |
SocketTimeoutException: connect timed out | 连都连不上(网络不通、端口没开、防火墙、下游线程池满了不接受连接)。跟 read timeout 是完全不同的两个问题 |
ConnectException: Connection refused | 端口没进程监听(服务挂了 / 端口写错了) |
IOException: Broken pipe / Connection reset by peer | 客户端先断了,通常无害。只有在大量出现时才要查(可能是你的响应太慢) |
IOException: Too many open files | 文件描述符耗尽。查 ulimit -n 和连接/文件是否泄漏 |
SQLException: Connection is not available, request timed out after 30000ms | HikariCP 连接池耗尽。要么慢 SQL 占着连接,要么连接泄漏没还 |
NoUniqueBeanDefinitionException | Spring 容器里同一个接口有多个实现且没有 @Primary。这是好事,启动失败是最仁慈的失败 |
BeanCreationException | 一路往下翻 Caused by,根因几乎从不在这一层 |
HttpMessageNotReadableException | 请求体反序列化失败(JSON 格式错 / 字段类型不匹配) |
怎么用这张表:看到异常 → 查表 → 得到”第一反应”(一个动作或一个怀疑方向)→ 去执行那个动作。不要在异常类型上停留超过 10 秒,异常类型只是索引,不是答案。
【思考】为什么
NoSuchMethodError编译期发现不了?(呼应第 00 章和第 07 章:编译期用版本 A,运行时 classpath 是版本 B。)想清楚这个问题的第二层:为什么
NoSuchMethodError是 Error 而不是 Exception?
参考答案
直接答案:因为 Java 的编译单元和部署单元不是同一个东西。
第一层:编译期和运行期看到的是两套 classpath。
// 编译时:你的 IDE / Maven 把 jackson-databind 2.15 放进 -classpath
// 编译器看到 ObjectMapper 有这个方法,通过
objectMapper.readValue(json, Order.class);
// 运行时:fat jar 里被 Maven 仲裁塞进去的是 2.9
// 2.9 的 ObjectMapper 没有这个重载 → NoSuchMethodError编译期是静态链接(按签名解析),运行期是动态链接(按全限定名 + 描述符在 classpath 里查)。两套 classpath 可以完全不同,而且 Java 没有任何机制保证它们一致。
对比 Go:go.mod 里的版本是唯一的,编译期和运行时用的是同一份代码(静态链接进二进制),所以 Go 世界里根本不存在 NoSuchMethodError 这个错误类别。
第二层:JVM 的类寻址键里没有版本号。
JVM 里一个类的唯一标识是 (ClassLoader, 全限定名) —— 注意,没有版本。
// 两个 jar 里都有这个类,JVM 眼里它们是"同一个类"
com.fasterxml.jackson.databind.ObjectMapper ← from jackson-databind-2.9.jar
com.fasterxml.jackson.databind.ObjectMapper ← from jackson-databind-2.15.jar
// ClassLoader 按 classpath 顺序找到第一个就加载,另一个被永久屏蔽(shadowed)所以不是”两个版本共存导致混乱”,而是只有一个版本能被加载,但你不知道是哪一个。7 章讲的工具(mvn dependency:tree -Dverbose、Arthas 的 sc -d)本质都是在回答同一个问题:这个类的字节码到底从哪个 jar 里读出来的?
第三层:为什么它是 Error 而不是 Exception。
这一层很多人没想过,但它揭示了 JVM 的设计意图。
// java.lang 的继承链
Throwable
├── Error ← 程序"不应该"捕获,通常代表 JVM 层面无法恢复的故障
│ └── LinkageError
│ └── NoSuchMethodError
└── Exception ← 程序"应该"处理的、可预期的问题NoSuchMethodError 挂在 LinkageError 下面。Linkage 就是”链接”,也就是把符号引用解析成实际方法的过程。
方法调用在 JVM 里分两类解析时机:
- 静态绑定(
private、static、final、构造器):编译期就确定了,字节码里直接写死 - 动态绑定(虚方法
invokevirtual/ 接口方法invokeinterface):运行期按描述符(方法名 + 参数类型 + 返回类型)在类的方法表里查
// 编译进字节码的是这条常量池记录,它只有名字和描述符,没有"来自哪个 jar"
// invokestatic #12 // com/fasterxml/jackson/databind/ObjectMapper.readValue:(Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object;运行期在这个类的方法表里找不到匹配描述符的方法 → 链接失败 → 抛 LinkageError。
为什么设计成 Error?因为 JVM 认为这是”部署错误”,不是”程序错误”。 程序的逻辑没问题,是把程序放到了一个它不该在的 classpath 上。这种错误代码层面无法自愈(你 catch 住了也没用,那个方法就是不存在的),所以 JVM 明确告诉你:别处理,去修你的部署。
更深一层:这就是”把冲突推迟到运行时”的账单。
第 00 章讲过,Go 把依赖冲突在编译期解决(MVS + 静态链接),Java 把它推迟到运行时(classpath 寻址)。NoSuchMethodError 就是这张账单的具体金额:
- Go 的代价:不灵活。想让两个库用不同版本的依赖?做不到(除非自己也静态链接一份)
- Java 的代价:灵活,但你需要一整套工具(Maven 仲裁、Shade、ClassLoader 隔离、
sc -d)来在运行时恢复”这个类从哪来”这个本来应该在编译期就确定的信息
这不是”Java 设计错了”,是一笔有意识的交易。 你要做的不是抱怨它,而是把 sc -d 练成肌肉记忆——它就是用来把运行时信息反向还原成编译期信息的那把钥匙。
栈信息丢失的坑:-XX:-OmitStackTraceInFastThrow
这个坑在 00 章提过一次,这里必须讲透,因为它是**生产环境真实高发、且极容易被误判为”日志框架坏了”**的问题。
现象:服务跑了三天,日志里开始出现这样的东西:
java.lang.NullPointerException
就一行。没有栈,没有行号,什么都没有。 运维第一反应是”Logback 配置错了""异步日志把栈吃掉了""磁盘满了截断写入”。
真相:这是 JVM 的 JIT 优化。HotSpot 对几个高频抛出的隐式异常(implicit exception)做了特殊处理:
NullPointerExceptionArithmeticException(除零)ArrayIndexOutOfBoundsExceptionArrayStoreExceptionClassCastException
当这些异常在同一个位置被抛出很多次以后,JIT 会把 fillInStackTrace() 优化掉 —— 直接复用一个预先构造好的、没有栈的异常实例。
为什么要这么做? 因为 fillInStackTrace() 是 native 方法,要遍历整个调用栈并构造 StackTraceElement[],成本是微秒级。一个每秒抛一万次 NPE 的热点路径,光填栈就能吃掉几个百分点的 CPU。JVM 的判断是:你已经抛了几千次了,栈长什么样你早该知道了。
坑在哪? 三处:
- 时间相关性:前几千次有栈,之后没栈。所以你事故刚发生时看到的日志有栈,等你回头翻历史日志时全没栈了
- 误导排查方向:你会去查日志框架、查磁盘、查异步队列,而真正的原因是 JVM 优化
- 重启后”自动修复”:重启 → JIT 重新预热 → 前几千次又有栈了 → 你以为问题自己消失了
解法:启动参数加上
-XX:-OmitStackTraceInFastThrow
# 注意是减号:把 OmitStackTraceInFastThrow 从"开"关成"关"代价:高频隐式异常的性能损耗回来。但这个代价在生产上几乎总是值得付的 —— 你宁可慢 1%,也不想在出事时拿到一条没栈的 NPE。
一条实践建议:生产环境默认就带上这个参数。它属于”平时没感觉,出事时救命”的那类配置。同理还有:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/
-XX:+ExitOnOutOfMemoryError # OOM 后主动退出,让容器/编排系统重启,别半死不活
-XX:NativeMemoryTracking=summary # 开 NMT,查堆外内存(有 5~10% 性能开销,按需开)
-Xlog:gc*:file=/data/logs/gc.log:time,uptime:filecount=10,filesize=100m # JDK 9+ 的 GC 日志