Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

NDK Native 调试与 Crash 定位 ☆

Native 能力是你的差异化强项,但面试要讲成“稳定性与诊断能力”,不是炫底层。 本章把 JNI 引用、线程 attach、RegisterNatives、so 加载、ABI/CMake、tombstone、addr2line、ndk-stack、RAII 和 native 泄漏串成一套线上排障语言。

一、JNI 引用与线程模型复盘

Native 崩溃和泄漏里很大一部分来自 JNI 生命周期误用。引用类型要和生命周期严格匹配。

引用类型生命周期常见用途风险
Local Reference当前 native 调用或 local frame临时对象、字符串、数组元素循环中不释放会引用表溢出;不能跨线程缓存。
Global ReferenceDeleteGlobalRef 前有效缓存 jclass、跨线程回调对象忘删会泄漏 Java 对象和 Activity。
Weak Global Reference对象未被 GC 前可观察缓存 owner、监听对象使用前要提升为 Local,否则可能已被回收。

JNIEnv* 是线程私有的,不能跨线程保存;JavaVM* 进程唯一,可以保存后在 native 线程里 AttachCurrentThread 获取当前线程的 JNIEnv*,线程结束前 DetachCurrentThread

class ScopedAttach {
public:
    explicit ScopedAttach(JavaVM* vm) : vm_(vm) {
        if (vm_->GetEnv(reinterpret_cast<void**>(&env_), JNI_VERSION_1_6) == JNI_EDETACHED) {
            attached_ = (vm_->AttachCurrentThread(&env_, nullptr) == JNI_OK);
        }
    }
    ~ScopedAttach() {
        if (attached_) vm_->DetachCurrentThread();
    }
    JNIEnv* env() const { return env_; }
private:
    JavaVM* vm_ = nullptr;
    JNIEnv* env_ = nullptr;
    bool attached_ = false;
};

面试高频追问是“为什么不能保存 JNIEnv 到全局变量”:因为它绑定当前线程,换线程使用会导致未定义行为甚至崩溃。

二、RegisterNatives、so 加载与初始化顺序

Native 方法注册分静态注册和动态注册。工程 SDK 更常用 RegisterNatives,在 JNI_OnLoad 中集中绑定 Java 方法和 C/C++ 函数。

环节关键点常见问题
System.loadLibrary加载 libxxx.so,触发 JNI_OnLoad库名不匹配、ABI 缺失、依赖 so 未找到。
JNI_OnLoad保存 JavaVM、查找类、注册 native 方法FindClass 失败、签名错误、pending exception 未处理。
RegisterNatives方法名 + 签名 + 函数指针绑定混淆后未 keep、签名写错、返回值未检查。
初始化顺序Java 层先加载 so 再调用 native多 so 依赖顺序、懒加载导致首次调用失败。

UnsatisfiedLinkError 常见原因包括:没有打包对应 ABI 的 so、System.loadLibrary 名称错误、native 方法签名不匹配、R8 混淆改了 Java native 方法名、依赖库加载失败。定位时先看 logcat 的 linker 错误和 ABI 路径,再查 JNI_OnLoad 返回值和注册日志。

三、ABI、CMake 与构建产物

ABI 决定 so 能在哪类 CPU 上运行。现代 Android 主流是 arm64-v8a,但模拟器、老设备和第三方 SDK 可能需要其他 ABI。

cmake_minimum_required(VERSION 3.22)
project(native_demo)

add_library(native_demo SHARED native_demo.cpp)
find_library(log-lib log)
target_link_libraries(native_demo ${log-lib})
维度面试回答
ABI 选择通常优先 arm64-v8a;是否保留 armeabi-v7a/x86 看用户设备和 SDK 依赖。
符号保留线上 so 可 strip 减体积,但 CI 必须保存同 build-id 的未裁剪符号文件。
CMake 配置关注 add_librarytarget_link_libraries、编译选项和 STL 选择。
依赖管理多 so 依赖要保证打包完整,避免运行时 linker 找不到。
体积优化ABI 拆分、只打必要架构、裁剪无用符号和资源。

构建产物治理很重要:没有符号文件,线上 native crash 只能看到地址,无法高效定位到源码行。

四、tombstone 与 native crash 现场

Android native crash 通常由信号触发,例如 SIGSEGV、SIGABRT、SIGBUS。系统会生成 tombstone 或在 logcat 中输出 crash 现场。

一份 tombstone 重点看:

  1. signal:崩溃类型,如 SIGSEGV 空指针/非法地址访问,SIGABRT 主动 abort。
  2. fault addr:访问的非法地址,0x0 附近通常是空指针。
  3. thread name / tid:崩溃线程,判断是主线程、渲染线程还是业务线程。
  4. registers:寄存器现场,可辅助判断参数和访问地址。
  5. backtrace:so 名称、偏移地址、函数符号。
  6. memory map:模块加载基址,用于符号化和判断版本。
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
backtrace:
  #00 pc 0000000000012344  /data/app/.../lib/arm64/libdemo.so (foo+24)
  #01 pc 0000000000015678  /data/app/.../lib/arm64/libdemo.so (bar+80)

分析时先确认 so 版本和 build-id 是否对应,再做符号化。不要只凭函数名猜结论,要结合入参、线程、最近业务行为和复现路径。

