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 Reference | DeleteGlobalRef 前有效 | 缓存 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_library、target_link_libraries、编译选项和 STL 选择。 |
| 依赖管理 | 多 so 依赖要保证打包完整,避免运行时 linker 找不到。 |
| 体积优化 | ABI 拆分、只打必要架构、裁剪无用符号和资源。 |
构建产物治理很重要:没有符号文件,线上 native crash 只能看到地址,无法高效定位到源码行。
四、tombstone 与 native crash 现场
Android native crash 通常由信号触发,例如 SIGSEGV、SIGABRT、SIGBUS。系统会生成 tombstone 或在 logcat 中输出 crash 现场。
一份 tombstone 重点看:
- signal:崩溃类型,如 SIGSEGV 空指针/非法地址访问,SIGABRT 主动 abort。
- fault addr:访问的非法地址,0x0 附近通常是空指针。
- thread name / tid:崩溃线程,判断是主线程、渲染线程还是业务线程。
- registers:寄存器现场,可辅助判断参数和访问地址。
- backtrace:so 名称、偏移地址、函数符号。
- 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 地址映射到函数、源码文件和行号。常用工具包括 addr2line、ndk-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_ptr、std::vector、智能指针 | 裸指针多分支释放容易漏。 |
| 文件描述符 | 自定义 RAII fd wrapper | 忘记 close 导致 fd 泄漏。 |
| JNI GlobalRef | unique_ptr + 自定义 deleter | 析构时要拿当前线程 JNIEnv。 |
| mutex | std::lock_guard / std::unique_lock | 手动 lock/unlock 容易死锁。 |
| native handle | 封装 owner class | 所有权不清导致 double free。 |
RAII 还能提升面试表达:你不仅会写 native,还知道如何让 native 在异常、崩溃和复杂生命周期下更稳定。
七、native 泄漏与调试方法
Native 泄漏包括内存泄漏、fd 泄漏、线程泄漏、JNI 全局引用泄漏和图形/音视频资源泄漏。它们不一定出现在 Java heap 中,所以只看 LeakCanary 不够。
排查思路:
- 确认现象:RSS/PSS 持续增长、fd 数增长、线程数增长、崩溃前内存压力。
- 区分 Java/native:Android Studio Profiler、
dumpsys meminfo、heapprofd、Perfetto。 - 定位分配点:debug 包可用 malloc 调试、ASan/HWASan、heapprofd 采样。
- 检查 JNI 引用:全局引用是否释放,循环局部引用是否 DeleteLocalRef。
- 检查所有权:对象是否 double free、use-after-free、跨线程释放。
- 修复后回归:压测相同路径,观察内存/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 匹配;再用 addr2line 或 ndk-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 引用和所有权审查。