Java native崩溃:读懂hs_err_pid中的# C 0x0 凌晨两点多被电话叫起来某个业务节点整进程消失了运维只甩过来一句“机器上的 Java 进程没了日志目录多了个 hs_err_pid 文件”。等我把那份日志翻到第二页就看见那行熟悉得让人头皮发麻的字Problematic frame: # C 0x0000000000000000。这不是 OutOfMemory也不是 GC 卡死而是 Java 虚拟机在 native 层直接撞墙退出了连一句像样的 Java 异常都没留给你。这篇东西就是围绕这行字展开的它到底在说什么、为什么地址是 0、哪些写法会稳定复现它、现场该怎么保、日志该按什么顺序读、参数该怎么配。写给谁看写过 JNI 或者维护过带 native 依赖服务的人看最合适但只要你排查过一次进程莫名消失也能从里面的判读方法里拿到东西。1. 读懂这行字Problematic frame 到底指向谁很多人第一次看到这行第一反应是“地址 0那就是空指针呗”然后去翻 Java 代码找 NPE翻三天也翻不出来。方向一开始就偏了。这行输出是虚拟机的崩溃处理器信号处理器在收到致命信号后做栈回溯时打印出来的“出问题的那一帧”它描述的层级是机器码层不是 Java 层。你得先搞清楚它说的是哪个执行单元再谈后面的定位。1.1 三种 frame 前缀决定了你该往哪个方向查虚拟机的栈回溯会给每一帧打一个前缀字母这个字母的信息量极大比后面那串地址有用得多。常见的有这么几类J表示这段是 JIT 编译出来的 Java 方法机器码j表示解释器正在执行的字节码帧V或v表示虚拟机自身生成的代码比如 stub、适配器、运行时例程C表示 native 的 C/C 代码也就是你或者第三方库编译出来的那部分。看到# C方向就非常明确了崩在 native 代码里。它可能是你自己写的 JNI 实现可能是某个带 native 层的中间件也可能是某个看起来纯 Java 但实际上偷偷加载了 .so 的库。我见过最迷惑的一次是一个号称“纯 Java 实现”的加解密组件实际上通过 SPI 加载了一个本地加速库出问题的时候 Java 栈上干干净净全靠这行# C才把嫌疑锁定过去。反过来如果前缀是J那重点就该放在 JIT 编译优化、方法内联、边界检查消除这些方向如果是V那多半是虚拟机的运行时例程比如 safepoint、异常分发、类加载这种往往和版本缺陷或者内存越界有关。前缀不同后面查的东西几乎不重叠所以第一步永远是把前缀看清楚。1.2 一份 hs_err_pid 日志的标准阅读顺序崩溃日志动辄几千行从头读是浪费时间。我习惯固定按这个顺序扫基本三分钟内能定性顺序段落你要找什么1头部信号摘要信号类型、pc 值、pid/tid2Problematic frame前缀字母、地址、有无符号3Current thread线程名、状态_thread_in_native 等4siginfosi_signo、si_code、si_addr 三元组5RegistersRIP 落在哪个模块、关键寄存器是否为 06Native frames / Java frames崩溃点的调用链上下文7Dynamic libraries是否加载了同名多版本、路径是否异常8VM Arguments排查参数是否被覆盖、是否开了奇怪选项头部那段通常是这个样子我裁掉了无关行# A fatal error has been detected by the Java Runtime Environment: # # SIGSEGV (0xb) at pc0x00007f3c8a1b2c40, pid27841, tid27899 # # JRE version: ... # Problematic frame: # C 0x0000000000000000注意这里有个非常关键的细节头部的pc是有一个具体地址的0x00007f3c8a1b2c40看起来完全正常但下面 Problematic frame 却打印了全零。这两个值不一致本身就说明了一件事——真正执行到的位置是在某个模块里的只是虚拟机没能把那个 pc 翻译成函数名。这个矛盾点恰恰是后面第二节要展开的核心。siginfo那一行同样不要跳过它给出的是“为什么崩”而不是“崩在哪”siginfo: si_signo: 11 (SIGSEGV), si_code: 1 (SEGV_MAPERR), si_addr: 0x0000000000000000si_signo: 11就是 SIGSEGV段错误si_code: 1 (SEGV_MAPERR)表示访问的地址没有映射到任何内存区域si_addr是触发故障的那个地址。有的日志这里是SEGV_ACCERR值为 2意思是地址映射了但权限不对——这两种情况的排查方向差别很大MAPERR更像野指针或空指针ACCERR更像踩了只读页或者栈保护页。2. 0x0000000000000000 的两种截然不同含义这是整篇文章最想讲清楚的一点。同一个全零地址背后可能是完全相反的两种情况处理方式也完全相反。搞混了你就会在错误的方向上耗尽一整晚。2.1 符号没找到库被 strip 或代码来自未知模块第一种情况也是最常见的崩溃确实发生在某个正常的函数里但虚拟机做符号解析时失败了于是它把“解析不出来的地址”填成了 0。栈回溯的底层依赖是遍历已加载模块的符号表把 pc 落在哪个模块的哪个符号区间里算出来。如果那个 .so 在上线前被 strip 过发布包为了体积经常这么干或者代码是由某个运行时动态生成的、不在任何模块的符号表里解析就会落空。验证方法很直接去 Registers 段看 RIP 的值再对照 Dynamic libraries 段里各个库的加载基址区间看 RIP 落在谁的范围内。Registers: RAX0x0000000000000001, RBX0x00007f3c00000000, RCX0x0000000000000000, RDX0x0000000000000020 RIP0x00007f3c8a1b2c40, RSP0x00007f3c7a4f8d20, RBP0x00007f3c7a4f8d60只要RIP落在一个合理的可执行区间里那这一帧其实是“有主”的只是名字丢了。这种情况下去追“谁跳到了零地址”毫无意义你要做的是把符号找回来把对应版本的带符号库拿到手用addr2line一类工具反查或者干脆在预发环境用未 strip 的库复现一次。我在一个项目里就这么干过把解析出来的函数名一对照问题立刻清楚了——是一个第三方库内部的对象池回收后又被访问跟业务代码一行关系都没有。还有一种变体pc 落在 JIT 生成的代码区里。JIT 代码没有符号表虚拟机自己心里有数通常会把这一帧标成J而不是C。但如果是从 JIT 代码回调进 native再从 native 里崩掉就可能出现“外层是 C内层信息全无”的混合栈这种最考验耐心。2.2 真的跳到了零地址空函数指针调用现场第二种情况就凶险得多程序真的把控制流交给了 0 地址。这时候你会看到si_addr: 0x0000000000000000且si_code: 1 (SEGV_MAPERR)RIP 也确实是 0 或者极小的值有时候是 0x8、0x10 这种那是因为调用约定的偏移。这种崩溃的根本原因只有一个——某个函数指针变量的值是 NULL然后被当成函数调用了。为什么会有空函数指针在 JNI 世界里最常见的路径是动态符号查找失败却没检查返回值。dlsym找不到符号时返回 NULL它会老老实实告诉你失败了但如果你不判空、不打印dlerror()后面一调用就是直接跳零。这段代码我见过太多次几乎是模板级的错误写法typedef int (*process_fn)(void* ctx, int len); static process_fn g_process NULL; JNIEXPORT void JNICALL Java_com_demo_NativeBridge_init(JNIEnv* env, jclass cls) { void* handle dlopen(libcodec.so, RTLD_NOW | RTLD_LOCAL); if (handle NULL) { /* 这里只打日志不返回后面照样往下走 */ fprintf(stderr, dlopen failed\n); } g_process (process_fn) dlsym(handle, codec_process_v2); /* 缺了判空也缺了 dlerror() */ } JNIEXPORT jint JNICALL Java_com_demo_NativeBridge_doWork(JNIEnv* env, jclass cls, jlong ctx, jint len) { return g_process((void*) ctx, len); /* 这一行就是崩溃点 */ }改成什么样才算合格其实就三条dlopen失败必须提前返回并把dlerror()打出来dlsym失败必须判空并在init阶段就抛异常让问题在启动时暴露函数指针在每次使用前做一次防御性判断。启动时崩比跑了两小时后崩要好一万倍前者十分钟能查清后者要花一整夜。除了dlsym另外两个高频来源是回调注册和 C 虚表。回调注册的经典场景是native 层持有 Java 侧传下来的函数指针或全局引用Java 侧对象被回收了、引用被删了native 侧还在往上调C 虚表的情况则是对象被memset清零过或者已经被析构虚表指针变成 NULL一调虚函数就跳零。这两种在日志上的表征几乎一模一样得靠 Java frames 段有没有对应的调用上下文来区分。3. 按场景倒推五类最容易踩的真凶光看一行日志当然定不了案但把日志特征和代码里常见的坏味道对起来能大幅缩小范围。下面这五类是我这些年遇到频率最高的基本覆盖了七八成的现场。3.1 JNI 函数指针判空缺失最高频这条上面已经说了但值得再强调一次因为它有很强的隐蔽性。很多封装库在初始化时做了容错——查找失败就记一条 warn 日志然后继续跑心里想的是“这个功能用不到就不影响”。结果某天业务真的调到了这个功能进程直接没了。日志级别是 warn报警规则里没配线上跑三个月才炸一次炸的时候谁也想不起来那条 warn。正确的处理思路是把这类错误提升为致命错误RegisterNatives或者动态查找阶段失败就在静态初始化块里抛UnsatisfiedLinkError让服务起不来。启动失败是可控事故运行时崩溃是不可控事故。我现在的习惯是所有 JNI 桥接类的静态初始化块里必写一段自检把关键符号挨个查一遍查不到就直接抛。多花的那几十毫秒启动时间换来的是不用半夜爬起来。3.2 同名库版本错配导致函数表错位这一类更阴因为它不报错。典型场景是这样的基础镜像里带了一份某加解密库的 1.x业务镜像里又通过环境变量或者打包方式塞进了 2.x两个版本的 .so 名字一样但 ABI 不同。动态链接器按搜索顺序加载了先找到的那一份而你在代码里按照另一份的头文件去调入参结构体布局对不上、函数表偏移对不上调用能进去但不一定回得来。判断特征要看 Dynamic libraries 段里有没有重复条目Dynamic libraries: 0x00007f3c88000000 /lib64/libc.so.6 0x00007f3c8a000000 /app/lib/libcodec.so 0x00007f3c8a200000 /usr/local/lib/libcodec.so同一个库名出现两条、路径不同基本可以确定问题在这。解决方式不是改代码是收敛加载路径明确指定LD_LIBRARY_PATH的优先级镜像构建阶段用ldd做一次依赖树检查把不该出现的路径清掉。我现在的做法是在 Dockerfile 最后加一步自检如果发现同一个 soname 出现在两个路径下就直接让构建失败从根上堵住。3.3 栈溢出打穿保护页这一类的日志特征相当明显si_code一般是SEGV_ACCERR而不是MAPERR因为不是访问了未映射区域而是碰到了操作系统放在栈底的那一页保护内存。日志里通常会有一句明确的提示告诉你栈保护页被触碰了同时 Stack 段的sp值会非常接近栈区间的下界。Stack: [0x00007f3c7a4f9000,0x00007f3c7acf9000], sp0x00007f3c7a4f9f80, free space12kfree space12k就是铁证——线程还剩十几 K 栈就到底了。Java 层的深递归一般会被虚拟机拦下来抛StackOverflowError但 native 层的递归没有这层保护一个解析嵌套结构比如深层嵌套的 JSON、XML、表达式树的递归函数遇上恶意构造的输入就能直接把栈打穿。算一笔账默认线程栈大小在多数 64 位平台是 1MB如果你起了 800 个线程光栈就占 800MB 虚拟地址空间这部分是惰性分配的实际物理内存没这么多但地址空间是真占了。真正决定生死的是单线程的栈深度。如果 native 递归每层帧占 200 字节1MB 的栈大约能撑 5000 层把-Xss调到 2M 就翻倍但这属于治标——治本还是得给递归加深度上限或者在业务入口做嵌套层数校验。3.4 CPU 指令集与库编译目标不匹配这一类报的往往不是 SIGSEGV 而是 SIGILL信号值 4但也会以# C帧的形式出现。原因很简单库在编译时启用了较高版本的指令集优化而运行机器的 CPU 不支持。这个在异构集群里太常见了——本地开发机是新款芯片编译镜像的构建机也是新款线上那批老机器的 CPU 少几条指令一跑到热点路径就非法指令。我踩过的最典型一次是某个矩阵计算库在构建脚本里默认开了激进的向量化选项本地跑得好好的上了老机器直接崩日志里帧地址也是解析不出来的一串零。排查方法是在两边机器上分别查 CPU 支持的指令集把差异列出来再对照编译选项。稳妥的做法是按最低基线编译或者编译多份产物按运行时探测选择别图那点性能直接上高基线。3.5 堆外内存与 mmap 的系统级限制严格说这一类不一定表现为崩溃更多时候是Map failed这样的错误但配置不当的时候也可能以信号的形式出现。技术点上主要有两个坑一是映射区域数量上限vm.max_map_count在很多发行版里默认是 65530大内存机器、大量线程栈、大量文件映射叠加起来很容易撞线二是ulimit -v这类虚拟内存限制被容器运行时设得偏小JVM 预留堆空间时就出问题。这里要顺便澄清一个高频误解hs_err_pid出现不等于内存不够。它出现只说明进程收到了不该收到的信号。我看过太多次把崩溃日志误判成 OOM然后去调堆大小调完照崩不误。判断依据是日志开头那段信号摘要SIGSEGV 是段错误SIGBUS 是总线错误常见于映射文件被外部截断两者跟“堆满了”是两回事。4. 一套能落地的排查流程理论聊完说流程。我现在的固定动作分四步每次崩溃都按这个走基本不会漏关键信息。4.1 先保现场core、日志、加载库清单最忌讳的操作是出事之后一条restart把容器重建了。容器一重建core 文件、临时目录、加载的库版本全没了剩下的日志就是个孤证。所以第一件事是确认 core dump 有没有落盘把日志文件原样拷出来同时把出问题的机器隔离掉不要再重启。core dump 这块在容器里坑特别多。宿主的core_pattern如果是管道到某个收集程序core 文件根本不会出现在当前目录而你ulimit -c unlimited设得再对也没用。检查一下cat /proc/sys/kernel/core_pattern ulimit -c如果core_pattern是管道形式得让容器运行时那边的配置配合或者干脆在容器内单独把core_pattern设成相对路径。另外-XX:ErrorFile也建议显式指定到一个有大空间、可写的目录默认写到工作目录容器里工作目录经常是只读的或者挂在很小的临时卷上写满了会引发二次故障。库清单也别落下这个信息在日志的 Dynamic libraries 段里有但现场再抓一份更保险pid$(pgrep -f java.*yourapp | head -1) cat /proc/$pid/maps /tmp/maps.txt ls -l /proc/$pid/fd /tmp/fd.txtmaps里能看到每个映射段的权限和来源fd里能看到打开的文件描述符。这两份东西在事后分析时价值极高尤其是判断“到底是哪一份 .so 被真的加载了”。4.2 六段定位法从 Current thread 到 Native frames日志到手按固定顺序抠六段出来前两段定方向中间两段定位置后两段定嫌疑人。第一段是信号摘要和 Problematic frame确定崩溃类型和层级。第二段是 Current thread看线程状态Current thread (0x00007f3c9000a800): JavaThread worker-7 [_thread_in_native, id27899, stack(0x00007f3c7a4f9000,0x00007f3c7acf9000)]_thread_in_native这个状态值很说明问题——说明线程正处在 native 代码里跟 Java 堆操作无关这时候去查 GC 日志是浪费时间。对应的还有_thread_in_Java、_thread_in_vm、_thread_blocked状态不同后续方向的优先级完全不同。第三段是 siginfo 三元组第四段是 Registers这两段配合着看能确定“真的跳零”还是“符号丢失”。第五段是 Native frames能看出崩溃点外层的调用链有多深、有没有明显的第三方库名字。第六段是 Java frames有时候能从 Java 侧看到一个可疑的调用路径比如某个反序列化入口、某个资源释放钩子。我一般会用几条命令把关键段落抽出来单独看免得在几千行里翻grep -n -A 3 Problematic frame hs_err_pid27841.log awk /Registers:/,/Top of Stack/ hs_err_pid27841.log | head -30 awk /Native frames:/,/Java frames:/ hs_err_pid27841.log | head -40 awk /Dynamic libraries:/,/^$/ hs_err_pid27841.log | head -604.3 用 -Xint 和编译排除做二分如果现场能复现最快的定位手段是关掉 JIT 再跑一遍。加-Xint走纯解释执行性能会掉得厉害但能快速排除“是不是 JIT 编译出来的代码有问题”这个可能性。如果关掉 JIT 就不崩了那基本锁定在编译优化或者内联边界上接下来就是用编译排除精准定位到方法java -XX:UnlockDiagnosticVMOptions \ -XX:CompileCommandexclude,com/demo/codec/NativeBridge::doWork \ -jar app.jar反过来的排除法也很有用-Xcomp强制编译所有方法如果加上之后崩溃频率明显上升说明问题跟编译产物强相关。另外-XX:PrintCompilation打出来的编译日志能对上时间点看看崩溃前正在编译哪个方法常常能发现意外收获。还有一个参数组合专门对付 native 边界问题-Xcheck:jni。它会给所有 JNI 调用加上校验包括参数合法性、引用有效性、数组边界。代价是性能掉一大截只能在预发或者压测环境开。但只要开了它还能复现日志里通常会给出一句非常明确的提示告诉你哪次 JNI 调用参数不对比事后猜快得多。4.4 core 文件回放栈回溯与地址反查有 core 的话直接用系统调试器加载第一件事看完整栈gdb /path/to/java /path/to/core.27841 -ex thread apply all bt -ex quit /tmp/bt.txt如果栈回溯出来大量??说明符号缺失得把带符号的库和可执行文件放到调试器能找到的路径下。缺符号的情况下只能靠地址反查先确定 pc 落在哪个模块再算相对偏移然后用addr2line反查行号addr2line -e /app/lib/libcodec.so -f -C 0x1b2c40如果偏移也算不出来那就退一步用反汇编看那一段指令在干什么重点看崩溃指令的操作数是不是在访问一个为 0 的寄存器objdump -d --start-address0x1b2c00 --stop-address0x1b2c80 /app/lib/libcodec.so看到类似mov (%rax),%rbx而rax是 0 的指令基本就确认是空指针解引用了。这一步虽然原始但在没有符号的情况下几乎是唯一出路我做 JNI 排查那几年靠反汇编读指令救回来的现场不止一次。5. 参数清单与工具对照排查靠流程但流程能不能走通取决于崩溃那一刻有没有留下足够的信息。这部分全是配置层面的东西平时不起眼出事的时候是救命的。5.1 建议常开的几个启动参数下面这套是我现在所有带 native 依赖的服务都会配的成本很低java -XX:ErrorFile/var/log/app/hs_err_pid%p.log \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/var/log/app/ \ -XX:CreateCoredumpOnCrash \ -Xss2m \ -XX:NativeMemoryTrackingsummary \ -jar app.jar逐个说下选择的理由。-XX:ErrorFile显式指定路径避免工作目录不可写导致日志丢失%p带上 pid 防覆盖。HeapDumpOnOutOfMemoryError虽然跟崩溃不是一回事但内存问题和崩溃经常交替出现一起开着省事。CreateCoredumpOnCrash是默认开启的写出来是为了提醒你它依赖系统ulimit配合光有参数没ulimit -c unlimited一样白搭。-Xss2m是给 native 递归留余量具体数值要看你的实际调用深度不是越大越好——线程多的时候栈空间是乘算的。NativeMemoryTracking开销很小但事后能告诉你堆外内存到底花在哪了。有一个参数我要专门提醒-XX:ShowMessageBoxOnError千万别在生产环境开。它会在崩溃时弹一个对话框等人确认在没有图形界面的服务器上表现为进程挂死既不退出也不产生完整日志反而把现场搞得更复杂。5.2 工具与命令速查表场景工具或命令关键点抓当前线程栈jstack或kill -3崩溃后进程已没了只能靠 core看 JNI 调用合法性-Xcheck:jni性能损失大只在预发开看本地内存分布NativeMemoryTrackingjcmd定位堆外增长趋势反查地址对应函数addr2line需要带符号的库看崩溃指令objdump -d确认操作数是否为 0看完整栈gdb 加载 core缺符号时栈会显示??看映射段/proc/pid/maps确认实际加载的是哪个 .so看库依赖树ldd查同名多版本看 CPU 指令支持/proc/cpuinfo对齐编译基线与运行环境表格里每一项我都实际用过其中ldd和/proc/pid/maps这两项最容易被人忽略但恰好是排查“版本错配”类问题的最短路径。尤其maps它反映的是运行时的真实情况比你在构建脚本里写的依赖声明可信得多。6. 常见问题速查与踩坑记录6.1 症状对照速查表日志特征大概率原因首选动作# C 0x0si_addr: 0x0RIP为 0空函数指针调用查所有dlsym/回调注册的判空# C 0x0RIP落在某模块内符号被 strip换带符号库重跑或地址反查si_code: 2 (SEGV_ACCERR)free space很小栈溢出查 native 递归深度信号为 4 (SIGILL)指令集不兼容对比构建机与运行机 CPU信号为 7 (SIGBUS)映射文件被截断查磁盘空间与映射文件大小Dynamic libraries同名两条版本错配收敛加载路径崩溃前刚做过 GC可能是 JIT/屏障相关试-Xint排除崩溃集中在并发高峰数据竞争查共享状态与锁粒度这张表我最常用的其实是第二行和第三行。第二行对应的场景特别容易误判因为日志上写着零地址直觉就去追空指针结果查了三天发现代码里根本没有解引用为空的路径——因为压根不是解引用的问题是符号丢了。6.2 几条花时间换来的经验第一条也是最值钱的一条别急着相信“地址是 0 就是空指针”。先花两分钟对照 RIP 和 Dynamic libraries 段确认是符号问题还是真跳零。这个习惯帮我省掉的无效排查时间比任何工具都多。第二条崩溃日志要和业务日志对齐时间轴。单看崩溃日志只能知道死在哪结合业务日志才知道死之前在处理什么请求、传了什么参数、调了哪个分支。我现在排这类问题第一动作是先grep崩溃时间点前后三十秒的业务日志往往能直接看到“某个超大报文进来”“某个开关被动态打开”这种关键线索。第三条在 native 边界做输入校验比事后排查便宜得多。所有进 JNI 的数组长度、指针有效性、句柄合法性在入口处做一次校验成本是一次比较指令收益是避免整进程退出。我们现在的 JNI 层统一加了一个校验宏任何参数不合法就抛异常回 Java 层让它变成一次可捕获的业务异常而不是一次进程死亡。第四条版本升级后必须做一次 native 兼容性回归。很多崩溃是新版本引入的但表现是概率性的只有压测或者长跑才暴露。我在项目里加了一个简单的长稳测试每天跑一轮小时级的压测专门盯崩溃日志目录有没有新文件生成有就报警。这个投入不大但把“线上炸”变成了“测试环境炸”。第五条遇到确实查不动的学会用排除法缩小到模块级。逐个禁用可选 native 组件跑基线虽然笨但有效。有一次我们把七个可选组件一个个关掉最后锁定到第四个整个过程花了两个小时比在代码里瞎找快了不知道多少。笨办法在信息不足的时候往往是最优解别嫌它土。