☰
JVM崩溃日志hs_err_pid排查:从致命信号到本地库治理
2026/11/2 5:04:48 网站建设 项目流程

1. 从 Exception 到 fatal error:这两类崩溃根本不在一个层面上

很多人第一次看到 “A fatal error has been detected by the Java Runtime Environment” 这句话时,第一反应是去业务日志里找堆栈,翻半天发现异常栈一条都没有,服务直接没了,进程 ID 消失,只剩一个文件名长得像乱码的hs_err_pid12345.log躺在工作目录里。这个反差本身就说明了一件事:这不是你写的 Java 代码抛出来的东西,而是承载你代码的那台“虚拟机”自己倒下了。

我用一个比较粗糙但好记的比喻:普通 Exception 相当于你家里的电灯坏了,灯不亮但房子还在,你可以换个灯泡继续住;fatal error 相当于整栋楼的地基出了问题,物业直接拉闸封楼,住在里面的每一户都得搬走,没有任何一户能靠“try-catch 一下”把楼撑住。JVM 是 C++ 写的宿主程序,Java 代码跑在它之上,一旦 JVM 自己的内存访问、信号处理、断言检查出了无法挽回的问题,它唯一能做的就是终止进程,顺手吐出一份“遗书”,也就是hs_err_pid日志。

所以处理这类报错,思路跟处理空指针、数组越界完全相反。业务代码层面的排查是“找哪一行”,fatal error 的排查是“找哪个层面”:是 Java 代码把参数传错了,还是本地库内部越界,还是 JVM 版本本身有缺陷,还是操作系统把进程干掉了。定位层级比定位行号重要得多,层级错了,后面所有努力都是白费。

1.1 谁在什么情况下写出 hs_err_pid 文件

先明确一个前提:不是所有的进程消失都会留下 hs_err 日志。这个文件是 JVM 内部的信号处理器和错误上报机制写的,只有当 JVM 自己还“有意识”的时候才能写。触发路径大致有这么几类。

第一类是致命信号。Linux 上典型的是 SIGSEGV(段错误,编号 11)、SIGBUS(总线错误,编号 7)、SIGILL(非法指令,编号 4)、SIGFPE(算术异常,编号 8)。JVM 注册了自己的信号处理器,收到这些信号后先判断是不是自己预期的(比如安全点轮询、空指针检查会用信号实现),如果不是预期用途,就认为是真崩溃,进入错误上报流程。

第二类是内部断言失败。HotSpot 源码里到处是guarantee(...)和assert(...),前者在生产版本也生效。一旦某个不变量被破坏,比如对象头标记位异常、元空间指针错乱,JVM 会主动调用report_fatal_error然后 abort。这类日志里常常能看到形如# Internal Error (xxx.cpp:123), pid=..., tid=...的字样。

第三类是主动崩溃开关。有些团队会加-XX:+CrashOnOutOfMemoryError,堆内存耗尽时直接让 JVM crash 掉,目的是快速失败并留下现场,避免一个半死不活的进程继续对外提供错误结果。这种“故意的崩溃”也会生成日志,看到时不必惊慌,那是配置出来的行为。

反过来,SIGKILL 是抓不住的。容器内存超限被内核的 OOM killer 干掉时,发的是 SIGKILL,进程没有任何机会执行清理逻辑,自然也就不会留下hs_err_pid。这就是为什么很多人信誓旦旦说“JVM 崩溃了但没日志”,其实压根不是 JVM 崩溃,是整台容器被端了。这个区分后面会单独展开,因为它是误判率最高的一类。

提示:日志没生成还有几个常被忽略的原因。工作目录没有写权限、磁盘写满、-XX:ErrorFile指到了一个不存在的目录、进程被 SIGKILL。排查“为什么没有日志”这件事本身,经常比看日志更有价值。

1.2 SIGSEGV、SIGBUS、SIGILL 在日志头部的真实含义

日志开头的第一段就是信号类型,这一行决定了你后面的排查方向,值得逐字读。

