运维群里甩过来一份hs_err_pid18732.log,第一眼扫到的是这两行:SIGSEGV (0xb) at pc=0x0000000000000000和Problematic frame: # C 0x0000000000000000。很多人到这一步就得出两个结论——“JDK 有 bug”或者“机器内存坏了”,然后开始在网上找“如何避免 JVM 崩溃”的文章,最后换了个 JDK 版本,问题暂时没再出现,就当解决了。这种处理方式的真实成本是:三个月后它以另一种面貌回来,而你已经忘了当初是怎么临时绕过去的。
Problematic frame: # C 0x0000000000000000这一行的信息量其实相当大,但它表达的是一个现象而不是原因:崩溃发生的那一刻,CPU 的指令指针寄存器被置成了 0,程序跳到了一个不存在的地址上执行。至于这个 0 是谁写进去的、为什么写进去,日志本身不会直接告诉你,得顺着线程栈、动态库列表、虚拟机参数和信号信息一层层往回推。这篇文章面向的是需要真正定位这类崩溃的 Java 后端、中间件和大数据方向的工程师,从日志字段的物理含义讲起,再到指令指针归零的几条典型来路、拿到日志后的分诊顺序、复现二分的具体操作,最后落到生产环境中应该提前配好的取证参数。看完之后,再遇到这种日志,你应该能在十分钟内给出“大概是哪一类问题、下一步查什么”的判断。
1. 从# C 0x0000000000000000这一行能读出多少东西
1.1 帧类型字母不是装饰,是定位的第一把钥匙
Problematic frame后面那个单字母前缀,决定了接下来所有排查动作的走向。JVM 在生成崩溃日志时会给崩溃点打标签:J表示 JIT 编译后的 Java 方法代码,j表示解释执行的 Java 字节码,V表示虚拟机自身的 C++ 代码(通常能对上libjvm.so里的符号,比如~StubRoutines::call_stub),v表示虚拟机生成的临时代码,C表示原生 C/C++ 代码。
看到C,第一反应应该是“栈上有 native 层的调用”,而不是“JVM 内部出错”。如果这个C后面能跟出一段类似[libxxx.so+0x4a2f1]的地址,那基本可以直接锁到某个第三方原生库的某个偏移上,剩下的就是拿符号表还原函数名。但这里跟的是0x0000000000000000,一个光秃秃的全零地址,连模块都没法匹配——因为没有任何一个被加载的共享库会被映射到 0 地址(现代操作系统都会保留低地址段,防止空指针访问造成隐蔽的逻辑错误)。
所以这一行的准确解读是:崩溃发生时,程序计数器指向了一个未映射的地址,且该地址恰好为 0,JVM 无法把它归属到任何已加载的代码模块,只能原样打印。
1.2 pc 归零在硬件层面到底发生了什么
在 x86-64 上,CPU 取指令时会把rip的值送到地址总线。如果rip是 0,MMU 查页表会发现这个虚拟地址没有有效映射,触发缺页异常,内核判断这不是一个可以按需调页的地址,于是向进程投递SIGSEGV。JVM 的信号处理器捕获到这个信号,记录si_addr(引发故障的内存地址)和pc(崩溃时的指令指针),然后写出hs_err文件。
注意这里有两个不同的寄存器值:si_addr是“访问了哪个地址”,pc是“执行到哪个地址”。在这份日志里,pc=0x0000000000000000,说明 CPU 是在取指阶段挂掉的,也就是说,程序试图执行位于 0 地址的指令,而不是试图读写 0 地址的数据。这两者的区别极大:后者通常意味着空指针解引用(比如Unsafe.putLong(0, x)),前者意味着控制流被劫持到了一个空地址上。
控制流怎么会跑到 0 去?在机器码层面无非三种可能:一是call *%rax这类间接调用指令,而%rax里存的是 0;二是jmp *%rax间接跳转,同理;三是ret指令从栈上弹出返回地址,而栈上那个位置被人写成了 0。这三种情况对应的问题类型完全不同,也是后面分诊的核心依据。
1.3 和其他几种常见崩溃形态的对照
光看pc=0还不够,得知道它不是另外几种情况。下面这张表是我自己在排查时常用的对照,能快速缩小范围:
| 日志特征 | 含义 | 排查方向 |
|---|---|---|
# C 0x0000000000000000 | 执行了空地址,无模块可归 | 函数指针为空、返回地址被踩、栈耗尽 |
# C [libfoo.so+0x1a2b] | 崩在某个原生库内部 | 该库的符号还原、版本兼容性 |
# V [libjvm.so+0x...] ~StubRoutines::call_stub | 崩在 JNI 调用桥 | 入参、局部引用、JNI 环境使用 |
# J 1234 c2 com.foo.Bar.baz()V | 崩在 JIT 编译产物里 | 用-Xint或降编译层级验证 |
# j com.foo.Bar.baz()V+15 | 崩在解释器执行中 | 字节码、类文件异常 |
这张表的用法是:先定位到行,再决定下一步。如果看到的是第三行call_stub,那我可以几乎不看别的内容,直接去翻 native 层代码;如果是第一行,那才需要往下深挖。
1.4Current thread与Stack段给出的边界信息
紧接着Problematic frame之后,日志会打印当前线程信息,包括线程名、线程类型(JavaThread还是CompilerThread)、栈地址范围、sp当前栈指针,以及free space。这几个字段在判断“是不是栈溢出”时是决定性的。
比如Stack: [0x00007f3a2c000000,0x00007f3a2c100000], sp=0x00007f3a2c0ff8a0, free space=3k——栈是向低地址增长的,sp已经非常靠近区间的下界,剩下的free space只有 3K,这说明栈几乎被用光了,此时任何一次函数调用或局部变量分配都可能越过保护页,触发SIGSEGV。栈溢出导致的崩溃有个有意思的特点:si_addr通常是栈下界附近的一个地址,但有时候报告出来的pc看起来也很诡异,尤其在信号处理路径本身也在用栈的情况下。
2. 指令指针被清零的四条来路
2.1 原生层的函数指针为空
这是pc=0最常见的成因,尤其在有 JNI 或 JNA 参与的系统里。典型形态是:Java 侧通过System.loadLibrary加载了一个原生库,这个库内部维护了一些回调函数指针,或者从某个句柄表里取出函数地址。如果取出来的值是空,然后直接调用了它,机器码就是call *%reg,%reg为 0,pc立刻变 0。
函数指针为什么可能是空?排在第一位的是符号解析失败但没做检查。用dlsym拿函数地址,拿到NULL之后没判断就存进结构体,后面调用时直接炸。第二位是库被提前卸载。有些系统为了做热更新或者插件隔离,会在运行时dlclose某个库,但 Java 侧还留着指向该库内部的函数指针,或者原生层留了一份“借来的”函数地址,下次回调就落空。第三位是初始化顺序问题,某个全局函数指针在JNI_OnLoad之前就被使用,此时它还是初始值 0。
这类崩溃的日志特征很明确:Native frames段里能看到一段原生调用链,最顶上那个帧就是0x0000000000000000,再下面是某个libxxx.so的函数,再下面是~StubRoutines::call_stub或者 JNI 入口。看到这个形态,直接去查这段原生链上最后一个“有名字”的函数附近,通常就能找到那个没被赋值的指针。
2.2 返回地址被写坏
ret指令的行为是:从栈顶弹出 8 个字节作为返回地址,然后跳过去。如果这 8 个字节被人覆盖成了 0,ret执行完rip就是 0。这种情况比函数指针为空更隐蔽,因为崩溃点看起来“毫无道理”——上一行代码还好好的,忽然就跳到 0 了。
谁会把返回地址写坏?绝大多数是堆外内存越界写。用Unsafe、ByteBuffer.allocateDirect、JNI 里的malloc或者某些零拷贝框架时,如果长度算错、偏移算错、或者并发下没做同步,写入的数据就可能落到不该落的地方。栈和堆在虚拟地址空间里离得很远,单纯的堆越界一般打不到栈上,但有一条路可以:栈上分配的缓冲区。比如某个原生函数里声明了char buf[256],然后按用户传入的长度往里拷,长度超过 256,就会覆盖同一栈帧或上层栈帧里的返回地址。这种溢出在崩溃前往往毫无征兆,一旦被覆盖的返回地址恰好被改成 0(或者被覆盖后正好是 0 值的内存),下一次函数返回就炸。
排这类问题,最有效的手段是在编译期开栈保护(-fstack-protector-strong之类),让它在覆盖发生时立刻 abort,而不是等到ret时才崩,这样崩溃现场的调用栈才是准确的。另外,如果怀疑是这条路径,可以把本地库用带调试符号的方式重新编译一遍,然后用gdb在崩溃点看x/8gx $rsp,检查返回地址附近的数据是不是有明显的“非代码数据”特征。
2.3 线程栈耗尽撞上保护页
JVM 给每个 Java 线程分配的栈大小由-Xss控制,默认在 x86-64 上通常是 1MB 左右。原生线程的主栈则由操作系统的ulimit -s决定。无论哪种,栈的末尾都会放一个保护页(guard page),没有读、写、执行权限。当栈指针一路下压撞上保护页时,内核投递SIGSEGV。
这种崩溃的日志有两个辨识特征。第一,Stack段的free space极小,通常只有几 K;第二,Native frames里会出现大量高度重复的帧,或者是某个递归深度极大的原生函数。有时候日志里还会出现StackOverflowError相关的记录,但也有很多时候来不及抛异常,直接就在信号处理里崩了。
我遇到过一次非常典型的误判:某服务偶发pc=0,所有人都在怀疑 JIT,结果把Native frames一帧帧翻下去,发现是某个原生 JSON 解析库在处理超深嵌套结构时递归调用自己,栈耗尽后压到了保护页上。修复方式是加一个嵌套深度上限,问题立即消失。这个过程告诉我一件事:看到pc=0先别急着往“玄学”上想,先数一数栈还剩多少。
2.4 JIT 编译器产物或虚拟机自身的边角问题
这条路径确实存在,但概率远低于前三条。-XX:+TieredCompilation的多层编译模型下,C1 和 C2 会基于运行时profile做出一些激进假设,比如某个接口调用点只观察到过一个实现类,于是生成内联缓存或直接调用。如果这个假设在后续被打破,理论上会走去优化路径,但去优化本身也有边界条件。
另一个方向是生成的代码跳到了非法地址。有些场景下,JIT 会生成跨越nmethod边界的跳转,如果nmethod被回收而当前线程还在这段代码里执行,理论上也会出问题——不过现代的 safepoint 机制已经把这个窗口压得很小。
区分方法非常简单粗暴:加上-Xint全解释执行跑一遍。如果-Xint下再也不崩,那几乎可以确定问题在编译产物这条路上,接着把-XX:TieredStopAtLevel=1(只用 C1)和-XX:-TieredCompilation(只用 C2)分别试一遍,就能把范围缩到具体的编译器层级。
3. 拿到 hs_err 之后我实际按什么顺序看
3.1 头三段定基调:信号、线程、摘要
拿到日志不要从头往下读,先看前三段。
第一段是头部信息,包括SIGSEGV的行号、pc值、进程号和线程号、JRE 版本和构建号、操作系统的uname输出。这里要留意的是信号编号和si_addr。如果si_addr也是一个极小的值(比如0x8、0x10),说明是访问空对象附近的内存,属于空指针解引用类;如果si_addr是一个看起来正常但未映射的地址,那可能是悬垂指针。而如果只看到pc=0而没有si_addr段(有些配置下不打印),那就要靠后面的栈信息。
第二段是S U M M A R Y,这里面有崩溃线程的类型和状态,还有当前执行到的方法。如果崩溃线程是CompilerThread2,那几乎可以锁定 JIT 有问题;如果是JavaThread且状态是_thread_in_native,说明正在执行原生代码;如果是GC Thread,那是另一类问题。这一步的判断能省掉大量无用功。
第三段是Current thread详情,包括线程名、栈范围、sp、free space、线程状态、持有的锁信息、是否处于 safepoint。这一段我主要看free space和线程状态,前者判断栈溢出,后者判断是不是在 JNI 边界。
3.2Native frames与Java frames的交叉对照
这两段的价值在于把 Java 调用栈和原生调用栈对上。理想情况下,你能看到类似这样的结构:
Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code) C 0x0000000000000000 C [libnativedemo.so+0x1f2a] do_callback+0x3a C [libnativedemo.so+0x20b1] process+0x71 V [libjvm.so+0x...] ~StubRoutines::call_stub J 1122 c1 com.example.Demo.invoke(J)V (12 bytes) @ 0x...从下往上读:Java 方法Demo.invoke被 JIT 编译成 C1 代码,通过call_stub进入原生库,原生库的process调do_callback,do_callback里调用了一个空地址,崩了。这个链条已经把答案写在那儿了——问题就在do_callback里调用的那个函数指针。
如果Native frames段只有一行C 0x0000000000000000,下面直接就是V ~StubRoutines::call_stub,那说明空调用发生在原生入口的最开头,通常是JNI_OnLoad里注册的函数表某一项为空,或者 Java 侧调用的 native 方法根本没找到实现。
3.3Dynamic libraries段是排除法的依据
这一段会列出进程加载的所有共享库及基址。它的作用不是“看”,而是“对比”。把这份列表和正常环境的列表做个 diff,多出来的那些就是可疑对象。特别留意:非 JDK 自带的、版本号看着很新的、名字带native/jni/nativ字样的、以及同一个库出现两个不同路径的(说明有重复加载,非常危险)。
有一次我在一处崩溃日志里发现加载了两个不同版本的同一个原生压缩库,一个由应用直接加载,一个作为某框架的依赖被间接加载。两个版本的内部结构体定义不一致,导致一方传过去的句柄被另一方按不同布局解析,最终在回调时取到空指针。清理依赖后问题解决。
3.4Events段里的异常被忽略了太多次
日志后半部分的Events (N events)和Internal exceptions段经常被跳过,但它们记录了崩溃前的一批异常事件,包括类加载失败、NoSuchMethodError、OutOfMemoryError之类的发生次数。这些异常本身不致命,但会改变程序的执行路径,进而让某些未被充分测试的分支被触发。
我见过一个案例:崩溃前的日志里有几十条java.lang.NoClassDefFoundError,全是同一个可选依赖。框架捕获了这些异常并走了降级路径,而降级路径里的某个原生回调注册没有做完,最后回调时拿到空指针。如果没有看这一段,很难把“崩溃”和“缺个包”联系起来。
4. 把偶发崩溃压缩成可控问题
4.1 先用-Xint一刀切掉 JIT 嫌疑
在崩溃复现概率不高、又急着缩小范围的时候,最快的一刀是-Xint。全解释执行会慢十倍以上,但它能立刻回答一个问题:这崩溃和 JIT 有没有关系。
操作上,我一般会准备两套启动脚本,一套正常运行,一套额外加启动参数:
java -Xint -XX:-UseCompressedOops -XX:ErrorFile=/var/log/app/hs_err_%p.log -jar app.jar解释执行下跑一轮完整的业务回归,如果崩溃不再出现,那基本可以确认问题在 JIT 编译产物这条线上,接着用-XX:TieredStopAtLevel=1和-XX:-TieredCompilation分别测,把范围从“JIT”缩到“C1”还是“C2”。如果-Xint下照样崩,那就把精力全部转到原生层和栈上,不用再在编译器上花时间。
注意:
-Xint会改变时序,很多并发问题在慢速执行下反而不复现。所以“-Xint下不崩”只能说明“和 JIT 或时序相关”,不能说明“问题出在 JIT”。这一点必须分清,否则容易冤枉一个无辜的编译器。
4.2 原生库做二分,一次只摘一个
确认问题在原生层之后,下一步是二分。把Dynamic libraries里的第三方库列出来,一次只移除或禁用一个,跑足够的样本量看崩溃率变化。这里的关键是样本量要够——如果原来是一天崩一次,跑十分钟没崩什么也说明不了。
我的做法是先按可疑度排序:最近新加进来的排最前,有自定义 JNI 代码的排第二,版本经历过升级的排第三,纯老牌稳定的库排最后。然后从最可疑的开始,一次改一个变量,用同样的压测脚本跑同样的时长。这个过程很笨,但它的结论最硬,不会骗人。
4.3gdb挂 core 的正确打开方式
如果环境上留了 core 文件,那是最高价值的证据。挂载方式:
gdb /path/to/java /path/to/core.18732进去之后,我最先看三个东西:
(gdb) info registers rip rsp rbp (gdb) x/16i $rip (gdb) bt 40x/16i $rip在pc=0的情况下反汇编不出任何东西(因为 0 地址没有内容),但它的失败本身就是信息。真正有用的是bt,如果栈没被破坏,bt能直接把调用链打出来,比hs_err里的Native frames更接近真相,因为它带上了帧指针和局部变量的位置。接着可以针对某个可疑栈帧执行info frame、info locals、x/32gx $rsp,看栈上的数据布局。
如果bt报Backtrace stopped: previous frame inner to this frame (corrupt stack?),那恭喜,基本确认栈被写坏了,接下来就是在栈上找可疑数据了。
4.4jhsdb带来的 Java 视角
JDK 自带的jhsdb可以直接对 core 做 Java 层分析,不需要额外装东西:
jhsdb jstack --exe /path/to/java --core /path/to/core.18732 jhsdb jmap --exe /path/to/java --core /path/to/core.18732 --heapjstack能还原崩溃时所有 Java 线程的栈,看哪几个线程卡在 native 方法上;jmap能看堆的整体分布,判断是不是内存压力导致 GC 线程异常。两个命令配合gdb的原生视角,能把“Java 在做什么”和“机器在做什么”对齐。
async-profiler在崩溃现场的用处有限(它主要服务于运行中的进程),但它可以在复现压测时用来观察原生调用热点,如果某个原生函数占用了异常高的采样比例,那它就是嫌疑对象。
5. 四类现场的具体处置手法
5.1 栈溢出伪装成的空指针崩溃
这类问题的迷惑性在于:日志里明明白白写着pc=0,看起来像函数指针问题,实际上根子在于栈耗尽。判据是free space极小,加上Native frames里有大量重复的相同函数。
处置思路有三层。第一层是把坑挖大:调大-Xss,比如从默认的 1MB 调到 4MB 或 8MB,如果崩溃消失或频率显著下降,那基本确认是栈的问题。第二层是把坑填掉:找到造成深递归的代码,可能是递归算法本身,也可能是序列化/反序列化库在处理超深嵌套,或者是正则表达式的回溯爆炸。第三层是在入口设限:在解析入口处对输入深度、曲线长度、嵌套层数设置硬上限,超限直接拒绝。
第二层的定位有个小技巧:在Native frames里找到那个重复出现的函数,然后拿它的名字去源码里搜,看它是不是自己调自己。如果是递归函数,看一下递归的终止条件在什么情况下会失效——很多时候问题是“空入参”或者“非法结构”导致的无限下降。
5.2 热加载或模块卸载导致的回调失效
有些系统为了做到运行时不重启就更新业务逻辑,会在运行期动态加载和卸载模块。如果某个模块里有 JNI 代码,卸载时没有把所有外部持有的函数指针清理干净,后面任何一次回调都会踩到已释放的内存或者空指针。
这类崩溃的日志有一个很明显的伴生特征:Dynamic libraries里会出现同一个库的多个版本,或者出现某些已经dlclose但仍留在列表里的库。另外崩溃往往发生在“刚开始更新”或者“更新后运行了一段时间”这两个时间窗口里。
处置方式我总结成两条硬规矩。第一条:任何持有原生函数指针的对象,必须在模块卸载前显式注销,不能指望 GC,因为原生指针在 GC 眼里就是几个整数。第二条:模块卸载后,所有调用方要么同步更新到新版本,要么在卸载前明确禁止调用,不要依赖“反正不会有人调”的侥幸。如果框架层做不到这两条,那就老老实实改成进程级重启,成本比半夜排障低得多。
5.3 堆外内存越界写坏函数指针
这是最难排的一类,因为崩溃现场和真正的问题点之间的时间距离、空间距离都可能很大。hs_err里看到的pc=0,可能是几秒甚至几十秒前某次越界写的后果。
排查手段有两招。第一招是给堆外内存加边界标记:在每块分配的堆外内存前后加固定的魔数(比如0xDEADBEEF),定期(或者在关键回调前)检查魔数有没有被改动。这需要改造分配器,成本不低,但一旦上线,能在越界发生的位置立刻报警,而不是等到很久以后的崩溃。第二招是用带地址检查的内存分配器做复现:把malloc换成带保护的实现,越界访问会当场触发异常,崩溃点直接指向肇事代码。
还有个更省事的办法:如果代码里用了Unsafe.putLong、Unsafe.putAddress这类方法,用 grep 全量扫一遍,逐个核对偏移量的计算。我见过一个 bug,偏移量用的long在乘法时溢出回绕,算出一个小地址,然后putLong就把数据糊到了低地址段上。这类问题靠看代码比靠看日志快。
5.4 JIT 边角问题与版本对照实验
如果前面三条都排除了,剩下的才轮到 JIT 和虚拟机自身。此时最有价值的手段不是读源码,而是版本对照。
具体做法是:把 JDK 版本、操作系统、CPU 架构三个维度的组合列出来,在不同组合上跑同样的压测。如果崩溃只在一个特定版本上出现,那大概率是已知问题,对应的更新版本里通常已经修了。如果换机器就不崩,那可能和 CPU 指令集特性或者内核版本相关。
验证时也可以加一些辅助参数来收集信息:
java -XX:+UnlockDiagnosticVMOptions -XX:+ShowHiddenFrames \ -XX:+LogCompilation -XX:LogFile=/var/log/app/comp.log \ -XX:ErrorFile=/var/log/app/hs_err_%p.log -jar app.jar-XX:+LogCompilation会记下所有编译决策,崩溃前最后几个被编译的方法往往就是嫌疑对象。把comp.log里最后几段编译记录和崩溃时的Native frames对照,如果崩溃点落在某个刚被编译完的nmethod里,这个方向就值得深挖。
6. 生产环境该提前留好的证据链
6.1 一份能用的启动参数清单
崩溃发生的那一秒,你能拿到什么证据,完全取决于崩溃前配了什么。下面这套是我在多数生产服务上都会加的:
java \ -XX:ErrorFile=/var/log/app/hs_err_%p.log \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/var/log/app/ \ -XX:OnError="/usr/local/bin/collect_crash.sh %p" \ -XX:+CreateCoredumpOnCrash \ -Xlog:os+signal=info:/var/log/app/signal.log:time,uptime \ -jar app.jarErrorFile指定崩溃日志落盘路径,用%p带上进程号,避免多实例互相覆盖。HeapDumpOnOutOfMemoryError只在 OOM 时生效,对SIGSEGV没用,但同一次故障里两类问题常常一起出现,加上不亏。OnError允许你在崩溃后立刻执行一个脚本,把机器状态、线程状态、dmesg尾部、当前时间的内存信息一并抓下来,这些信息在重启后就没了。CreateCoredumpOnCrash让 JVM 主动尝试生成 core(默认行为,但显式写出来更放心)。-Xlog:os+signal把信号处理过程记到独立文件,排查信号被谁拦截、处理了几次时很有用。
那个collect_crash.sh内容不用复杂,关键几条:
#!/bin/bash PID=$1 TS=$(date +%Y%m%d%H%M%S) DIR=/var/log/app/crash-$TS-$PID mkdir -p "$DIR" dmesg -T | tail -200 > "$DIR/dmesg.tail" cat /proc/$PID/status > "$DIR/proc.status" 2>/dev/null cat /proc/$PID/maps > "$DIR/proc.maps" 2>/dev/null cp /var/log/app/hs_err_*.log "$DIR/" 2>/dev/nullproc.maps尤其重要,它能告诉你崩溃瞬间进程虚拟地址空间的真实布局,比hs_err里的Dynamic libraries段更完整。
6.2 容器环境下的几个坑
在容器里跑 Java,core生成的默认行为经常不生效,原因有三个。一是容器的ulimit -c默认是 0,需要在启动命令或者编排配置里显式设置;二是core_pattern可能指向宿主机的某个路径,而容器内没有写权限;三是容器文件系统层可能是只读的,core 写不进去。
对应的处置:启动时设置ulimit -c unlimited,把core_pattern改成容器内可写路径,或者干脆用-XX:ErrorFile和OnError脚本把关键信息抓出来,不依赖 core。另外容器的内存限制会触发SIGKILL而不是SIGSEGV,这种情况下 JVM 什么都来不及写,日志是空的。所以看到“进程没了但没有 hs_err”,先怀疑是被外部杀了,去查内核日志和编排层的事件记录。
6.3 崩溃日志的归档与聚合
单机上的hs_err只能说明一次崩溃,只有把它聚合成时间序列,才能判断是偶发还是规律性发生。我一般的做法是把hs_err里的关键字段抽出来存一份结构化记录:时间戳、pc值、Problematic frame首行、崩溃线程类型、free space、VM Arguments的指纹。这些字段足够做聚类,能看出“最近三天全崩在同一个原生库上”还是“每次都是不同的随机地址”。
聚合还有一个额外好处:能发现崩溃模式的变化。比如原来都是pc=0,某次升级后变成了pc=<某个有效地址>,说明问题性质变了,之前的结论不能直接套用。
最后分享一个我自己在实际操作中的体会。这类“看起来毫无头绪”的崩溃,真正难的不是技术,而是忍住不去改代码。人在压力下很容易想着“先加个 try-catch 兜住”“先换个 JDK 版本试试”,这些动作会让现场消失,也会让真相消失。我的做法是先花二十分钟把日志里的每一段都读一遍,把Native frames从下往上抄一遍,把Dynamic libraries和正常环境的 diff 打出来——往往在这个过程进行到一半的时候,答案就自己浮出来了。日志从来不会撒谎,它只是需要你把每一行都当回事。