NDK 与 JNI ☆
这是你的强项,也是你的差异化武器。 一般应用开发者只会调用别人的 so,你能独立写 SDK。这一篇帮你把这份功底结构化成面试语言,在三面/技术亮点环节碾压竞争者。面试讲这块时要主动、自信、深入。
一、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_->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++ 函数名,但不能替代混淆/加固/权限校验 |
动态注册要检查 FindClass、RegisterNatives 返回值和 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 后用
addr2line、ndk-stack或 Breakpad/Crashpad 工具映射到函数、文件和行号。
七、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);
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 和异常路径泄漏。