SIGSEGV是最常见的,含义是访问了非法内存地址。它下面细分两种:SEGV_MAPERR表示访问的地址根本没有映射到任何内存区域,通常是空指针解引用或者指针彻底跑飞;SEGV_ACCERR表示地址有映射但权限不对,典型是往只读页写数据,比如字符串常量区被改写,或者代码段被覆盖。

SIGBUS相对少见但很有意思,它多半和内存映射文件有关。当你用MappedByteBuffer映射了一个文件,然后这个文件被外部进程截断或删除,再去访问映射区域就会触发总线错误。这个场景在日志处理、数据库存储类应用里偶有出现,特征是崩溃点非常随机,跟数据量有关。

SIGILL是 CPU 遇到了它不认识的指令。放到 JVM 场景里,通常是 JIT 生成的代码用了当前 CPU 不支持的指令集,或者虚拟化环境屏蔽了某些扩展指令但 JVM 探测时认为支持。这类问题换台物理机就复现不了,非常折磨人。

日志头部还会带上一堆环境信息:JRE 版本、JVM 名称、CPU 架构与核心数、物理内存、容器相关的检测结果、完整的启动命令行。这些看起来像废话,实际上是判断“版本错配”类问题的第一手证据。我见过一次崩溃,最后定位到的是同一批机器里混进了两个不同小版本的 JDK,日志里java version那一行跟其他节点差了一个补丁号,而那个补丁号恰好修了一个编译器相关的缺陷。这种线索,只有把日志头部当回事的人才抓得住。

2. 打开 hs_err_pid 日志后的十分钟读法

一份完整的崩溃日志动辄几千行,中间大段的寄存器状态、内存映射表、类加载统计对普通业务开发来说信息密度极低。真正常用的其实就那几个段落,掌握阅读顺序能让你在十分钟内判断出大方向,而不是从头到尾硬啃。

我的习惯顺序是:先看头部信号和 Problematic frame,再看 siginfo,然后扫动态库列表和启动参数,最后才去翻线程栈和内存映射。这个顺序的设计逻辑是“从结论倒推证据”——Problematic frame 基本已经告诉你是谁崩的,后面的内容是用来验证和解释的。

2.1 先把 Problematic frame 这一行揪出来

这是整份日志里含金量最高的一行,长这样:

Problematic frame: C [libsomething.so+0x1a2b3c]

或者:

Problematic frame: J 123 c2 com.example.Foo.bar(Ljava/lang/String;)V

前缀C表示崩在本地(native)代码里,中括号里是库名和偏移地址;前缀J表示崩在 JIT 编译后的 Java 代码里,后面跟着编译层级(c1/c2)、方法名和字节码索引;前缀V或者v表示崩在 JVM 自身的代码里。

三种前缀指向三条完全不同的排查路线。C指向第三方库,你要查的是这个.so是谁带进来的、版本几何、和当前 JDK 是否匹配;J指向 Java 方法,说明 JVM 认为问题出在编译生成的机器码上,此时要重点怀疑 JIT 缺陷(尤其是 c2 编译的方法)或者方法本身触发了某种极端情况;V指向 JVM 自身,基本可以判定是 JDK 的问题,需要去比对已知缺陷列表并考虑升级补丁版本。

有个细节很容易被忽略:C前缀后面如果是libjvm.so,那还是要按 JVM 自身问题处理,因为libjvm.so就是 HotSpot 本体。别看到C就一口咬定是第三方库,我见过有人拿着libjvm.so的帧去查业务依赖,白白浪费两天。

2.2 siginfo 里的 si_addr 透露了访问地址的性质

紧挨着 Problematic frame 下面有一段siginfo: si_signo: 11 (SIGSEGV), si_code: 1 (SEGV_MAPERR), si_addr: 0x0000000000000000。这里的si_addr是被访问的那个非法地址,它的取值很有讲究。

如果是0x0000000000000000,几乎可以确定是空指针解引用。本地库里某个结构体指针没初始化、某个返回值没判空,一路传到解除引用就炸了。这种情况下si_addr会是个很小的值,比如0x8、0x10,那是从空指针加上结构体字段偏移算出来的。

