第 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 记录   定位到类+行     交叉验证        火焰图看热点

这张图怎么用?三步。

  1. 分类:现象属于五类中的哪一类?(这一步 30 秒,但能砍掉 80% 的搜索空间)
  2. 提假设:这一类下面通常有 3~5 个候选根因,按你的直觉排个序
  3. 做验证:按验证成本从低到高依次排除。注意是”验证成本”,不是”可能性排序”

第三步是大部分人做错的地方。他们会先去验证”最可能的”,而不是”最容易验证的”。但排错的目标是尽快缩小解空间,所以应该先做 30 秒能出结果的检查(看一眼监控、grep 一下日志),把便宜的可能性批量清掉,再去做贵的操作(堆转储、压测复现)。

一张反馈周期对照表

维度GoJava(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

五个组成部分:

  1. 异常类型 + message:IllegalStateException: Failed to load ApplicationContext
  2. 栈帧序列:最上面一行是抛出点(这是最容易搞反的地方 —— 调用顺序是自下而上,但抛出点在最上面)
  3. Caused by 链:一层层剥下去,每一层都是”上一层的直接原因”
  4. ... 37 more:表示”下面还有 37 行,但跟上一段栈帧重复了,省略”。要看完整内容,往上翻一段
  5. 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,框架通常会写清楚哪个参数
IllegalMonitorStateExceptionwait()/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 exceededJVM 的自我保护:“我 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 30000msHikariCP 连接池耗尽。要么慢 SQL 占着连接,要么连接泄漏没还
NoUniqueBeanDefinitionExceptionSpring 容器里同一个接口有多个实现且没有 @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)做了特殊处理:

  • NullPointerException
  • ArithmeticException(除零)
  • ArrayIndexOutOfBoundsException
  • ArrayStoreException
  • ClassCastException

当这些异常在同一个位置被抛出很多次以后,JIT 会把 fillInStackTrace() 优化掉 —— 直接复用一个预先构造好的、没有栈的异常实例。

为什么要这么做? 因为 fillInStackTrace() 是 native 方法,要遍历整个调用栈并构造 StackTraceElement[],成本是微秒级。一个每秒抛一万次 NPE 的热点路径,光填栈就能吃掉几个百分点的 CPU。JVM 的判断是:你已经抛了几千次了,栈长什么样你早该知道了。

坑在哪? 三处:

  1. 时间相关性:前几千次有栈,之后没栈。所以你事故刚发生时看到的日志有栈,等你回头翻历史日志时全没栈了
  2. 误导排查方向:你会去查日志框架、查磁盘、查异步队列,而真正的原因是 JVM 优化
  3. 重启后”自动修复”:重启 → 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 日志