1. 项目背景
业务场景:某金融支付系统在生产高峰时段突然发生宕机------应用进程直接崩溃,留下了两份文件:一个 hs_err_pid12345.log 和一个 core dump。运维紧急重启后,开发组展开了 3 小时的故障复盘。但尴尬的是,团队中没有人能完整读懂 hs_err_pid 日志------它包含了 JVM 崩溃时的完整内存快照、线程堆栈、编译任务、GC 历史等,但大家只在里面搜索"error""crash"等单词,找不到明显的异常信息,最终结论是"不明原因的 JVM 崩溃"。
痛点:
- 异常被吞噬 :业务代码中大量的
catch (Exception e) { log.error(e); }本质上是在"吞掉"异常------上层代码对异常类型一无所知,无法做针对性处理。甚至在测试环境报NullPointerException被 catch 住后,日志只打印"系统错误",然后继续执行------错误被无声地掩盖。 hs_err_pid的天书难题 :JVM 崩溃日志包含了Internal exceptions、Compilation events、VM Arguments、Memory map等一整套信息,但绝大多数开发者只认识SIGSEGV和Stack: [0x...]这两段,其余信息像天书。OmitStackTraceInFastThrow的陷阱 :JVM 为了性能,在同一个异常频繁抛出时,会"优化掉"异常堆栈------线上的NullPointerException没有堆栈信息,无从追查哪里调用出了问题。而测试环境因为调用频率低,堆栈完整------同一个异常在不同环境的表现完全不同。
本章系统梳理 Throwable 家族的血缘关系、受检/非受检异常的工程争议、异常的性能代价,以及一份完整的 hs_err_pid 日志字段解读------最后输出一份可落地的"线上异常分级与处置 SOP"。
2. 项目设计
(小胖盯着线上日志中一条"java.lang.NullPointerException"------但没有堆栈信息。)
小胖:大师,这 NPE 怎么没有堆栈?就一行"NPE",连哪个类、哪行代码抛的都没有。测试环境没问题、我本地也没问题,怎么一上线就"变异"了?
大师 (扫了一眼):这不是"变异"------这是 JVM 的"智能优化"。当同一个类型的异常在同一个地方频繁抛出(默认 ≥ 5 次),JIT 编译器会把这个异常的堆栈去掉,节省 CPU 开销。这个行为由 -XX:-OmitStackTraceInFastThrow 控制。加上这个参数,异常堆栈就"永远不会被优化掉"。
但你要理解,这不是 bug,而是 HotSpot 在'频繁异常场景下'做的性能取舍 。异常在 Java 里从来就不便宜------每个异常要生成 StackTraceElement[] 数组,而这个过程中要去遍历当前线程的栈帧、解析类名和方法名。这些操作在高频率抛异常的场景下(比如用异常做流程控制),开销非常惊人。
技术映射:异常对象的栈填充 ↔ 每次紧急事故都要出完整的事故调查报告------第一次非常有价值,但如果每天出 1000 次"笔没水了"的事故报告,大家就累了,JVM 选择"从第 5 次起只写标题"。
小白:那你说的"用异常做流程控制"是不对的吗?我一直觉得受检异常(Checked Exception)太麻烦了,非受检异常(RuntimeException)才爽------不用管、不用声明、代码清爽。
大师:这个问题是 Java 社区争论了 20 年的"宗教战争"。先看清家谱:
php
Throwable
├── Error (不应该被捕获的错误)
│ ├── OutOfMemoryError
│ ├── StackOverflowError
│ ├── VirtualMachineError
│ └── AssertionError
└── Exception
├── RuntimeException (非受检异常)
│ ├── NullPointerException
│ ├── IllegalArgumentException
│ └── IndexOutOfBoundsException
└── 其他Exception (受检异常)
├── IOException
├── SQLException
└── ClassNotFoundException
Error 是 JVM 层面的致命问题,原则上不应该被 catch(除非你在做"垂死挣扎"的日志记录)。RuntimeException 是程序 bug(你传了 null、数组越界)------这种异常不应该被捕获,而应该在开发阶段被修复。只有受检异常(IOException 等)才是"环境问题而非程序 bug",调用方必须处理。
但在实际工程中,受检异常造成了极大的噪音:
java
try {
byte[] data = Files.readAllBytes(Paths.get("/data/config.json"));
} catch (IOException e) {
throw new RuntimeException(e); // 三层嵌套包装,毫无意义
}
这就是 Spring 等框架全面转向非受检异常(DataAccessException extends RuntimeException)的原因------他们认为"大多数异常在业务层无法恢复,包装受检异常只是在折磨调用方"。
工程共识:
- 受检异常适合:调用方有明确恢复策略的场景(如网络重连、文件不存在时创建默认文件)。
- 非受检异常适合:调用方无法恢复,只能"兜底日志 + 告警"的场景(如数据库挂了)。
- 铁律 :绝不用异常做流程控制 (如用
catch替代if != null)。
技术映射:受检异常 ↔ 餐厅的"需要挂号的传染病"------服务员发现必须"捂嘴"并向上汇报;非受检异常 ↔ "顾客筷子掉了"------服务员觉得是顾客自己不小心,没必要往上报。
小胖 :那 hs_err_pid 日志怎么看?上次线上 crash 了,运维甩给我这个文件,我打开一看,3000 行天书......
大师 (在白板上画出结构图):hs_err_pid 是 JVM crash 后的"尸检报告",有固定结构。先教你速读法------只看 6 个关键段:
bash
# 1. 头信息
# A fatal error has been detected by the Java Runtime Environment:
# SIGSEGV (0xb) at pc=0x..., pid=12345, tid=12346
# → 告诉你是谁死的(哪个信号)、在哪死的
# 2. Problematic frame:
# C [libjvm.so+0x8a3b2f] ...
# → 直接告诉你崩溃发生在哪一行代码(如果有符号)
# → C=本地代码, J=JIT编译的代码, j=解释器代码, v=VM代码
# 3. Current thread:
# tid: 0x..., native tid: 12346
# → 是哪个线程触发了崩溃,它当时在干什么
# 4. Stack: [0x...]
# → 崩溃线程的"临终遗言栈"------最后调用的 Native 栈
# 5. Internal exceptions
# → 崩溃前 JVM 内部捕捉到的异常(非常重要!)
# 6. VM Arguments 和 Memory Map
# → 参数和内存布局------用于回溯配置是否正确
快速诊断法:先看 Problematic frame------如果地址在 libjvm.so 中 → JVM 自身 bug;如果在 .so/.dll 的 JNI 库中 → 本地代码 bug;如果在 JIT 编译的代码缓存中 → 可能是 JIT 优化产生的 bug。
技术映射 :hs_err_pid ↔ 飞机黑匣子------告诉你"撞机前最后 10 秒发生了什么",而不是"为什么撞机"。
3. 项目实战
3.1 环境准备
| 组件 | 版本 | 用途 |
|---|---|---|
| JDK | OpenJDK 21 | 复现异常和 crash |
| jstack | 内置 | 线程 dump |
| hs_err 分析 | 文本编辑器 | 阅读 crash 日志 |
3.2 分步实现
步骤一:复现 OmitStackTraceInFastThrow 陷阱
目标:证明同一个异常频繁抛出后,JVM 会省略堆栈信息。
java
// FastThrowDemo.java ------ 复现堆栈被优化
public class FastThrowDemo {
public static void main(String[] args) throws Exception {
for (int i = 0; i < 100; i++) {
try {
String s = null;
s.length(); // NPE
} catch (NullPointerException e) {
// 前几次有完整堆栈,后面被优化
if (i < 3 || i > 80) {
System.out.println("--- 第 " + (i + 1) + " 次 NPE ---");
System.out.println("堆栈长度: " + e.getStackTrace().length);
if (e.getStackTrace().length > 0) {
System.out.println("第一帧: " + e.getStackTrace()[0]);
}
}
}
}
}
}
bash
# 默认行为(第 5 次后堆栈被优化)
javac FastThrowDemo.java
java FastThrowDemo
# 预期输出:
# --- 第 1 次 NPE ---
# 堆栈长度: 6
# ...
# --- 第 85 次 NPE ---
# 堆栈长度: 0 ← 堆栈被优化掉了
# 使用 -XX:-OmitStackTraceInFastThrow 禁用优化
java -XX:-OmitStackTraceInFastThrow FastThrowDemo
# 预期:所有 NPE 堆栈完整
关键发现 :如果你的监控系统依赖异常堆栈来定位问题,务必在生产环境添加 -XX:-OmitStackTraceInFastThrow。
步骤二:制造 JVM Crash 并阅读 hs_err_pid 日志
目标:故意用 JNI 写出非法内存访问,触发 JVM crash。
java
// CrashDemo.java ------ 通过 Unsafe 制造 crash
import sun.misc.Unsafe;
import java.lang.reflect.Field;
public class CrashDemo {
public static void main(String[] args) throws Exception {
System.out.println("PID: " + ProcessHandle.current().pid());
System.out.println("3 秒后 JVM 将崩溃,准备阅读 hs_err_pid 日志...");
Thread.sleep(3000);
// 获取 Unsafe 实例(反射)
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);
// 向无效地址写入 → SIGSEGV
unsafe.putAddress(0, 0xDEADBEEF); // ← JVM crash!!!
}
}
bash
javac CrashDemo.java
java CrashDemo
# JVM 崩溃后会生成 hs_err_pid<pid>.log 文件
# 查看关键段
echo "=== 关键信息 ==="
grep -E "fatal error|SIGSEGV|Problematic frame|Current thread|Internal exceptions" hs_err_pid*.log
典型的 hs_err_pid 解读:
ini
# 头信息
# A fatal error has been detected by the Java Runtime Environment:
# SIGSEGV (0xb) at pc=0x00007f..., pid=12345, tid=12346
→ 信号:SIGSEGV(段错误, 非法内存访问)
# Problematic frame:
# j sun.misc.Unsafe.putAddress(JJ)V+0
→ 解释器正执行 Unsafe.putAddress 方法
→ 在解释器帧(j=interpreted frame)中崩溃
# Current thread (0x00007f...): JavaThread "main" [_thread_in_Java, id=12346]
→ 主线程正在执行 Java 代码时崩溃
# Internal exceptions (最近10个):
→ 如果为空, 说明崩溃前 JVM 没有内部异常积累
步骤三:编写线上异常分级与处置 SOP 模板
目标:制定一份可落地到生产环境的异常处理规范。
markdown
## 线上异常分级与处置 SOP
### 分级标准
| 级别 | 代表异常 | 处置动作 | 响应时间 |
|------|---------|---------|---------|
| P0 - 致命 | OutOfMemoryError, StackOverflowError, SIGSEGV | 保留 hs_err + dump → 重启 → 复盘 | 即时 |
| P1 - 严重 | 未捕获的 RuntimeException (NPE, IAE) | 熔断 + 降级 + 日志取证 | 5 分钟 |
| P2 - 警告 | IOException, SQLException (业务可降级) | 记录 → 告警通知 | 30 分钟 |
| P3 - 提示 | 非法参数 (客户端传入), 超时重试成功 | 日志计数 → 周报分析 | 按排期 |
### 日志规范
- 所有异常必须包含: 请求ID(traceId) + 用户ID + 业务上下文
- 禁止 `catch (Exception e) { e.printStackTrace(); }`
- 禁止 `catch (Exception e) { log.error("系统错误"); }`(丢失堆栈上下文)
### 错误码规范
- P0/P1 → 5xx + 告警到 PagerDuty
- P2 → 4xx/5xx + 记录到监控大盘
- P3 → 2xx + 异步统计
步骤四:使用 jstack 制作线程 dump 并与异常堆栈关联
目标:学会在异常发生时立即抓取线程 dump。
java
// ExceptionWithJStack.java ------ 异常 + dump 联动
import java.io.*;
import java.lang.management.ManagementFactory;
public class ExceptionWithJStack {
public static void main(String[] args) throws Exception {
String pid = ManagementFactory.getRuntimeMXBean().getName().split("@")[0];
System.out.println("PID: " + pid);
// 注册异常处理器:未捕获异常时自动触发线程 dump
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
System.out.println("捕获未处理异常: " + throwable);
takeThreadDump(pid);
});
// 模拟业务逻辑中抛出未捕获异常
Thread worker = new Thread(() -> {
try { Thread.sleep(2000); } catch (InterruptedException e) {}
throw new RuntimeException("模拟业务异常崩溃");
}, "Worker-Thread");
worker.start();
worker.join();
}
static void takeThreadDump(String pid) {
try {
Process proc = new ProcessBuilder("jstack", pid).start();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(proc.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}
System.out.println("线程 dump 已自动生成");
} catch (IOException e) {
e.printStackTrace();
}
}
}
可能遇到的坑:
hs_err_pid没有生成 :如果 JVM 进程被kill -9杀死的,不会生成hs_err_pid------只有"内部信号崩溃"(如 SIGSEGV/SIGBUS/SIGFPE)才会生成。如果怀疑 JVM bug 但无 crash 日志,需要-XX:+CrashOnOutOfMemoryError等 flag 帮助诊断。StackTraceElement性能陷阱 :e.printStackTrace()在 log4j 中会调用Throwable.fillInStackTrace()------这个操作需要遍历当前线程整个栈帧,在高并发异常场景下极其昂贵。建议用结构化日志(JSON + traceId)代替栈打印。catch (Throwable)捕获 Error :catch (Throwable)会捕获OutOfMemoryError------但捕获后内存并没有释放,程序继续执行可能再次 OOM。永远不要 catch Throwable,除非你明确知道恢复策略。-XX:+CrashOnOutOfMemoryError的注意点:这个参数让 JVM 在 OOM 时主动生成 crash 日志,方便事后分析。但代价是进程立即终止------对高可用服务需要搭配优雅降级和快速重启。
3.3 测试验证
| 验证点 | 命令 | 预期结果 |
|---|---|---|
| FastThrow 堆栈消失 | java FastThrowDemo |
5 次后堆栈长度=0 |
| FastThrow 堆栈保留 | java -XX:-OmitStackTraceInFastThrow FastThrowDemo |
100 次仍完整 |
| hs_err 生成 | java CrashDemo |
当前目录生成 hs_err_pid*.log |
| hs_err 内容验证 | grep "Problematic frame" hs_err*.log |
显示 Unsafe.putAddress |
| 线程 dump 自动化 | java ExceptionWithJStack |
未捕获异常时自动输出 jstack |
bash
#!/bin/bash
echo "=== 1. FastThrow 优化验证 ==="
java FastThrowDemo 2>&1 | grep "堆栈长度" | tail -5
echo ""
echo "=== 2. hs_err 生成验证 ==="
java CrashDemo 2>&1 || true
ls -la hs_err_pid*.log 2>/dev/null
grep "SIGSEGV" hs_err_pid*.log 2>/dev/null | head -3
echo ""
echo "=== 3. 线程 dump 自动化验证 ==="
timeout 10 java ExceptionWithJStack 2>&1 || true
4. 项目总结
4.1 优点与缺点
| 维度 | 优点 | 缺点 |
|---|---|---|
| 异常体系 | 类型层次清晰,Error/Exception/RuntimeException 三权分立 | 受检异常在实践中被滥用,导致大量无意义的 try-catch 包装 |
| FastThrow 优化 | 对"频繁异常做流程控制"的代码能显著降低 CPU 开销 | 线上查问题时看不到堆栈------对"真 bug"也无差别优化 |
| hs_err_pid | JVM 崩溃后的全量诊断快照------信号类型、线程上下文、内存布局一应俱全 | 解读门槛高(3000+ 行),没有培训和工具支持就是一纸天书 |
| jstack | 零依赖、一键抓取所有线程状态和死锁 | 只反映瞬间快照,瞬态锁竞争可能被漏掉 |
| Error 体系 | Error 明确告知"这是 JVM 层问题,不是你代码问题" | OOM 等 Error 可能在业务线程中抛出,导致 catch(Exception) 兜不住 |
| 对比技术 | 受检异常 | 非受检异常 | Go 的 error 返回值 | Rust 的 Result<T,E> |
|---|---|---|---|---|
| 强制处理 | 是(编译期报错) | 否 | 是(未使用 error 编译警告) | 是(match/?) |
| 性能开销 | 高(栈填充) | 中(同受检,但通常更少) | 低(无栈回溯) | 低(编译期绑定) |
| 工程体验 | 差(大量包装) | 中(易被忽略) | 好(显式但不强制) | 好(模式匹配处理) |
4.2 适用场景
- 线上异常监控 :用
UncaughtExceptionHandler捕获所有未处理异常,自动关联分布式 traceId。 - JVM 崩溃诊断 :读懂
hs_err_pid的 6 个关键段,判断崩溃是 JVM bug、JNI bug 还是系统层面问题。 - API 设计 :对外接口用受检异常(
throws声明预期失败);内部调用用非受检异常(@NotNull等注解替代 NPE 检查)。 - 性能排查 :识别"用异常做流程控制"的坏味道代码,用
-XX:-OmitStackTraceInFastThrow辅助 debug。 - 应急响应:将异常分级 SOP 与监控系统联动,P0 异常自动触发 dump + 重启。
不适用场景:
- 极致性能要求的场景(高频交易、实时系统)------应避免抛出任何异常,用返回值 + 错误码替代。
- 纯函数式编程风格------用
Optional/Either替代异常做错误处理。
4.3 注意事项
| 类型 | 详细说明 |
|---|---|
| FastThrow 的白名单 | 优化只对某些特定异常生效(NPE、ArithmeticException、ArrayIndexOutOfBoundsException、ArrayStoreException、ClassCastException)------自定义异常不会被优化 |
| Suppressed Exceptions | try-with-resources 使用 addSuppressed() 记录资源关闭时的异常------检查最外层异常的 getSuppressed() 可以看到"被压制"的资源关闭失败 |
| 异常链 | RuntimeException(String message, Throwable cause) 的 cause 参数不会自动打印------检查日志时务必确保 cause 链完整 |
| StackOverflowError | 如果栈溢出,printStackTrace() 本身的递归调用可能再次触发溢出------日志输出可能不完整 |
4.4 常见踩坑经验
案例 1:catch (Exception) 吞掉了 OOM
某定时任务调度模块用 catch (Exception e) 包裹了所有业务逻辑。某天 JVM 堆内存不足,OOM 被 catch 住后只打印了一句"任务执行异常",然后继续调度下一个任务------下一个任务又 OOM → 又 catch → 继续调度......形成了"OOM 风暴"。根因 :catch (Exception) 捕获不到 OutOfMemoryError(它是 Error 子类),但如果代码是 catch (Throwable e) 就全抓了------两种写法都有坑。修复 :使用多级异常处理------catch (Error e) { 紧急日志 + 退出; } + catch (Exception e) { 业务日志; }。
案例 2:Spring @Transactional 异常回滚边界错误
Spring 的 @Transactional 默认只对 RuntimeException 和 Error 回滚------对受检异常不回滚。某开发在一个需要回滚的方法上 throws IOException,结果事务没有回滚,脏数据写入了数据库。根因 :Spring 的默认回滚策略与 Java 的受检异常设计哲学不一致。修复 :@Transactional(rollbackFor = Exception.class) 或改用非受检异常包装。
案例 3:日志框架的异步 Appender 丢失异常堆栈
某系统升级到 Logback 的异步 Appender 后,线上所有异常日志都丢失了异常堆栈------只剩 Exception: null。根因 :异步日志中异常对象的 stackTrace 在 log.error(msg, e) 调用时被序列化到队列,但 logback 只序列化了 message 和 stackTrace 的字符串,而 FastThrow 优化后 stackTrace 为空。修复 :先关闭 FastThrow 优化(加 -XX:-OmitStackTraceInFastThrow),再改为同步日志或确保异常序列化时保留完整堆栈。
4.5 思考题
-
进阶题 :
Throwable.fillInStackTrace()是synchronized方法------在高并发异常场景下,这是否可能成为全局瓶颈?请设计一个实验测量fillInStackTrace()在多线程下的吞吐,并分析 HotSpot 是否有针对此方法的优化(提示:搜索JVM_GetStackTraceElement源码)。 -
实战题 :你的线上服务偶发 JVM crash,
hs_err_pid文件显示Problematic frame: V [libjvm.so+0x...]。你要如何进一步定位这个地址对应的源码行号?请列出命令行工具和步骤。(提示:addr2line、nm、c++filt等工具。)
答案提示 :思考题 1 答案见第 19 章线程池部分 + HotSpot
reflection.cpp中JVM_GetStackTraceElement实现;思考题 2 答案见第 2 章编译安装中的 debug 符号部分。
下一章预告:第 11 章将揭开反射、动态代理和 MethodHandle 的面纱------理解 Spring/MyBatis 等框架的"魔法"背后的 JDK 能力,并演示模块系统下非法反射失败的完整案例。