我第一次遇到 JVM Crash,是在一个业务刚开始扩大使用范围的时候。
服务会在高峰期出现大量 5xx,随后进程自动重启。奇怪的是,崩溃之前没有明显的 GC 异常,CPU 和内存也没有出现持续抖动。日志里偶尔出现一些难以关联的空指针,但无法稳定复现。
普通 Java 异常通常只会影响一次请求。JVM 进程直接退出,意味着问题可能已经越过应用代码边界。
先让服务能够继续运行
排查根因之前,首先要降低业务影响。
我们增加了服务资源,并通过平滑重启减少进程退出带来的中断。这些措施没有解决问题,但让服务在崩溃发生时能够更快恢复,也为后续排查争取了时间。
止损和根因分析需要分开。线上问题中,最先实施的方案不必最接近根因,只要它能以可控代价缩小影响。
几个没有奏效的方向
最初的注意力集中在 JVM 本身。
我们检查了 GC、线程和资源使用,也尝试调整编译相关参数。关闭分层编译以后,崩溃频率有所变化,却没有消失。
随后又咨询了有相关经验的同事,陆续尝试了一些 JVM 参数。因为现象像虚拟机问题,我们还升级到当时较新的 JDK 版本,并向官方提交了错误报告。
这些动作都不能算毫无依据,但结果说明它们没有触及真正原因。
排查时间变长以后,很容易因为已经投入大量精力而继续沿着原方向深入。实际上,每次调整都应该被当成一次假设验证:现象是否发生变化,变化能否稳定复现,它支持还是削弱原来的判断。
崩溃文件里的异常线索
转折来自一次保留下来的 crash 文件。
其中出现了一个 AbstractMethodError。这个错误通常意味着运行时看到的类定义与调用方预期不一致。沿着相关对象继续追查,我们把目标从 JVM 调优转向了对象的生成方式。
这个对象并不是普通代码直接创建,而是由一个序列化组件反序列化得到。组件在特定情况下生成了不一致的类结构,最终触发运行时异常,并进一步导致 JVM 崩溃。
替换序列化方式以后,服务不再出现同类问题。后来我们也把现象反馈给了相关开源项目,并用同样的线索帮助其他团队处理过类似故障。
为什么前面的判断会偏离
JVM Crash 很容易让人把注意力全部放在虚拟机。
但进程崩溃只是一种结果,根因可能来自 JDK、JNI、Unsafe 操作、字节码生成或第三方序列化组件。应用使用的库同样可能进入虚拟机边界以内。
当时一些调参带来了频率变化,也进一步强化了“这是 JVM 编译问题”的判断。但相关性并不足以证明因果。参数改变了代码编译和执行路径,可能只是让问题更难触发,而不是消除了原因。
这次排查留下的经验
遇到罕见故障时,广泛尝试并不完全可避免。关键是保留每次尝试的条件和结果,避免让临时变化变成结论。
另一方面,崩溃文件、线程转储和系统日志即使难以理解,也比根据现象猜测更接近事实。真正的突破往往来自一个能够缩小范围的异常,而不是更多参数组合。
这次问题让我第一次明确感受到:排查深度不取决于知道多少 JVM 名词,而在于能否不断修正假设,直到现象、证据和最终改动能够互相解释。