如果si_addr是一个看起来很像正常地址的大数值,比如0x00007f3a2c004000,那就要看内存映射表里这个地址落没落在某个已知区间里。落在[stack]附近说明撞到了栈的保护页;落在堆区间之外说明指针越界跑飞了;落在[anon]匿名映射里可能是直接内存被提前释放后又被访问。

还有个组合值得记:si_code: 2 (SEGV_ACCERR)加上地址落在一个只读区间,基本就是“写只读内存”。这类问题在 JNI 里改写传入的字符串常量、或者错误地转换const char*时出现得最多。

2.3 动态库列表与命令行参数常常藏着真凶

日志里有一段Dynamic libraries:,列出进程当时加载的所有.so及其路径和基址。这段的价值在于发现“意外加载”——比如LD_LIBRARY_PATH里混进了一个老版本的库,或者某个依赖包自带了一份同名的.so,加载顺序一变,实际用的就不是你以为的那个。同一个库名出现两条不同路径,是典型信号。

紧接着的VM Arguments:段列出完整的 JVM 参数。这里能看出很多问题:堆设置是否超过容器限制、MaxMetaspaceSize有没有显式指定、有没有人偷偷加了实验性参数(-XX:+UnlockExperimentalVMOptions后面的东西要特别留意)、栈大小-Xss是多少。有些崩溃是参数配出来的,比如-Xmx设得接近物理内存上限,堆外内存一涨就直接被系统收拾。

再往下的Environment Variables:段也值得扫一眼。LANG、LC_ALL相关的编码问题会在某些本地库里引发异常行为,LD_PRELOAD如果被设了东西,那整个进程的内存布局都可能被改变,排查难度陡增。

2.4 Native frames 与 Java frames 对照着看调用链

线程栈部分会把崩溃线程的两种视角分别打印出来。Java frames是从 Java 层往上数的调用栈,Native frames是 C/C++ 层的调用栈。两份对照着看,能还原出“Java 调用进来之后在本地代码里走了多远”。

举个例子,Java frames停在某个类的native方法声明上,Native frames显示调用链已经深入到某个.so的内部函数好几层,那基本可以确定问题在这段本地逻辑里,跟 Java 传的参数是否合法只是间接关系。

还有一种情况是Native frames里出现了J开头的行,意思是“这里回调进了 Java 代码”。这种 Java → Native → Java 的来回穿梭最容易出问题,因为栈帧边界、本地引用、线程状态切换都在这个交界处。看到这种形态的栈,排查重心应该放在 JNI 引用管理和线程附着上。

寄存器状态和内存映射表这两段,日常排查可以先跳过,等确定要用 gdb 分析 core 文件的时候再回来对照地址。

3. 六类高频诱因及其指纹特征

崩溃日志读完之后,下一步是对号入座。我把实际遇到过和帮别人看过的案例做了归类,归纳出六种最常见的诱因,每种都有自己的“指纹”,照着特征比对能快速缩小范围。

需要提前说明的是,下面所有的案例都是虚构的模拟场景,用来说明排查思路,不对应任何真实项目。

3.1 老版本本地库与新 JDK 的 ABI 冲突

某图像处理方向的模拟项目,升级 JDK 大版本后,服务在跑批量图片处理任务时偶发崩溃,Problematic frame指向一个负责编解码的.so,si_addr是0x0,Native frames显示崩在库内部的初始化函数里。

这类问题的根因通常是ABI 不兼容。本地库在编译时链接的是某个版本的 JVM 接口头文件,而 JVM 的JNIEnv函数表结构在不同大版本之间可能发生变化,或者库内部依赖的 C++ 运行时版本与当前进程里加载的不一致。表现就是“大部分时候能跑,某些路径一进去就炸”。

排查手法很直接:把那几个.so拉出来看依赖关系,确认它加载的运行时库版本,再和 JVM 进程实际加载的那份做对比。如果发现两份不同版本的运行时被同时映射进同一个进程,那就基本实锤。

处理方式只有一条路:让本地库跟着 JDK 一起升级重编,不要试图用参数绕过。临时规避可以关掉依赖这个库的功能模块,但那只是把问题藏起来。

3.2 容器内存超限被杀:为什么你连 hs_err 都找不到

