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 堆栈解读实战

拿到符号化后的堆栈,怎么读?我总结了一个“三步法”:

  1. 看最顶层:崩溃发生的位置,通常是问题的直接原因
  2. 看调用链:从顶层往下看,理解代码的执行路径
  3. 看关键参数:函数参数、寄存器值,往往能提供额外线索

举个例子,假设我们拿到这样一个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更头疼的问题。到时候我会分享一些我在项目中用过的“黑科技”。