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 与 JNI ☆

这是你的强项,也是你的差异化武器。 一般应用开发者只会调用别人的 so,你能独立写 SDK。这一篇帮你把这份功底结构化成面试语言,在三面/技术亮点环节碾压竞争者。面试讲这块时要主动、自信、深入。

一、JNI 基础与类型映射

JNI(Java Native Interface)是 Java/Kotlin 与 C/C++ 互调的桥梁。

类型映射

Java 类型JNI 类型签名
booleanjbooleanZ
bytejbyteB
charjcharC
intjintI
longjlongJ
floatjfloatF
doublejdoubleD
ObjectjobjectL 全限定名;
StringjstringLjava/lang/String;
int[]jintArray[I
voidvoidV

方法签名

格式 (参数)返回值,如 (ILjava/lang/String;)V 表示 (int, String) -> void。用 javap -s 可查看签名。

二、引用管理(高频深挖)

JNI 有三种引用,管理不当会泄漏或崩溃:

  • 局部引用(Local Reference):默认创建的引用(如 FindClass、NewObject 返回值)。方法返回后自动释放,不能跨方法/线程缓存。JNI 规范保证进入 native 方法前至少可创建 16 个局部引用;ART/设备上的引用表容量和扩展行为可能不同,不要把“512”当通用契约。循环中大量创建不释放仍可能 ReferenceTable overflow —— 要及时 DeleteLocalRef,或用 EnsureLocalCapacity / PushLocalFrame 管理容量。
  • 全局引用(Global Reference):NewGlobalRef 创建,跨方法/线程有效,必须手动 DeleteGlobalRef 否则泄漏。用于缓存常用 jclass、jobject。
  • 弱全局引用(Weak Global Reference):NewWeakGlobalRef,不阻止 GC。使用前推荐通过 NewLocalRef 提升成强局部引用,若返回 NULL 说明对象已回收;单纯 IsSameObject(ref, NULL) 只能做瞬时判断,之后仍可能被 GC。

实战经验(面试可讲):缓存 jclass/jmethodID 用全局引用提升性能;在 native 循环处理数组时注意局部引用释放,避免引用表溢出。

引用类型生命周期典型用途易错点
Local当前 native 调用/当前 local frame临时对象、字符串、数组元素循环中不删会堆积;不能保存到全局变量或跨线程用。
Global手动删除前有效缓存 jclass、跨线程回调对象忘记 DeleteGlobalRef 会泄漏 Java 对象。
Weak Global对象未被 GC 前可观察缓存可被回收的 Java owner使用前要提升为强引用,不要假设检查后就安全。
// 批量处理对象数组:用 local frame 一次释放本轮临时引用
for (jsize i = 0; i < count; i += 64) {
    if (env->PushLocalFrame(64) < 0) return; // OOM pending
    for (jsize j = i; j < count && j < i + 64; ++j) {
        jobject item = env->GetObjectArrayElement(array, j);
        // ... 使用 item ...
    }
    env->PopLocalFrame(nullptr); // 释放本 frame 内局部引用
}

三、JNIEnv 与 JavaVM、线程

  • JavaVM:进程唯一,代表整个虚拟机,可跨线程共享
  • JNIEnv:线程私有,每个线程一份,不能跨线程缓存使用。
  • native 线程访问 JVM:在 native 自己创建的线程里要调用 Java,必须先 JavaVM->AttachCurrentThread 拿到该线程的 JNIEnv,线程退出前 DetachCurrentThread,否则可能造成线程相关资源泄漏。由 JVM 调进 native 的线程已经 attached,通常不应随意 detach。
  • 拿 JavaVM 的方式:JNI_OnLoad 回调里保存全局 JavaVM 指针。
JavaVM* g_vm;
jint JNI_OnLoad(JavaVM* vm, void*) {
    g_vm = vm;                       // 保存,供后续线程 attach
    return JNI_VERSION_1_6;
}
// native 线程中:
JNIEnv* env;
g_vm->AttachCurrentThread(&env, nullptr);
// ... 调用 Java ...
g_vm->DetachCurrentThread();

更稳的工程写法是 RAII 包一层,确保异常路径/提前 return 也会 detach:

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

四、静态注册 vs 动态注册

  • 静态注册:按命名约定 Java_包名_类名_方法名 命名 native 函数,运行时按名查找。缺点:函数名长、首次调用需查找、暴露包名(易逆向)。
  • 动态注册:在 JNI_OnLoad 里用 RegisterNatives 把 Java 方法和 native 函数指针映射注册。优点:函数名自由、效率高、隐藏映射关系(更安全,SDK/加固常用)
static JNINativeMethod methods[] = {
    {"nativeFoo", "(I)I", (void*)foo_impl},
};
env->RegisterNatives(clazz, methods, 1);

面试可讲:做 SDK/对抗时用动态注册隐藏 native 入口,增加逆向难度——这是你风控背景的天然加分点。

维度静态注册动态注册
绑定方式函数名按 Java_package_Class_method 约定匹配RegisterNatives 显式绑定 Java 方法名/签名/函数指针
可维护性简单直观,Demo/少量方法够用集中注册,重构包名时更可控
性能/加载首次调用按名解析JNI_OnLoad 一次注册,调用路径更直接
安全/逆向导出符号暴露 Java 包名和方法可隐藏 C++ 函数名,但不能替代混淆/加固/权限校验

动态注册要检查 FindClassRegisterNatives 返回值和 pending exception;注册失败如果继续运行,后续 Java 调 native 会变成难定位的 UnsatisfiedLinkError

五、native 调用 Java 与异常

  • 调用流程:FindClass → GetMethodID/GetStaticMethodID → CallXxxMethod
  • 异常处理:JNI 调用后若 Java 抛异常,native 不会中断,必须 ExceptionCheck/ExceptionOccurred 检查并 ExceptionClear,否则后续 JNI 调用行为未定义。

六、native crash 捕获(你的强项可深讲)

  • native 崩溃(SIGSEGV/SIGABRT)不走 Java 的 try/catch,默认直接挂掉进程。
  • 捕获方式:注册信号处理器(sigaction) 捕获 SIGSEGV、SIGABRT、SIGBUS 等,在 handler 里 dump 寄存器、调用栈(unwind)、maps。
  • 成熟方案:Google Breakpad / Crashpad、字节的 xCrash。它们生成 minidump,符号化后定位。
  • 难点:信号处理器中只能调用 async-signal-safe 函数;栈展开(unwind)需结合 .eh_frame/.ARM.exidx;符号化需保留带符号 so。
  • 工程流程:线上包可 strip 符号减体积,但 CI/符号服务器必须保存同 build-id 的未裁剪 so;拿到 tombstone/minidump 后用 addr2linendk-stack 或 Breakpad/Crashpad 工具映射到函数、文件和行号。

七、CMake / ABI / so

  • 构建:CMakeLists.txt + externalNativeBuild;add_librarytarget_link_librariesfind_library(log)
  • ABI:arm64-v8a(主流)、armeabi-v7a(老设备)、x86/x86_64(模拟器)。abiFilters 控制打包哪些,通常只留 arm64-v8a 减体积。
  • so 加载:System.loadLibrary("name") 加载 libname.so,触发 JNI_OnLoad
  • 现代 C++:RAII(资源随对象析构释放)、智能指针(unique_ptr 独占 / shared_ptr 共享 / weak_ptr 防循环引用)、移动语义(std::move 转移资源所有权)。NDK 用这些写出更安全的 native 代码。

现代 C++ 在 NDK 里的落地

场景推荐写法为什么
管理 native buffer/句柄std::unique_ptr + 自定义 deleter,或封装 RAII class避免异常/return 分支漏 free/close/DeleteGlobalRef
多模块共享对象明确所有权后再用 std::shared_ptr引用计数有成本,还可能循环引用;默认优先 unique_ptr
回调持有 Java ownerC++ 侧弱引用 + Java/Kotlin 生命周期取消避免 native 长生命周期对象强持 Activity。
大对象返回/转移移动构造/移动赋值,必要时 std::move减少拷贝,但 moved-from 对象只应处于可析构/可重新赋值状态。
struct GlobalRefDeleter {
    JavaVM* vm;
    void operator()(jobject ref) const {
        if (!ref) return;
        ScopedJniEnv scoped(vm);
        scoped.get()->DeleteGlobalRef(ref);
    }
};

using UniqueGlobalRef = std::unique_ptr<_jobject, GlobalRefDeleter>;

移动语义陷阱:std::move 只是强制把对象当右值,真正移动取决于类型是否实现移动构造/赋值;对还要继续读取的对象不要随手 move。JNI 场景中尤其要避免把 JNIEnv*、局部引用这类线程/生命周期受限资源包装后跨线程 move。


高频面试题

Q1:JNI 的局部引用和全局引用区别?什么时候会引用表溢出? 局部引用方法返回或 local frame 弹出后释放、不可跨线程;规范保证至少 16 个局部引用,具体容量/扩展行为看 VM 实现,不要死记 512。全局引用需手动 DeleteGlobalRef、可跨线程缓存。循环中大量创建局部引用不及时 DeleteLocalRef/PushLocalFrame,就可能触发 ReferenceTable overflow。

Q2:JNIEnv 和 JavaVM 区别?native 线程怎么调 Java? JavaVM 进程唯一可共享;JNIEnv 线程私有不可跨线程。native 自建线程要调 Java,需先 AttachCurrentThread 拿当前线程的 JNIEnv,线程退出前 DetachCurrentThread;从 Java 调进来的线程已经 attached,不要误 detach。

Q3:静态注册和动态注册区别?为什么 SDK 喜欢动态注册? 静态按 Java_包名_方法名 命名约定查找;动态用 RegisterNatives 在 JNI_OnLoad 注册函数指针映射。动态注册函数名自由、效率高、隐藏入口映射,增加逆向难度,适合 SDK/加固。

Q4:native 崩溃为什么 Java 的 try/catch 抓不到?怎么捕获? native 崩溃是 OS 信号(SIGSEGV 等),不经过 JVM 异常机制。需注册 sigaction 信号处理器捕获,在 handler 中 dump 栈和寄存器,用 Breakpad/xCrash 生成 minidump 后符号化定位。

Q5:JNI 调用 Java 方法后为什么要检查异常? JNI 调用若使 Java 抛异常,native 代码不会自动中断,异常处于 pending 状态,此时继续调用其他 JNI 函数行为未定义。必须 ExceptionCheck 后 ExceptionClear 处理。

Q6:为什么大多 App 只打包 arm64-v8a? 现代设备基本都是 arm64,只留 arm64-v8a 可显著减小 so 体积;armeabi-v7a 仅老设备需要。按 ABI 拆分 + App Bundle 动态下发是体积优化常用手段。

Q7:智能指针怎么选?(现代 C++) 独占所有权用 unique_ptr;确实有共享所有权才用 shared_ptr(引用计数有成本);打破 shared_ptr 循环引用用 weak_ptr。默认优先 unique_ptr 和 RAII,把 JNI 全局引用、fd、native buffer 等资源包装进析构函数,避免裸 new/delete 和异常路径泄漏。