某消息处理方向的模拟服务,跑着跑着就没了,容器退出码 137,日志目录里干干净净一份崩溃日志都没有。有人坚持说“JVM 崩了”,于是开始在 JVM 参数上折腾,加了一堆诊断开关,毫无效果。

退出码 137 就是 128 + 9,代表被 SIGKILL 终止。这种情况 JVM 根本来不及反应,不可能写日志。要确认,去看容器的内存统计:/sys/fs/cgroup/memory.events里的oom_kill计数如果涨了,那就是被内核回收了。

关键在于理解容器内存账本。很多人以为-Xmx4g就代表进程最多占 4G,实际上进程的常驻内存是这么几块的叠加:

内存区域说明是否受 -Xmx 约束
Java 堆对象实例主体是
元空间类元数据、方法信息否,受 MaxMetaspaceSize 约束
代码缓存JIT 编译产物否,受 ReservedCodeCacheSize 约束
直接内存直接缓冲区、映射文件否,受 MaxDirectMemorySize 约束
线程栈每线程一份,默认 1M 左右否,受 -Xss 与线程数影响
本地库与运行时glibc、本地库自身的内存池否

一个-Xmx4g的进程,实际常驻 5.5G 到 6G 完全正常。如果容器限制设成 5G,那就必然被杀。经验值是容器 limit 至少要比堆上限高出 25% 到 30%,线程数多、用了大量直接内存的应用还要再往上加。

注意:用-XX:MaxRAMPercentage而不是写死-Xmx,在容器里更省心,但要确认 JDK 版本对容器感知的支持是否正常,老版本 JDK 可能读不到 cgroup 的限制值,直接按宿主机内存算,那就彻底失控了。

3.3 栈边界撞击与 native 层递归

普通的无限递归在 Java 层会抛StackOverflowError,这是 JVM 主动检测并抛出的,进程不会死。但有两种情况会绕过这个保护。

一种是递归发生在 native 代码里,JVM 不知道你在本地栈上走了多深,等到撞上栈顶的保护页,直接 SIGSEGV。日志里si_addr会非常接近线程栈的起始地址,Problematic frame是个C帧,Native frames里能看到同一个函数重复出现几十上百层。

另一种是-Xss设得太小,加上某些框架在启动阶段就会用较深的调用栈,还没到业务逻辑就把栈用完了。这种情况的表现是“启动就崩”或者“某个特定操作必崩”,非常稳定,反而好排查。

还有一种隐蔽的:信号处理器本身运行在备用信号栈上,如果处理逻辑太重,也可能在栈上出问题。这类属于 JVM 层面的边缘情况,一般升级 JDK 就能解决。

处理栈相关的崩溃,先确认-Xss的当前值,再看崩溃线程的栈深度。默认值在不同平台上不一样,Linux x64 通常是 1M,容器里某些基础镜像会调小。我一般会显式写-Xss1m或者-Xss2m,把不确定性消掉。

3.4 堆外内存失控:直接缓冲区与内存映射文件

某数据管道方向的模拟服务,堆内存监控一直很平稳,GC 也很健康,但进程 RSS 一路爬升,最终被杀或者崩溃。这类问题的特征很明显:堆指标好看,进程指标难看。

堆外内存的来源主要有三块:ByteBuffer.allocateDirect分配的直接缓冲区、FileChannel.map得到的内存映射区、以及本地库自己 malloc 的内存。前两者受-XX:MaxDirectMemorySize约束,但这个参数默认值等于堆最大值,很多人根本不知道它存在,也就从来没设过。

直接缓冲区还有一个坑:它的回收依赖Cleaner机制,而Cleaner是在 GC 时触发的。如果堆很空闲,GC 很少发生,直接缓冲区就会一直积累,直到把限额吃光。这时申请新的直接内存会抛OutOfMemoryError: Direct buffer memory,属于正常的 Java 异常;但如果本地库直接 malloc 失败返回空指针而代码没判,就变成si_addr: 0x0的崩溃了。

