NDK 与 JNI ☆
这是你的强项, 也是你的差异化武器. 一般应用开发者只会调用别人的 so, 你能独立写 SDK. 这一篇帮你把这份功底结构化成面试语言, 在三面 / 技术亮点环节碾压竞争者. 面试讲这块时要主动, 自信, 深入.
代码示例上下文: 本章 JNI/C++ 片段用于解释边界, 默认需要
#include <jni.h>, 对应 Java/Kotlinnative声明, 正确的包名/签名以及 CMake/NDK 配置; 除非明确标注, 不保证单个片段可脱离上下文独立编译.
一, JNI 基础与类型映射
JNI (Java Native Interface) 是 Java/Kotlin 与 C/C++ 互调的桥梁.
类型映射
| Java 类型 | JNI 类型 | 签名 |
|---|---|---|
| boolean | jboolean | Z |
| byte | jbyte | B |
| char | jchar | C |
| int | jint | I |
| long | jlong | J |
| float | jfloat | F |
| double | jdouble | D |
| Object | jobject | L 全限定名; |
| String | jstring | Ljava/lang/String; |
| int [] | jintArray | [I |
| void | void | V |
方法签名
格式 (参数)返回值, 如 (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 owner | C++ 侧弱引用 + 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 和异常路径泄漏.