4、Crash问题实战:Java Crash与Native Crash的捕获、分析、符号化与堆栈解读
说到Crash,这可能是我们稳定性工程师打交道最多的东西了。我经常跟团队里的小伙伴说,Crash不可怕,可怕的是看不懂Crash。今天咱们就来聊聊,怎么把Java Crash和Native Crash这两头“拦路虎”给驯服了。
4.1 Java Crash的捕获机制
Java Crash,说白了就是Java层抛出了未捕获的异常。系统默认会调用Thread.setDefaultUncaughtExceptionHandler来处理。但咱们做稳定性优化的,肯定不能只靠系统默认的那一套。
核心思路:在应用启动时,尽早注册我们自己的UncaughtExceptionHandler,接管全局异常处理。
public class MyCrashHandler implements Thread.UncaughtExceptionHandler {
private static final String TAG = "CrashHandler";
private Thread.UncaughtExceptionHandler mDefaultHandler;
public MyCrashHandler() {
// 保存系统默认的处理器
mDefaultHandler = Thread.getDefaultUncaughtExceptionHandler();
}
@Override
public void uncaughtException(Thread t, Throwable e) {
// 1. 收集崩溃信息
String crashInfo = collectCrashInfo(t, e);
// 2. 保存到本地文件
saveCrashToFile(crashInfo);
// 3. 上传到服务器(异步)
uploadCrashAsync(crashInfo);
// 4. 交给系统默认处理器处理(杀掉进程)
if (mDefaultHandler != null) {
mDefaultHandler.uncaughtException(t, e);
}
}
}
避坑指南:我曾经在项目中犯过一个错误——在uncaughtException里做了太多耗时操作,导致应用ANR。记住,这个回调里不能做耗时操作,保存文件要异步,上传更是要放到后台线程。
4.2 Native Crash的捕获机制
Native Crash就麻烦多了。它不像Java层那样有统一的异常处理机制。Native Crash通常是信号级别的,比如SIGSEGV(段错误)、SIGABRT(主动中止)等。
我个人的习惯是使用sigaction系统调用来注册信号处理器。嗯,这里要注意,信号处理函数里能做的事情非常有限,因为信号可能发生在任意时刻,甚至是在malloc内部。
#include <signal.h>
#include <unistd.h>
#include <android/log.h>
void crash_handler(int sig, siginfo_t* info, void* context) {
// 1. 获取信号信息
const char* sig_name = get_signal_name(sig);
// 2. 获取堆栈信息(这里用backtrace)
void* buffer[100];
int count = backtrace(buffer, 100);
char** symbols = backtrace_symbols(buffer, count);
// 3. 写入日志
for (int i = 0; i < count; i++) {
__android_log_print(ANDROID_LOG_ERROR, "NativeCrash",
" #%02d %s", i, symbols[i]);
}
// 4. 保存到文件
save_native_crash(sig, symbols, count);
// 5. 恢复默认处理并重新发送信号
signal(sig, SIG_DFL);
raise(sig);
}
void register_native_crash_handler() {
struct sigaction sa;
sa.sa_sigaction = crash_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_SIGINFO | SA_ONSTACK;
sigaction(SIGSEGV, &sa, NULL);
sigaction(SIGABRT, &sa, NULL);
sigaction(SIGFPE, &sa, NULL);
sigaction(SIGBUS, &sa, NULL);
}
特别注意:信号处理函数里绝对不能调用非异步信号安全的函数,比如malloc、printf、fopen等。你想想看,如果程序正好在malloc内部崩溃了,你在信号处理里又调用了malloc,那就会死锁。我建议用write系统调用直接写文件,或者用mmap预分配内存。
4.3 堆栈符号化
拿到堆栈地址后,你会发现全是十六进制数字,比如0x7a3b4c5d。这玩意儿谁能看懂?所以需要符号化——把地址翻译成函数名和行号。
对于Java Crash,ProGuard混淆后的堆栈需要反混淆。我常用的工具是retrace.sh,配合mapping文件使用。
# Java堆栈反混淆
java -jar proguard.jar retrace mapping.txt stacktrace.txt
对于Native Crash,我们需要用addr2line或者ndk-stack。我个人更推荐addr2line,因为它更灵活。
# 使用addr2line符号化
arm-linux-androideabi-addr2line -f -e libnative.so 0x7a3b4c5d
# 使用ndk-stack批量处理
ndk-stack -sym /path/to/obj/local/armeabi-v7a -dump crash.log
小技巧:我曾经遇到过一个情况,符号化出来的行号总是差几行。后来发现是编译优化导致的。建议在调试版本里关闭优化(-O0),或者保留完整的调试信息(-g)。
4.4 堆栈解读实战
拿到符号化后的堆栈,怎么读?我总结了一个“三步法”:
- 看最顶层:崩溃发生的位置,通常是问题的直接原因
- 看调用链:从顶层往下看,理解代码的执行路径
- 看关键参数:函数参数、寄存器值,往往能提供额外线索
举个例子,假设我们拿到这样一个Java Crash堆栈:
java.lang.NullPointerException
at com.example.app.MainActivity.onClick(MainActivity.java:45)
at android.view.View.performClick(View.java:7125)
at android.view.View$PerformClick.run(View.java:28295)
at android.os.Handler.handleCallback(Handler.java:873)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loop(Looper.java:214)
at android.app.ActivityThread.main(ActivityThread.java:7356)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930)
你看,最顶层是MainActivity.java:45,说明第45行有个空指针。我打开代码一看,原来是textView.setText(null)。嗯,这就是典型的UI线程空指针问题。
再看一个Native Crash的例子:
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
#00 pc 0x0001a3b4 libnative.so (Java_com_example_native_NativeUtils_processData+128)
#01 pc 0x0002c5d6 libnative.so (native_process+42)
#02 pc 0x0003e7f8 libnative.so (JNI_OnLoad+56)
信号11是SIGSEGV,fault addr是0x0,说明是空指针解引用。再看堆栈,processData函数里偏移128字节处出了问题。用addr2line一查,发现是memcpy(dst, NULL, size)。你看,Native层的空指针和Java层的空指针,本质是一样的,只是表现形式不同。
4.5 实战中的常见陷阱
| 陷阱类型 | 表现 | 解决方案 |
|---|---|---|
| 堆栈被截断 | 只能看到部分堆栈 | 增大backtrace的buffer大小,或者使用libunwind |
| 符号化失败 | 显示??:0 | 检查so文件是否带符号表,确认addr2line版本匹配 |
| 信号处理死锁 | 应用卡死,不生成Crash日志 | 使用异步信号安全函数,预分配内存 |
| 多线程Crash | 堆栈显示的是其他线程 | 在信号处理中获取当前线程ID,确认崩溃线程 |
核心总结:Crash分析说白了就是“定位-符号化-解读”三个步骤。Java Crash相对简单,Native Crash需要多注意信号安全和符号化工具的选择。我建议每个项目都建立自己的Crash分析SOP,这样新人上手也快。
好了,这一章的内容就到这里。下一章咱们聊聊ANR问题的定位与优化,那可是比Crash更头疼的问题。到时候我会分享一些我在项目中用过的“黑科技”。