排查手段是打开原生内存追踪:-XX:NativeMemoryTracking=summary,然后jcmd <pid> VM.native_memory summary,对比各分类的占用。日志里如果能看到Internal或者Other分类异常膨胀,基本就锁定了方向。内存映射文件的问题则要检查文件是否被外部截断,映射区访问越界会触发 SIGBUS。

3.5 JIT 编译线程和 GC 线程自己崩了

Problematic frame前缀是V,或者虽然是C但指向libjvm.so,并且崩溃线程名是C2 CompilerThread或者GC Thread#N,那方向就变了——这是 JVM 自己的代码出了问题。

JIT 相关的崩溃有个很典型的验证方法:加-Xint用纯解释模式跑,如果崩溃消失,基本可以确认跟编译产物有关。更精细一点用-XX:TieredStopAtLevel=1只开 C1 编译,如果也不崩,那问题就锁定在 C2 上。这个方法我称之为“分层关闭法”,逻辑很朴素——把怀疑的组件一层层摘掉,看问题是否还在。

GC 线程崩溃通常和堆内存被破坏有关。如果业务代码里通过 JNI 越界写了堆内存,破坏了对象头或者标记位,表现就是 GC 在扫描对象图的时候踩到脏数据。这种情况下崩溃点看起来在 GC 里,真凶却在业务侧的本地调用里,属于典型的“案发现场不是作案现场”。

应对这类问题,第一步永远是确认 JDK 补丁版本是不是最新。JIT 和 GC 是 HotSpot 里改动最频繁的部分,已知缺陷修得很快,很多看起来诡异的问题升个小版本就没了。第二步是用-XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation把编译日志打出来,虽然文件很大,但配合崩溃时间点能对上是哪个方法在编译时出的问题。

3.6 指令集与系统库版本的不兼容

这类问题出现频率不高,但排查起来最费劲,因为它有强烈的环境相关性——换个环境就复现不了。

SIGILL非法指令是其中最典型的表现。JIT 会根据 CPU 探测结果决定使用哪些指令集扩展,如果探测逻辑出错,或者运行环境(比如某些虚拟化平台)屏蔽了实际上被声明支持的指令,生成出来的机器码一执行就是 SIGILL。日志头部的 CPU 信息段会列出检测到的特性,可以拿去和实际的 CPU 规格对照。

另一类是系统库版本问题。JDK 对基础 C 库有最低版本要求,不满足时一般会在启动阶段直接报找不到符号,属于“启动即失败”,比较好认。麻烦的是那些运行时才动态加载的路径,启动时没问题,跑到某个功能才崩,日志里Dynamic libraries段能看到加载了多个版本的同一个基础库,那就很可疑了。

提示:容器镜像的基础系统版本和 JDK 构建时面向的目标版本,中间差一截是常事。构建镜像时用最精简的基础镜像图省空间,结果运行时的库版本刚好卡在边界上,这类教训不少。

4. 一次从偶发崩溃到复现的完整排查记录

下面这个案例是我按典型排查路径构造的模拟场景,把从“服务半夜挂了”到“定位到具体原因”的全过程串起来,重点在于每一步为什么这么做,而不是结论本身。

场景设定:某跨平台系统的服务端节点,平均每天崩溃一到两次,时间不固定,重启后能正常跑,日志目录里有hs_err_pid文件,但每次的Problematic frame看起来都不太一样,有人怀疑是硬件问题,有人怀疑是 JDK 缺陷。

4.1 第一步永远是保住现场而不是重启

绝大多数团队的默认操作是“挂了就重启”,监控告警一响,运维脚本自动拉起。这个动作把最有价值的信息毁掉了——崩溃瞬间的进程状态没了,只剩一份静态日志。

正确的做法是先做现场保护清单,哪怕只是把文件挪个地方:

# 把崩溃日志、core 文件、当时的 JVM 参数一并归档 mkdir -p /data/crash/$(date +%Y%m%d_%H%M%S) cp /path/to/workdir/hs_err_pid*.log /data/crash/$(date +%Y%m%d_%H%M%S)/ # 归档进程被杀时容器记录的内存事件 cat /sys/fs/cgroup/memory.events > /data/crash/$(date +%Y%m%d_%H%M%S)/memory.events # 归档当时的系统负载与内存快照(如果进程还在) free -m > /data/crash/$(date +%Y%m%d_%H%M%S)/free.txt

