资讯详情

JVM fatal error与hs_err_pid日志排查指南

📅 2026/11/2 5:04:41 | 华诺云谱 👁 阅读
JVM fatal error与hs_err_pid日志排查指南
运行报错时日志里蹦出A fatal error has been detected by the Java Runtime Environment:这一行我估计不少人的第一反应是心里咯噔一下——这跟普通的空指针、类型转换异常完全不是一回事。前者是 JVM 自己都撑不住了直接把命交了出去后者只是应用程序层面没写好。这条fatal error通常意味着 Java 虚拟机在运行过程中遭遇了无法在 Java 层捕获的致命问题可能是本地内存越界、可能是 JNI 层的野指针、可能是容器内存配额被击穿、也可能是 JIT 编译器在某个特定字节码序列上翻了车。它会顺手在进程工作目录下吐出一个hs_err_pidPID.log文件这个文件是整起事故唯一的黑匣子。这篇文章面向的是需要真正落地解决问题的人线上服务突然挂掉需要快速止血的运维和开发、被hs_err_pid日志里几千行汇编和寄存器劝退的中级工程师、以及想提前给系统加固、不想在凌晨被电话叫醒的架构同学。我不打算堆概念而是按我实际排查这类崩溃的顺序来讲先教你把这坨日志切出有用的十几行再倒推根因分类然后走一遍可复现的排查流程最后附上常见问题的速查表和几个真金白银换来的坑。看完你至少能做到两件事遇到同类报错不再乱改参数以及知道哪几个开关必须在服务上线前就打开。1. 先把日志读明白这行报错到底在说什么1.1 hs_err_pid 文件才是真正的第一现场打眼看去这行报错只在控制台留了一句A fatal error has been detected by the Java Runtime Environment:但真正的信息全在崩溃瞬间生成的那个日志文件里。文件名格式固定是hs_err_pid进程号.log默认落在 Java 进程的当前工作目录如果那个目录没有写权限JVM 会退而求其次写到/tmp。我在做故障复盘时最怕碰到的情况之一就是服务容器被重建、临时目录被清空或者启动脚本里cd到了某个只读路径导致这个文件根本没落地——那种情况下你手上只有一个退出码排查难度直接翻好几倍。所以第一件事不是急着看栈而是确认文件在哪。可以用-XX:ErrorFile/var/log/jvm/hs_err_pid%p.log把路径钉死到一个持久化目录%p会被替换成真实进程号。这个参数我建议写进所有生产环境的启动模板里属于一次性投入长期受益的配置。顺手再配上-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath目录虽然堆转储跟崩溃日志不是一回事但两者经常同场出现一次性配齐省得后续再改启动参数。另外要提醒一点hs_err文件的生成过程本身可能失败。如果进程是在内存已经彻底耗尽的状态下崩溃的JVM 试图写日志时再去申请内存就会再次失败于是你只能看到一个零字节的文件或者干脆没有。碰到这种情况去看系统层面的dmesg输出、看看有没有被内核的 OOM Killer 干掉往往能得到补充信息。1.2 崩溃日志的六个必读段落一份完整的hs_err_pid日志动辄两三千行从寄存器快照到全部线程栈再到环境变量全都有硬着头皮从头读会直接看麻。我的习惯是只抓六个区块按顺序扫一遍八成问题在五分钟内就能定方向。第一个区块是文件开头二十行以内。这里会写明错误类型比如SIGSEGV (0xb)、SIGBUS、SIGILL、SIGFPE还会写明是哪个线程触发的、是不是在编译线程里、JVM 版本和编译模式是 mixed 还是 interpreted。看到SIGILL我第一反应就是 CPU 指令集不兼容看到崩溃线程名是C2 CompilerThread之类的基本可以把嫌疑集中到 JIT 上。第二个区块是Problematic frame那一行。这是全文价值最高的单行信息格式一般是C [libz.so0x8a12]或者j com.xxx.Foo.bar()V或者V [libjvm.so0x...]。C开头代表问题出在本地代码也就是 native 库j开头代表 JVM 认为问题在 Java 代码里V代表虚拟机内部生成的代码。C是最常见的它直接把嫌疑锁定到某个.so文件上。第三个区块是siginfo里的si_code。SEGV_MAPERR表示访问了一个压根没有映射的地址通常是空指针或野指针SEGV_ACCERR表示地址存在但权限不对通常是往只读段写数据或者栈溢出撞到了保护页。这两者的排查方向差别很大前者往指针生命周期上想后者往递归深度、大数组、栈帧大小上想。第四个区块是堆和内存的摘要信息。日志里会有Heap:段列出 GC 算法、各代容量与使用量会有ClassLoader或者元空间相关数字如果是本地内存分配失败还会有一段醒目的Native memory allocation (mmap) failed to map xxx bytes for committing reserved memory这种就不是访问违例而是真的申请不到了方向立刻转到配额和限制上。第五个区块是Dynamic libraries把所有加载进来的.so及其地址范围列了一遍。排查本地库冲突时这份清单特别有用你经常会发现同一个库的两个版本同时被不同组件加载或者某个第三方组件偷偷塞进来一个年代久远的库。第六个区块是VM Arguments和Environment Variables。别小看这两段很多崩溃的根源就是某个参数在容器里被改写、某个环境变量把 C 库的 malloc 行为切换掉了。我在一次事故里就是靠环境变量段发现MALLOC_ARENA_MAX被设置成了一个极端值导致线程数上去之后内存分配行为异常。2. 五类高频根因与判断依据2.1 SIGSEGV 与访问违例最像玄学的一类访问违例是这类崩溃里占比最高的一档我粗略估一下线上真实案例里一多半都是它。它的典型特征是程序平时跑得好好的某个特定请求进来、或者某个定时任务触发、或者跑了几小时之后突然崩掉重启后又恢复如初让人误以为是偶发。真正的偶发极少绝大多数是可复现的并发或内存生命周期问题。最常见的来源是本地库JNI对内存的处理不规范把一个 Java 传过去的直接缓冲区地址缓存了下来等 Java 层那次 GC 之后缓冲区被回收本地库再去访问这块已经归还的地址直接就是一次访问违例。这类问题一定会绑定在某个方向的流量或者某个特定的数据形态上所以你抓到崩溃时的Problematic frame再对一下出事前最后处理的请求特征往往能猜到七八分。第二个来源是直接内存DirectByteBuffer的释放时机。Java 层的清理依赖虚引用队列如果应用创建的缓冲区又大又频繁而清理线程跟不上就会出现账面上堆内存很闲进程内存却一路飙升的局面最终在本地分配处直接失败。这类问题用-XX:MaxDirectMemorySize限制住配合本地内存跟踪很容易确认。第三个来源是并发下的对象复用。本地缓存、对象池、线程本地变量在网络层和序列化层里用得极多一旦某处归还逻辑漏了或者提前归还了另一个线程拿到同一块内存再写崩溃点会飘忽不定Problematic frame每次都不一样这种漂移的崩溃点本身就是很强的信号。2.2 内存账本堆外、直接内存、线程栈、元空间JVM 的进程内存远不止堆那部分这个道理大家都懂但真出事的时候还是会本能地去jmap看堆然后发现堆只用了三成陷入迷茫。我习惯把进程内存当成一本账来做减法堆内存、直接内存、元空间、线程栈、代码缓存、GC 自身的辅助结构、本地库自己申请的内存、内存映射文件。容器里的RSS就是这个账本的总和任何一项失控都能把进程顶到配额上。线程栈这一项值得单独讲。每个 Java 线程都要向操作系统申请一块栈空间默认大小在 1MB 上下-Xss可以调。业务里有大量线程池、每个线程又嵌套了很多层调用线程数上千的时候光栈就要一个 G 起步。要是某个请求里有深层递归或者某个序列化框架递归解析一个畸形嵌套结构栈就会一直长到撞上保护页触发SIGSEGV而si_code往往就是SEGV_ACCERR。这类崩溃在日志里能明显看出崩溃线程的栈非常深Java frames段落会有几十上百层相同的调用。元空间和代码缓存的问题则更隐蔽。动态生成类的框架各种代理、表达式引擎、热加载、模板编译如果不加约束会让类元数据持续膨胀JIT 编译产生的机器码挤在代码缓存里一旦缓存被填满正常情况下 JVM 会退化成解释执行但如果缓存碎片严重或者存在某个大方法反复编译也可能引出异常路径。-XX:MaxMetaspaceSize和-XX:ReservedCodeCacheSize我都建议显式设置别全靠默认值。2.3 运行环境容器配额、C 库、指令集、句柄如果说前面两类是代码问题这一类就属于环境问题也是最容易被忽略的。容器化之后JVM 看到的机器和真实机器之间隔了一层很多老的监控和排查经验直接失效。内存配额方面容器被限制 4G而 JVM 的默认最大堆是根据宿主机的可见内存算出来的或者容器感知参数没配对结果堆上限设得比配额还高进程刚爬升到一半就被内核带走了。这类情况在日志上的表现是被强制终止、看不到hs_err或者能在日志里看到Out of Memory Error但Native memory allocation后面的字节数远超预期。我一般把堆上限压到容器配额的 60% 到 70%剩下的留给直接内存、元空间、线程栈和本地库宁可少用一点也别顶到临界。线程数和句柄数也是硬约束。容器里有pids限制宿主机上有ulimit -u/proc/sys/kernel/threads-max又是另一道关卡任意一道被越过创建线程就会失败。有意思的是这种失败抛出的异常信息未必是创建线程失败有时会以OutOfMemoryError: unable to create native thread的形式出现如果没有被正确捕获接着就可能演变成更严重的状态。文件句柄同理ulimit -n太小会导致连接建立失败、文件打开失败某些本地库在这种错误上的错误处理并不严谨进而引发更深的崩溃。C 库和指令集这块比较硬核。不同发行版的 C 库版本差异、同一台机器上多个版本共存、某个本地库编译时链接的是新版本符号而运行环境里只有老版本这些问题在加载阶段就可能失败也可能在运行到某个特定函数时才爆出来。指令集不兼容更典型本地库为某个较新的向量指令集做了编译优化运行环境是虚拟化过的 CPU 或者老机器一执行到那条指令就是SIGILL。这类问题有个非常干脆的判断方法——看崩溃是否只发生在某一批机器上而这几台机器的架构或内核版本明显不同。2.4 JIT 编译与字节码增强纯粹由 JIT 引发的崩溃比例不高但一旦发生特征非常鲜明也最让人抓狂因为它跟你的业务代码逻辑毫无关系。典型症状是同一个请求在高频调用几十万次之后突然崩一次崩溃线程落在编译线程上Problematic frame指向虚拟机内部的编译代码或者用解释模式-Xint跑同样的压力完全不复现。碰到这种我用的判断手法很简单加-XX:-TieredCompilation或者干脆-Xint跑一轮压测如果崩溃消失基本能锁定到即时编译路径。接下来用-XX:CompileCommand把可疑的方法或包排除掉编译缩小范围同时把 JVM 版本升级到最新的小版本——编译器相关的问题修复得相当频繁跨几个补丁版本就消失的情况我遇到过不止一次。字节码增强是另一个隐形雷区。各种 agent 在类加载时改字节码大多数时候没问题但如果增强逻辑对某些边界情况处理不当生成了不合法的栈帧、错误的方法表或者不对齐的局部变量表验证器未必每次都拦得住最后可能在执行到某段机器码时出错。判断方法是把所有 agent 去掉跑一轮看是否复现如果去掉就好的再逐个加回来做二分。这类问题和业务代码的关系往往藏在某个类被增强后方法体过大这种细节里值得耐心拆。2.5 硬件与内核层面的沉默杀手还有一类根因说出来容易被人当成甩锅但确实存在内存条故障、CPU 过热降频、磁盘坏块、文件系统异常。内存故障的表现特别有欺骗性——同一个二进制程序跑几个小时之后在某条完全无关的指令上崩溃Problematic frame每次都不一样跟流量无关、跟数据无关重启之后能撑一段时间又崩。这种无规律漂移是内存问题的经典指纹。内核层面也有一些坑。文件系统写入异常会导致本地库读写失败临时目录写满会让某些依赖临时文件的机制失灵透明大页在某些负载下会造成长时间停顿虽然通常不至于崩溃但在极端场景下会放大其他问题。至于容器被 OOM Killer 干掉那压根不会留hs_err只能从dmesg里看到被终止的记录和当时的RSS。我一般会在复盘时把硬件因素排在最后但不会排除。特别是在多台机器只有一台复现换了机器就好了同配置不同批次机器表现不同这三种情况下我会认真怀疑硬件。3. 一套可复现的排查流程3.1 第一步止血与保留现场顺序不能错线上出现这类崩溃第一优先级永远是恢复服务但止血动作必须建立在保留现场的前提下否则你就是把唯一的线索亲手销毁了。我的标准动作是这样的先把崩溃节点从负载均衡里摘掉不让新流量上来但进程先别急着杀紧接着把hs_err_pid文件、崩溃前后的应用日志、监控上的内存曲线、容器的资源使用快照全部打包拷走确认文件都在之后再重启进程或者替换节点。这里面有个容易踩的坑容器编排系统默认会在进程退出后重建容器临时目录里的东西瞬间蒸发。所以我强烈建议把-XX:ErrorFile指到挂载出来的持久目录。另外崩溃往往不是单次如果服务在循环重启你要做的是先停掉重启策略让它停在那儿别动而不是反复重启去撞运气。反复重启的代价不只是影响可用性还会把现场覆盖掉。还有一步值得做如果崩溃不是必现的考虑在重启时带上-XX:HeapDumpOnOutOfMemoryError和-XX:NativeMemoryTrackingsummary再加一个本地内存跟踪的采样周期。这些参数会带来一些开销但在排查窗口期完全值得。等定位完之后再摘掉别把排查态的参数长期留在生产上。3.2 第二步日志精读把三千行切出关键二十行这个环节我有一套固定的动作序列用命令行把关键信息一次性提出来。第一步看错误类型和崩溃线程head -n 40 hs_err_pid12345.log第二步把最关键的几行单独抓出来grep -n -A2 Problematic frame hs_err_pid12345.log grep -n siginfo hs_err_pid12345.log grep -n Out of Memory Error\|Native memory allocation hs_err_pid12345.log第三步看当时的堆状态和线程数量grep -n -A12 ^Heap: hs_err_pid12345.log grep -c ^0x hs_err_pid12345.log第四步看加载了哪些本地库、有没有可疑的重复项sed -n /Dynamic libraries/,$p hs_err_pid12345.log | head -n 80这几个命令跑完你手上就有了二十来行高浓度信息。判断逻辑很直接si_code是SEGV_MAPERR就往空指针和野指针走是SEGV_ACCERR就往栈溢出和写只读内存走Problematic frame指向某个.so就找这个库是谁带进来的、版本是多少出现Native memory allocation failed就直接转去看内存账本和配额。这里补一个实操心得读Java frames那段时从上往下看栈顶附近的几帧往往能看到出事时正在执行的业务方法。这个位置是极有价值的线索——它告诉你崩溃发生在处理什么样的请求路径上。我通常把这一帧拿去过一遍代码看这个路径上有没有调用本地方法、有没有分配大块直接缓冲区、有没有可能在深层递归里。3.3 第三步二分收缩与最小化复现拿到初步方向后最有价值的动作是造出一个稳定的复现。没有复现一切结论都是猜测有了复现剩下的就是耐心。收缩的思路是逐层砍变量。第一层砍参数把可疑的性能参数、GC 参数、本地内存上限、栈大小逐个还原成最保守配置看崩溃是否消失。第二层砍组件把所有字节码增强的 agent 去掉把所有可插拔的本地库去掉尽可能让 JVM 跑在裸的形态。第三层砍负载用压测工具把出事前的流量特征重放出来包括请求体的大小分布、并发度、连接存活时长很多时候复现的关键就藏在某类大请求或者长连接空闲后突然活跃这种特征里。我用过一个很笨但很有效的办法拿二分法砍监控。因为崩溃点飘忽我连续抓了七八份hs_err把它们Problematic frame所在的库做个频次统计出现次数最多的那个库立刻变成头号嫌疑。这个办法不需要你理解每一个栈只需要统计成本极低。曾经就这么把一个第三方本地压缩库给揪了出来——升级到修复版本后崩溃彻底消失。3.4 第四步落到具体的 native 帧上当Problematic frame指向某个本地库时还可以再深一层。日志里那行一般形如C [libfoo.so0x1234a]这个偏移量配合Dynamic libraries里记录的这个库的加载基址可以把地址换算回库内的符号。如果这个库是你们自己编译的带上调试符号直接就能定位到函数如果是第三方库查看它的版本变更记录往往也能找到对应条目。开启 core dump 是另一条路。设置好ulimit -c unlimited配置好内核的 core 文件输出路径崩溃时就会留下整个进程的内存镜像配合调试器可以还原完整的调用栈、变量值、寄存器状态。这条路信息量最大但也最重core 文件动辄几个 G生产环境要考虑磁盘和传输成本。我的建议是只在确认某类崩溃有复现价值、且前几轮常规手段都没结论时才在预发或者单台灰度机器上开 core dump。还有一点很多人忽略如果崩溃集中出现在某个特定 JDK 小版本上去看这个版本的更新说明里有没有相关修复比自己在代码里大海捞针高效得多。编译器、GC、本地内存管理这几块的修复在小版本之间非常密集升级往往就是最省事的解法。3.5 关键参数与动作速查表场景关键动作或参数说明让崩溃日志落到持久目录-XX:ErrorFile/data/logs/hs_err_pid%p.log不要依赖默认的当前工作目录崩溃时保留堆快照-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dump与崩溃日志常常同源排查直接内存是否超限-XX:MaxDirectMemorySize512m显式设置别用无上限排查线程栈占用-Xss512k起步线程多时栈总量是隐形大头排查元空间膨胀-XX:MaxMetaspaceSize512m动态生成类多时必须设判断是否 JIT 问题-Xint或-XX:-TieredCompilation崩溃消失则基本锁定编译路径排除单个热点方法编译-XX:CompileCommandexclude,com.x.Foo::bar用于缩小范围精确定位本地方法-XX:ShowMessageBoxOnError仅限本地调试线上禁用本地内存使用追踪-XX:NativeMemoryTrackingsummary配合jcmd pid VM.native_memory summary排查窗口期开启避免临时目录引发的启动期异常-Djava.io.tmpdir/data/tmp目录需存在且可写这张表里我最看重前两条。它们属于部署层面的基础设施跟具体崩溃原因无关但决定了你有没有资格排查。我在好几个项目里见过启动脚本写着极简的java -jar app.jar所有参数都是默认值出事之后一群人围着黑屏发愁——这种基础工作的缺失比任何技术难题都更让人无奈。4. 常见问题速查与踩坑实录4.1 常见问题速查表现象高频原因排查切入点崩溃点每次都不同毫无规律内存条故障、本地库指针问题换机器验证检查是否只在一台机器复现堆内存很闲但进程内存持续上涨直接内存或本地库泄漏开启本地内存追踪观察各分区增长SIGILL且只在新机器上出现CPU 指令集不兼容检查本地库的编译目标架构大量线程创建后崩溃线程数配额或栈总量超限查pids限制、ulimit -u、-Xss设置崩溃线程名为编译线程JIT 编译问题-Xint验证升级到最新小版本定时任务触发后崩溃本地库在大数据量下的边界问题重放该任务的数据规模容器重启后日志消失临时目录未持久化配置-XX:ErrorFile到挂载目录崩溃前有明显的长时间停顿GC 或本地内存分配抖动观察 GC 日志与进程内存曲线4.2 几个容易忽略的坑第一个坑把所有这类崩溃都归因于JDK 有 bug然后升级完事。这条路偶尔能解决问题但大多数时候你会升级完还是崩而且因为中间动了环境反而引入了新的变量。我的建议是先定位再动手哪怕定位只到某类本地库这一层也比盲升级靠谱得多。第二个坑只盯着堆看。前面反复说过但线上还是经常见到排查时只jmap一下就不看了。堆只是内存账本里的一项而且往往是唯一被管得很好的那一项真正的溢出多数发生在账本里没人看的那半边。第三个坑在生产环境直接开-XX:ShowMessageBoxOnError或者打开极端详细的日志。某些调试参数会让进程在崩溃时挂起等待交互在容器里那就是彻底僵死连重启都要靠强制手段。调试参数只在本地和预发用线上要的是崩了就记下来然后尽快退出。第四个坑忽略重启策略的副作用。崩溃循环里每次都生成一份hs_err文件如果不做清理几百次重启能把磁盘写满磁盘满之后又会引发一串完全不同的报错让原本的线索彻底被淹没。要么限制崩溃重启次数要么给这些日志配一个轮转清理。第五个坑参数之间互相打架。比如堆上限设得很大、直接内存也设得很大、线程池又开了很多、栈还设成默认的 1MB单项看都合理加起来超过容器配额。我现在的习惯是拿一张纸把账算一遍容器配额减去堆、减去直接内存上限、减去元空间上限、减去线程数乘栈大小、再留 20% 余量剩下的才是本地库的活动空间。算不平就改配置别赌。4.3 我个人的几条习惯踩了这么多次坑之后有几个习惯我基本改不掉了。所有服务的启动参数一律走统一的模板-XX:ErrorFile、堆转储路径、本地内存追踪这些必需项在模板里就位应用想覆盖只能在模板基础上加不能全盘替换。每次上线前把容器的内存配额和 JVM 各项上限对着算一遍写进发布检查清单。监控上除了堆使用率一定加一条进程总内存的曲线两条线一起看才能看出堆稳、总内存在涨这种本地泄漏的形态。还有一条经验是关于心态的这类崩溃的排查周期往往比普通业务 bug 长因为它天然涉及跨层信息——Java 层的调用、本地库的实现、操作系统的资源、硬件的行为。第一天没结论很正常重要的是每一轮都有明确的信息增量而不是在同一种猜测里打转。我见过最有效率的做法是建一个共享的排查记录把每一次hs_err的关键行贴进去几轮之后规律自己就浮出来了比一个人闷头想快得多。最后一个习惯可能有点朴素把每次崩溃的根因、定位过程、最终措施记成一条短记录。这类问题重复出现的概率远比想象的高半年后同样的库又崩一次翻出旧记录能让你在一个小时内结束战斗而不是重新走一遍全流程。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