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/C++ 片段用于解释边界, 默认需要 #include <jni.h>, 对应 Java/Kotlin native 声明, 正确的包名/签名以及 CMake/NDK 配置; 除非明确标注, 不保证单个片段可脱离上下文独立编译.

一, 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_ == nullptr) return;
        const jint state = vm_->GetEnv(reinterpret_cast<void**>(&env_), JNI_VERSION_1_6);
        if (state == JNI_EDETACHED) attached_ = vm_->AttachCurrentThread(&env_, nullptr) == JNI_OK;
        else if (state != JNI_OK) env_ = nullptr;
    }
    ~ScopedJniEnv() {
        if (attached_) vm_->DetachCurrentThread();
    }
    bool ok() const { return env_ != nullptr; }
    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++ 函数名, 但不能替代混淆/加固/权限校验

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

JNI 最小三件套: 声明, 实现, 构建

以下是可放入 Android 工程验证的最小示例. 假设 Kotlin 文件为 com.example.jnidemo.NativeBridge, 模块已配置 NDK 与 CMake; 输出取决于输入, 不声称已在本仓库执行.

// app/src/main/java/com/example/jnidemo/NativeBridge.kt
package com.example.jnidemo

object NativeBridge {
    init { System.loadLibrary("native_demo") }

    external fun add(left: Int, right: Int): Int
    external fun greet(name: String): String
    external fun sum(values: IntArray): Long
}

// 调用:check(NativeBridge.add(2, 3) == 5)
// 预期:greet("Ada") 返回 "Hello, Ada";sum(intArrayOf(1, 2, 3)) 返回 6L.
// app/src/main/cpp/native_demo.cpp
#include <jni.h>
#include <string>

static jint add_impl(JNIEnv*, jobject, jint left, jint right) {
    return left + right;
}

static jstring greet_impl(JNIEnv* env, jobject, jstring name) {
    if (name == nullptr) return env->NewStringUTF("Hello, anonymous");
    const char* utf8 = env->GetStringUTFChars(name, nullptr);
    if (utf8 == nullptr) return nullptr; // VM 已挂起 OOM,立即返回
    std::string message = "Hello, ";
    message += utf8;
    env->ReleaseStringUTFChars(name, utf8);
    return env->NewStringUTF(message.c_str());
}

static jlong sum_impl(JNIEnv* env, jobject, jintArray values) {
    if (values == nullptr) return 0;
    const jsize size = env->GetArrayLength(values);
    jlong total = 0;
    for (jsize index = 0; index < size; ++index) {
        jint value = 0;
        env->GetIntArrayRegion(values, index, 1, &value);
        if (env->ExceptionCheck()) return 0; // 保留 pending exception 给 Kotlin
        total += value;
    }
    return total;
}

static JNINativeMethod kMethods[] = {
    {const_cast<char*>("add"), const_cast<char*>("(II)I"), reinterpret_cast<void*>(add_impl)},
    {const_cast<char*>("greet"), const_cast<char*>("(Ljava/lang/String;)Ljava/lang/String;"), reinterpret_cast<void*>(greet_impl)},
    {const_cast<char*>("sum"), const_cast<char*>("([I)J"), reinterpret_cast<void*>(sum_impl)},
};

JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void*) {
    JNIEnv* env = nullptr;
    if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) return JNI_ERR;
    jclass clazz = env->FindClass("com/example/jnidemo/NativeBridge");
    if (clazz == nullptr || env->ExceptionCheck()) return JNI_ERR;
    const jint result = env->RegisterNatives(clazz, kMethods, sizeof(kMethods) / sizeof(kMethods[0]));
    env->DeleteLocalRef(clazz);
    return result == JNI_OK ? JNI_VERSION_1_6 : JNI_ERR;
}
# app/src/main/cpp/CMakeLists.txt
cmake_minimum_required(VERSION 3.22)
project(native_demo)
add_library(native_demo SHARED native_demo.cpp)

Gradle 的 externalNativeBuild.cmake.path 指向该 CMakeLists.txt, 然后以 ./gradlew :app:assembleDebug 构建并在设备 / 模拟器 ABI 匹配时调用 Kotlin 示例. 动态注册的三个签名必须分别是 (II)I, (Ljava/lang/String;)Ljava/lang/String;, ([I)J; 任一差异都会使 RegisterNatives 失败.

调用方必须先检查 ScopedJniEnv.ok(); 失败时返回错误, 不能解引用空 JNIEnv*. Kotlin object 未标记 @JvmStatic 的成员使用 JNI 实例 receiver, 本例第二参数为 jobject. GetStringUTFChars 返回 Modified UTF-8 缓冲, 只在 ReleaseStringUTFChars 前有效. 数组若使用 GetIntArrayElements, 必须在所有提前返回路径调用 ReleaseIntArrayElements; 此例使用 GetIntArrayRegion 避免取得需要释放的元素指针.

五, native 调用 Java 与异常

  • 调用流程: FindClass → GetMethodID/GetStaticMethodID → CallXxxMethod.
  • 异常处理: JNI 调用后先 ExceptionCheck. 若继续向 Java 传播, 立即返回并保留 pending exception; 只有 native 已处理或转换异常时才 ExceptionClear, 清除后才能继续 JNI 调用.

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

  • native 崩溃 (SIGSEGV/SIGABRT) 不走 Java 的 try/catch, 默认直接挂掉进程.
  • 捕获方式: 注册信号处理器 (sigaction) 捕获 SIGSEGV, SIGABRT, SIGBUS 等, 在 handler 里 dump 寄存器, 调用栈 (unwind), maps; 难点是 handler 中只能调用 async-signal-safe 函数, 栈展开需结合 .eh_frame/.ARM.exidx.
  • 成熟方案:Google Breakpad / Crashpad(生成 minidump), 爱奇艺的 xCrash(输出 tombstone 格式文件). 都需要符号化后定位.
  • 拿到 tombstone/minidump 后的崩溃现场解读与符号化 (tombstone 字段分析, addr2line / ndk-stack, build-id 匹配与 strip 符号管理) 属于排障主线, 详见 NDK Native 调试与 Crash 定位 的「tombstone 与 native crash 现场」和「addr2line / ndk-stack 符号化流程」.

七, CMake / ABI / so

  • 构建: CMakeLists.txt + externalNativeBuild; add_library, target_link_libraries, find_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);
        if (scoped.ok()) 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/Crashpad (minidump) 或 xCrash (tombstone 格式) 记录后符号化定位. tombstone 解读与符号化流程详见 NDK Native 调试与 Crash 定位.

Q5: JNI 调用 Java 方法后为什么要检查异常? JNI 调用若使 Java 抛异常, native 代码不会自动中断. 要传播就检查后立即返回并保留 pending exception; ExceptionClear 只用于 native 已处理 / 转换的分支.

学完能做什么与练习

你应能让声明, 注册, 线程和引用生命周期一致. 练习: 故意改错 sum 签名, 让 Java 回调抛异常, 并模拟 attach 失败; 预期证据是加载期注册失败, 异常保留回 Java, ok()==false 时不解引用 JNIEnv*.

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 等资源封装成 RAII 对象, 在析构函数中统一释放, 避免裸 new/delete 和异常路径泄漏.