同时把自动重启策略改成“先归档再重启”,这个改动很小,但收益巨大。

然后看日志的时间戳分布。如果有多个hs_err_pid文件,按修改时间排序看第一个和最后一个的间隔。如果崩溃时间点跟某些定时任务、流量高峰、或者整点对得上,那就是重要的模式线索。上面这个模拟案例里,崩溃时间集中在每天的流量低谷期,这反而提示了方向——低峰期 GC 触发频率下降,直接内存回收变慢,于是堆外占用爬到峰值。这个推断在后面得到了验证。

4.2 用 core 文件把死亡瞬间倒放

日志只是快照,core 文件是完整的现场。开启方式:

# 临时放开 core 大小限制(生产环境要评估磁盘占用) ulimit -c unlimited # 确认 core 的落盘路径与命名规则 cat /proc/sys/kernel/core_pattern

如果core_pattern是|/usr/lib/systemd/systemd-coredump ...这类管道形式,core 会被系统服务接管,需要从对应位置捞取;如果是普通路径模板,直接去找对应文件即可。

拿到 core 之后用 gdb 分析:

gdb /path/to/java /path/to/core.12345 # 在 gdb 里依次执行 (gdb) info threads (gdb) thread apply all bt (gdb) info sharedlibrary (gdb) frame 0 (gdb) info registers

重点看三件事:崩溃线程的完整 C 栈有多深、加载的共享库列表里有没有重复或版本可疑的条目、栈顶几帧的地址是否落在一个合理的模块范围内。

在这个模拟案例里,info sharedlibrary输出中出现了一个基础运行库的两个不同版本路径,一个来自系统目录,一个来自应用自带的依赖目录。崩溃栈的顶部正好落在那份较老的版本里。到此,“环境里有版本冲突”这个假设就有了实锤,不再是猜测。

4.3 二分法缩小范围:接口、版本、开关

有了方向之后要做的是缩小范围,而不是直接改代码。二分法的核心是把变量一个个固定住,观察崩溃是否还出现。

按功能二分:如果是多模块服务,先关掉一半模块跑一段时间,看是否还崩。这个模拟案例里,关掉某个处理上传文件的功能后,崩溃频率从每天一次降到零,范围一下从整个服务缩到了单个模块。

按版本二分:把 JDK 换回上一个补丁版本,或者换到最新补丁版本,各跑一段时间。这一步是为了验证“是不是新引入的问题”。如果两个版本都崩,说明跟版本无关,方向错了要及时回头。

按开关二分:这一步最有意思。先加-Xint关掉全部 JIT 编译,如果还崩,说明问题不在编译产物上;再加-XX:+UseSerialGC换成最朴素的收集器,排除 GC 实现的嫌疑;最后把本地库的加载路径改成绝对路径,排除加载顺序的干扰。这三个开关做完,基本能把问题锁到一个很窄的范围里。

注意:-Xint会让性能掉一个数量级,只适合在预发或者低流量环境验证,别在生产上长时间挂着。

在这个模拟案例里,-Xint下依然崩溃,说明跟 JIT 无关;换成绝对路径加载依赖库后崩溃消失,方向锁定在库加载上。结合 4.2 里发现的重复库路径,结论就很清楚了。

4.4 假设验证矩阵与最终定位

排查过程中我习惯维护一张矩阵表,把“假设、验证方法、预期结果、实际结果”四列写清楚。这样做的好处是避免自己骗自己——很多人验证到一半发现结果不符合预期,就自己脑补一个解释糊弄过去,最后方向越走越偏。

假设验证方法预期结果判定
硬件内存故障换节点跑同一压测崩溃随机分布,不随节点固定排除(换节点仍崩)
容器内存超限查 cgroup 的 oom_kill 计数计数上涨则说明被内核杀排除(计数为 0,且有日志)
JIT 编译产物缺陷加 -Xint 运行崩溃消失排除(仍崩)
本地库版本冲突对比动态库列表、改为绝对路径加载崩溃消失命中
栈空间不足增大 -Xss 观察崩溃频率下降部分相关(不是主因)

