ANR 怎么会变成崩溃?——两大 Top 疑难崩溃排查手记
做稳定性治理的同学大多有一个共识:真正难搞的崩溃,往往不是那些堆栈直指业务代码的空指针,而是堆栈躺在系统 so 里、报错信息语焉不详、复现路径飘忽不定的"幽灵问题"。这类问题占比不高,却长期占据崩溃榜前列,消耗着真实的用户体验。
本文复盘Bugly平台上近期Top崩溃的两个典型代表:一个长期霸榜 Top1,崩溃发生在 libart.so 的 dump 路径上;一个位列 Top4,倒在 GC 的标记阶段。两个问题的共性是——第一现场都不在崩溃发生的时刻,需要用"法医"的思路往回倒推。以下是完整的排查过程和结论,供参考。
案例一:Top1 —— ANR 之后,进程为何被 ART 亲手杀死?
现象与数据
该问题在近 90 天内上报崩溃 150万次+,影响业务超 10+,影响用户 60 万+,稳居崩溃榜 Top1。异常类型为 SIGABRT,错误消息非常明确:
Check failed: key_value != nullptr compiler-filter not found in oat header
真实崩溃堆栈如下(已符号化):
#00 pc 00000000000821a0 /apex/com.android.runtime/lib64/bionic/libc.so (abort+176)
#01 pc 00000000004b955c /apex/com.android.runtime/lib64/libart.so (art::Runtime::Abort(char const*)+2172)
#02 pc 000000000000c650 /system/lib64/libbase.so (android::base::LogMessage::~LogMessage()+608)
#03 pc 00000000004471c8 /apex/com.android.runtime/lib64/libart.so (art::OatHeader::GetCompilerFilter() const+280)
#04 pc 000000000044eab8 /apex/com.android.runtime/lib64/libart.so (art::OatFile::GetCompilerFilter() const+40)
#05 pc 000000000045a158 /apex/com.android.runtime/lib64/libart.so (art::OatFileManager::DumpForSigQuit(...)+376)
#06 pc 00000000004c6c88 /apex/com.android.runtime/lib64/libart.so (art::Runtime::DumpForSigQuit(...)+104)
#07 pc 00000000004daebc /apex/com.android.runtime/lib64/libart.so (art::SignalCatcher::HandleSigQuit()+1356)
#08 pc 00000000004d9f0c /apex/com.android.runtime/lib64/libart.so (art::SignalCatcher::Run(void*)+252)
#09 pc 00000000000e2364 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+36)
#10 pc 0000000000083d98 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64)
系统版本分布上,该问题高度集中于 Android 10(API 29)——崩溃监控后台数据显示其与 Android 10 的关联度高达 99%,其余版本零星分布。
堆栈里藏着的两个关键事实
通读堆栈,有两个细节决定了排查方向:
第一,崩溃线程是 SignalCatcher。 这是 ART 内部专门处理 SIGQUIT 信号的守护线程。SIGQUIT 什么时候来?最典型的场景是系统判定应用 ANR 时——AMS 会向无响应的进程发送 SIGQUIT,要求它 dump 所有线程堆栈(即大家熟知的 traces.txt 采集过程)。
第二,CHECK 失败的点是 OatHeader::GetCompilerFilter()。 dump 过程中,OatFileManager::DumpForSigQuit 会遍历已加载的 OAT 文件并读取其头部元数据,其中就包括 compiler-filter 字段。某个"OAT 文件"的头部里偏偏没有这个字段,CHECK 断言失败,ART 主动 abort。
也就是说:用户先遇到一次卡顿/ANR,系统在采集 ANR 堆栈的过程中又把进程杀死了。 用户感知到的是一次闪退,而崩溃堆栈记录的是"案发现场之后的二次事故"。
根因:内存里的 dex,没有 oat 头
谁的 OAT 文件会缺 compiler-filter?答案指向 InMemoryDexClassLoader。
InMemoryDexClassLoader 是 Android 8.0 引入的类加载器,允许直接从内存中的 dex 缓冲区加载类,dex 不需要落盘。工程实践中常见的典型代码如下:
public void loadSelf(Context c) {
try {
FileInputStream fis = new FileInputStream(c.getApplicationInfo().publicSourceDir);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bytesRead;
byte[] buffer = new byte[1024];
while ((bytesRead = fis.read(buffer, 0, buffer.length)) != -1) {
baos.write(buffer, 0, buffer.length);
}
baos.flush();
byte[] dex = baos.toByteArray();
ByteBuffer bb = ByteBuffer.allocate(dex.length);
bb.put(dex);
bb.position(0);
// 内存加载 dex,不落盘
ClassLoader loader = new InMemoryDexClassLoader(bb, null);
Class thisClass = loader.loadClass(this.getClass().getName());
Method method = thisClass.getMethod("sayHi", Context.class);
method.invoke(thisClass.newInstance(), c);
bb.clear();
} catch (Exception e) {
e.printStackTrace();
}
}
问题出在 Android 10 的 ART 实现上:内存 dex 对应的 OatFile 结构是"残缺"的,其 OAT header 中并未写入 compiler-filter 字段,但 DumpForSigQuit 的 dump 路径却假设所有已注册的 OatFile 都能读出这个字段,并以 CHECK 断言强约束。该问题已收录在 Google Issue Tracker(Issue 150633385),官方结论为 Android 10 系统层缺陷。
完整的事故链路可以归纳为:
主线程卡顿/ANR
│
▼
AMS 向进程发送 SIGQUIT
│
▼
SignalCatcher 线程被唤醒 → Runtime::DumpForSigQuit
│
▼
OatFileManager 遍历 OatFile → 读取 OAT header 的 compiler-filter
│
▼
InMemoryDexClassLoader 加载的内存 dex 无此字段
│
▼
Check failed → Runtime::Abort → SIGABRT(进程死亡)
Google 的修复
Google 于 2019 年 10 月在 AOSP platform/art 项目中合入了修复(Change 1149193,作者 Nicolas Geoffray,合并时间 2019-10-23,关联 Bug 143155012):
Add the compiler filter to InMemoryDexClassLoader backed by oat files. OatHeader::GetCompilerFilter requires it.
修复思路很直接:为内存 dex 支撑的 OatFile 补上 compiler-filter 字段(+32/-2 行,配套测试用例 692-vdex-inmem-loader),让 dump 路径的 CHECK 有值可读。该修复随后续 Android 版本发布,因此问题几乎只在 Android 10 上出现——这与崩溃监控的版本分布数据完全吻合。
结论与对策
系统 bug 无法由应用侧修复,但可以规避:
- 按系统版本降级加载策略。 在 Android 10(
Build.VERSION.SDK_INT == 29)设备上避免使用InMemoryDexClassLoader,回退为落盘 dex +DexClassLoader的传统方式,并确保落地路径具备防篡改保护。涉及加固/插件化等使用内存 dex 的方案时,需与对应 SDK 提供方确认其在 Android 10 上的适配情况。 - 降低触发概率作为辅助手段。 该崩溃的扳机是 ANR,持续治理主线程耗时(IO、锁竞争、大对象初始化)可以压缩崩溃敞口,但须明确这只是概率性缓解,不能替代策略降级。
- 监控口径上注意区分。 这类 SIGABRT 的"案发现场"是 ANR,分析归因时应把崩溃与 ANR 数据关联看,避免重复计算劣化。
案例二:Top4 —— klass 指针为何会变成 null?
现象与数据
该问题近 90 天上报崩溃 70 万次+,影响业务超 20+, 影响用户 40 万+,位列崩溃榜 Top4。同样以 SIGABRT 收尾,但错误消息换成了 GC 的"验尸报告":
klass pointer for obj: 0x225b578 found to be null
真实崩溃堆栈如下:
#00 pc 000000000008b70c /apex/com.android.runtime/lib64/bionic/libc.so (abort+156)
#01 pc 0000000000c54bec /apex/com.android.art/lib64/libart.so (art::Runtime::Abort+812)
#02 pc 0000000000016a10 /apex/com.android.art/lib64/libbase.so
#03 pc 0000000000015f50 /apex/com.android.art/lib64/libbase.so (android::base::LogMessage::~LogMessage()+544)
#04 pc 0000000000568e80 /apex/com.android.art/lib64/libart.so (art::gc::Verification::LogHeapCorruption)
#05 pc 0000000000414880 /apex/com.android.art/lib64/libart.so (art::gc::collector::MarkCompact::ProcessMarkStack+752)
#06 pc 00000000005c8514 /apex/com.android.art/lib64/libart.so (art::gc::collector::MarkCompact::MarkConcurrentRoots)
#07 pc 00000000005c9798 /apex/com.android.art/lib64/libart.so (art::gc::collector::MarkCompact::MarkRoots)
#08 pc 00000000005cb064 /apex/com.android.art/lib64/libart.so (art::gc::collector::MarkCompact::MarkingPhase+596)
#09 pc 00000000006dbb88 /apex/com.android.art/lib64/libart.so (art::gc::collector::MarkCompact::RunPhases+200)
#10 pc 00000000005d3578 /apex/com.android.art/lib64/libart.so (art::gc::Heap::CollectGarbageInternal+564)
#11 pc 0000000000708d84 /apex/com.android.art/lib64/libart.so (art::gc::Heap::ConcurrentGC+164)
#12 pc 0000000000707008 /apex/com.android.art/lib64/libart.so (art::gc::Heap::ConcurrentGCTask::Run)
#13 pc 0000000000477328 /apex/com.android.art/lib64/libart.so (art::gc::TaskProcessor::RunAllTasks+312)
java:
dalvik.system.VMRuntime.runHeapTasks(Native method)
java.lang.Daemons$HeapTaskDaemon.runInternal
java.lang.Daemons$Daemon.run
java.lang.Thread.run
该问题在新系统版本上上报量最高,机型分布与大盘基本一致,无明显机型或 ROM 聚集性——这通常暗示问题出在应用自身代码而非特定系统实现。
根因:GC 只是"报案人",不是"凶手"
LogHeapCorruption 是 ART 堆完整性校验的最后一道防线:MarkCompact 回收器在标记阶段遍历对象图时,会校验每个对象的 klass 指针(即对象头中指向其类元数据的指针)。一个正常存活对象的 klass 指针绝不可能为 null——除非这块内存在 GC 到来之前已经被写坏了。
最常见、也最典型的写法是 JNI 层越界写。下面是一个可直接复现该崩溃的最小样本:
/**
* 【复现】klass pointer null
* memset 0x00 越界 → 破坏相邻对象 klass → GC 时 LogHeapCorruption
*/
JNIEXPORT void JNICALL
Java_com_example_crashdemo_MainActivity_triggerCase1(
JNIEnv* env, jobject, jbyteArray arr) {
if (!arr) return;
jsize len = env->GetArrayLength(arr);
void* p = env->GetPrimitiveArrayCritical(arr, nullptr);
if (!p) return;
uint8_t* d = (uint8_t*)p;
// 致命的一行:从数组末尾之后开始写 8192 字节 0x00
memset(d + len, 0x00, 8192);
env->ReleasePrimitiveArrayCritical(arr, p, JNI_ABORT);
}
GetPrimitiveArrayCritical 拿到的指针直接指向 Java 堆内数组的数据区。memset(d + len, 0x00, 8192) 越过数组边界,把紧邻其后的若干 Java 对象——包括它们的对象头——整体清零。被清零的对象此时可能仍是存活的,引用关系一切正常,直到下一次并发 GC 标记到它,才发现 klass 指针已经是 null,于是 abort。
事故链路:
JNI 获取 Java 数组临界区指针
│
▼
越界 memset 0x00(写穿数组边界)
│
▼
相邻存活对象的对象头被清零(klass 指针 = null)
│
▼
(若干毫秒~数秒后)并发 GC 启动 MarkCompact 标记
│
▼
校验到损坏对象 → LogHeapCorruption → Runtime::Abort
这类问题最大的难点在于"案发时间差":越界写发生在 A 时刻的某个 native 调用里,崩溃却发生在 B 时刻的 GC 线程上,堆栈对第一现场毫无指向性。这也解释了为什么它的堆栈"干净"得只剩系统帧,却长期挂在 Top 榜上下不来。
排查与修复思路
- 第一原则:信堆栈的"性质",不信堆栈的"位置"。
LogHeapCorruption型崩溃几乎可断定存在 native 内存破坏,排查范围应立即收敛到 JNI/NDK 代码,而非在 Java 层空耗。 - 代码审计聚焦高危 API。 重点审查
GetPrimitiveArrayCritical/GetStringCritical/ 直接缓冲区等拿到裸指针后的所有写操作,逐一核对长度参数与边界计算;memcpy/memset/strcpy类调用是重灾区。 - 工具兜底。 native 侧接入 HWASan/ASan(内存越界检测)可在越界写发生的瞬间捕获第一现场,把"时间差"问题彻底消解,这是定位此类问题性价比最高的手段;同时可借助 GWP-ASan 对线上样本做抽样防护与归因。
- 防御性编码。 临界区内只做最小必要操作,写操作前显式校验
offset + length <= capacity;ReleasePrimitiveArrayCritical按需选择JNI_ABORT(无回写需求时),减少不必要的内存同步。
写在最后:疑难崩溃的三点方法论
两个案例形态迥异,但排查路径有共通之处,沉淀下来供大家参考:
- 读懂崩溃线程和报错文案,比读堆栈帧更重要。 案例一的
SignalCatcher线程直接指明了"崩溃发生在 ANR dump 期间";案例二的LogHeapCorruption文案直接宣判了"堆已被提前破坏"。这两句话都把排查范围收敛了 80%。 - 警惕"第二现场"陷阱。 ANR dump 崩溃、GC 验尸崩溃,堆栈记录的都不是事故起点。先回答"进程当时在干什么",再回答"谁把它变成了这样"。
- 系统问题与应用问题的分流要快。 版本/机型聚集度是最廉价的分流器:案例一在 Android 10 上关联度 99%,配合 Google Issue Tracker 与 AOSP 修复记录即可确认为系统缺陷,及时转入规避策略;案例二分布与大盘一致,果断转向 native 代码审计与 ASan 工具链,避免在错误的方向上恋战。
崩溃治理没有银弹,但每一个 Top 问题的归因闭环,都是在为下一个疑难问题积累直觉。