五、addr2line / ndk-stack 符号化流程

符号化的目标是把 tombstone 中的 pc 地址映射到函数、源码文件和行号。常用工具包括 addr2linendk-stack、Crashpad/Breakpad 符号化工具。

# 使用未 strip 的同版本 so,将 pc 偏移映射到源码行
$ANDROID_NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-addr2line \
  -f -C -e obj/local/arm64-v8a/libdemo.so 0000000000012344

# 对包含 backtrace 的日志整体符号化
ndk-stack -sym obj/local/arm64-v8a -dump tombstone.txt
工具适用场景注意事项
addr2line / llvm-addr2line单个地址快速定位输入应是相对 so 的 pc 偏移,so 版本必须匹配。
ndk-stack对 tombstone/logcat 批量符号化-sym 指向未 strip 符号目录。
Breakpad/Crashpad线上 minidump 平台化处理需要符号服务器和 build-id 管理。
IDE Debugger本地复现调试适合断点、变量观察,不替代线上符号化。

如果符号化结果对不上,优先检查:线上包和符号文件是否同一版本、ABI 是否一致、地址是否需要减去加载基址、so 是否被重新打包或裁剪。

六、RAII 与 native 资源安全

Native 代码没有 Java/Kotlin GC 帮你管理所有资源。RAII(Resource Acquisition Is Initialization)的思想是“资源在构造时获取,析构时释放”,用对象生命周期减少异常路径和提前 return 的泄漏。

资源推荐管理方式易错点
malloc/new 内存std::unique_ptrstd::vector、智能指针裸指针多分支释放容易漏。
文件描述符自定义 RAII fd wrapper忘记 close 导致 fd 泄漏。
JNI GlobalRefunique_ptr + 自定义 deleter析构时要拿当前线程 JNIEnv。
mutexstd::lock_guard / std::unique_lock手动 lock/unlock 容易死锁。
native handle封装 owner class所有权不清导致 double free。

RAII 还能提升面试表达:你不仅会写 native,还知道如何让 native 在异常、崩溃和复杂生命周期下更稳定。

七、native 泄漏与调试方法

Native 泄漏包括内存泄漏、fd 泄漏、线程泄漏、JNI 全局引用泄漏和图形/音视频资源泄漏。它们不一定出现在 Java heap 中,所以只看 LeakCanary 不够。

排查思路:

  1. 确认现象:RSS/PSS 持续增长、fd 数增长、线程数增长、崩溃前内存压力。
  2. 区分 Java/native:Android Studio Profiler、dumpsys meminfo、heapprofd、Perfetto。
  3. 定位分配点:debug 包可用 malloc 调试、ASan/HWASan、heapprofd 采样。
  4. 检查 JNI 引用:全局引用是否释放,循环局部引用是否 DeleteLocalRef。
  5. 检查所有权:对象是否 double free、use-after-free、跨线程释放。
  6. 修复后回归:压测相同路径,观察内存/fd/线程曲线回落。

线上治理要保存符号、增加 native 关键路径日志、对高风险模块做灰度,并用崩溃率和内存指标观察修复效果。


高频面试题

Q1:JNIEnv 和 JavaVM 有什么区别? JavaVM 进程唯一,可以跨线程保存;JNIEnv 是线程私有的,只能在当前线程使用。native 自建线程要调用 Java,必须先 AttachCurrentThread 获取 JNIEnv,退出前 DetachCurrentThread。

Q2:RegisterNatives 有什么好处? 它在 JNI_OnLoad 中集中注册 Java native 方法与 C/C++ 函数指针,避免静态注册的长函数名和包名暴露,重构更可控。工程上要检查 FindClass、签名和 RegisterNatives 返回值,并配置混淆 keep。

Q3:拿到 tombstone 后怎么定位 native crash? 先看 signal、fault addr、崩溃线程和 backtrace;确认 so 版本、ABI 和 build-id 匹配;再用 addr2linendk-stack 将 pc 地址符号化到函数和源码行,最后结合入参、业务路径和最近日志分析根因。

Q4:为什么线上一定要保存未 strip 的 so? 线上包为了体积会 strip 符号,crash 里常只有 so 偏移地址。没有同版本未 strip 符号文件,addr2line / ndk-stack 无法准确映射源码行,定位效率会大幅下降。

Q5:RAII 在 NDK 中解决什么问题? RAII 把资源释放绑定到对象析构,可以避免 return 分支、异常路径或错误处理遗漏释放。它适合管理 native 内存、fd、mutex、JNI GlobalRef 等资源,能显著降低泄漏和 double free 风险。

易错点 / 追问

  • 不要跨线程缓存和使用 JNIEnv*,跨线程只保存 JavaVM*
  • 不要忘记删除 GlobalRef,也不要把 LocalRef 存到全局变量里。
  • 不要只看 tombstone 函数名就下结论,必须确认符号文件、ABI、build-id 和业务上下文匹配。
  • 追问“addr2line 结果不对怎么办”:检查 so 版本是否一致、地址是否为相对偏移、ABI 是否匹配、符号是否被裁剪。
  • 追问“native 泄漏怎么发现”:看 PSS/RSS、fd、线程、heapprofd/Perfetto/ASan,并结合 JNI 引用和所有权审查。