最终定位到的是:应用自带的依赖目录里有一份较老的库,通过环境变量被优先加载,而业务代码里有一处调用用到了这个库在新版本才修正的结构体布局。升级该库并去掉环境变量后,崩溃消失,连续运行两周无复现。

值得一提的是,这张表里“栈空间不足”这一行被判定为“部分相关”,因为把-Xss从默认值调到 2M 之后崩溃频率确实略有下降。这种“有改善但不是根治”的现象特别容易误导人,让人以为找到答案了然后收工。我的经验是,只要没有“彻底消失”,就说明找的不是根因。

5. 让崩溃信息可追溯:启动参数与本地库管理规范

排查能力再强,也不如让问题在发生时就留下足够的证据。这一章讲的是工程侧的准备,做不做这些配置,决定了你下次崩溃时是十分钟定位还是三天瞎猜。

5.1 ErrorFile 和 core 路径必须显式配置

默认情况下hs_err_pid落在进程的工作目录,而工作目录在容器里往往是个临时路径,容器一重建文件就没了。所以必须显式指定到一个持久化目录:

java \ -XX:ErrorFile=/data/logs/hs_err_pid%p.log \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/data/logs/heapdump \ -XX:+ExitOnOutOfMemoryError \ -jar app.jar

%p会被替换成进程号,避免同名覆盖。HeapDumpOnOutOfMemoryError虽然主要针对堆 OOM,但对排查内存相关问题同样有价值。ExitOnOutOfMemoryError让进程在 OOM 后主动退出,交给编排系统重启,避免半死不活地继续服务。

还要确认日志目录所在的文件系统有足够空间。一份完整的 core 文件可能和进程内存一样大,hs_err日志几百 KB 到几 MB 不等。磁盘写满是“日志没生成”的常见原因之一,而且往往在崩溃这种高内存压力时刻才出现,很容易被忽略。

另外建议把hs_err的采集纳入日志系统。它不是普通文本日志,但可以配置单独的采集规则,按文件名匹配抓取。抓到统一存储之后,跨节点做对比分析会方便很多——前面提到的“同一批机器里混了不同 JDK 版本”就是这么发现的。

5.2 容器里的内存账本要算清楚

容器化之后,内存问题从“堆调优”变成了“整机预算分配”。我的做法是先算一个总账:

假设一台节点的容器 limit 是 8G,业务期望最大吞吐需要 5G 堆,那么剩下的 3G 要覆盖元空间、代码缓存、直接内存、线程栈、本地库、glibc 自身的分配。这个余量看起来够,但如果线程数上千(每线程 1M 栈就是 1G),加上用了大量直接缓冲区的网络框架,3G 可能并不宽裕。

具体参数上,我一般这么配:

java \ -XX:MaxRAMPercentage=65 \ -XX:InitialRAMPercentage=65 \ -XX:MaxMetaspaceSize=512m \ -XX:ReservedCodeCacheSize=256m \ -XX:MaxDirectMemorySize=512m \ -Xss1m \ -XX:NativeMemoryTracking=summary \ -jar app.jar

用百分比而不是绝对值,是为了让同一份镜像在规格不同的节点上都能自适配,前提是 JDK 版本能正确读取容器限制。MaxMetaspaceSize显式设置是为了防止元空间无限增长把容器撑爆——这个场景在大量动态生成类的应用里非常常见。ReservedCodeCacheSize收一点,是为了在类加载多、编译活动频繁时有个上限。

NativeMemoryTracking=summary会带来大概 5% 到 10% 的性能开销,我的建议是预发环境常开,生产环境按需开。它的价值在于出问题时能立刻看到内存分类分布,不用靠猜。

5.3 本地库的加载与版本治理

用了 JNI 或者依赖自带本地库的组件,就要建立一套管理规范,否则版本冲突是迟早的事。

第一条,不要依赖环境变量来指定库路径。LD_LIBRARY_PATH是全局的,会影响到进程里所有库的加载,一旦被别的组件污染,排查起来极其困难。规范做法是通过-Djava.library.path指定,或者在容器里把库放到固定的绝对路径下,用全路径加载。

第二条,库文件跟着应用包一起发布,不要从宿主机挂载。挂载看似灵活,实际会造成“同一份镜像在不同节点跑出不同行为”,这是最坑的一类环境差异。

第三条,在启动时打印加载的库清单。可以在应用启动逻辑里加一段诊断代码,列出java.library.path的实际值和系统属性,或者干脆把-Xlog:library=info这类日志打开(具体选项随 JDK 版本变化,需要按版本查一下)。启动日志里能对上的东西,崩溃时才敢下结论。

第四条,升级 JDK 时同步验证本地库。把“本地库兼容性验证”写进升级检查单,包含依赖的运行时版本、结构体布局是否变化、线程模型是否受影响。这一步做过一次,后面就能省下大量的排查时间。

6. 几个容易把人带偏的判断误区

最后说几个我踩过或者看着别人踩过的坑,都属于“看起来合理其实完全错误”的判断,写出来是为了让后来人少绕路。

6.1 “重启后跑了两天没事”说明不了任何问题

这是最高频的误判。崩溃是偶发的,重启只是重置了触发条件,不代表问题消失了。真正的判断标准是:在同等负载、同等数据量、同等运行时长下不再复现,而不是“重启后没人反馈”。

我见过一个模拟项目,崩溃后重启,观察了三天没再出现,于是结案。结果两周后在大促流量下集中爆发,一次挂了六个节点。原因是那次崩溃跟数据规模强相关,平时流量根本触发不到那个代码路径。

所以正确的态度是:看到 fatal error,必须给出一个能解释“为什么崩溃”的明确结论,哪怕结论是“某个已知缺陷,已升级规避”。没有结论的“观察中”,等于没排查。

6.2 OutOfMemoryError 和 fatal error 的边界

这两个经常被混为一谈,因为都能让进程死掉,但机制完全不同。

OutOfMemoryError是 Java 层面的 Error,JVM 还活着,能打印堆栈,能执行finally,能走关闭钩子,也能通过-XX:+HeapDumpOnOutOfMemoryError导出堆快照。它表示“某块内存申请不到”。

fatal error 是 JVM 自身崩溃,进程直接终止,没有 Java 层面的异常栈,只有崩溃日志。

但两者有交集:某些 OOM 场景下 JVM 无法完成异常对象的构造(比如堆已经完全耗尽,连OutOfMemoryError对象都创建不出来),这时会直接 abort,产生 fatal error 日志,日志里可能带着OutOfMemoryError相关字样。还有一种是通过-XX:+CrashOnOutOfMemoryError主动转成崩溃。

判断方法是看有没有hs_err文件、有没有 Java 异常栈。两者都有的话,优先信异常栈,崩溃很可能是 OOM 处理的次生结果。

6.3 只看业务日志永远找不到真凶

业务日志记录的是“应用视角发生了什么”,fatal error 发生在“应用视角之外”。如果进程是被 SIGKILL 杀掉的,业务日志会在某个正常的操作记录之后突然中断,中间没有任何告警信息。这时候去业务日志里从头翻,只会浪费时间。

正确的信息源组合是:业务日志看“崩之前在做什么”,hs_err日志看“崩在哪个层面”,系统日志看“崩的时候系统在干什么”,监控指标看“崩之前资源曲线什么样”。四路信息放在同一条时间轴上对齐,才能还原全貌。我通常会拉一条以秒为单位的时间线,把四个来源的关键事件标上去,这个动作花不了多少时间,但对判断因果帮助极大。

最后分享一个我在实际排查中形成的习惯:每次处理完一个 fatal error,不管结论是“代码问题”还是“环境问题”,都往团队的排查记录里补一条“指纹描述”——这个库崩溃长什么样、这个场景的日志关键行是什么、最后是怎么解决的。攒到十几条之后,下次再看到新日志,第一反应就从“从哪下手”变成“这个特征我见过”,处理速度完全不是一个量级。这件事的价值,往往要到第二次、第三次遇到同类问题的时候才体现出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询