跳到主要内容

JNI

JNI(Java Native Interface)规定托管代码与本地代码怎样互相调用。在 Android 上,Java、Kotlin 编译后的代码通过这套接口与 C/C++ 交互;NDK 则提供编译 Android 本地代码所需的工具和库。

JNI 本身不会让计算自动变快。它增加了类型转换、数据访问和生命周期管理责任,适合复用已有本地库、接入本地接口,或处理经过测量确认适合本地实现的工作。面试中的关键是说明调用怎样匹配,以及线程、对象和异常由谁管理。

1. 面试核心​

先记住这几条,再读正文
  • NDK 提供 Android 本地代码的工具与库,JNI 规定托管代码与本地代码的交互方式,两者不是同一个层次。卡 · 方法声明与注册
  • 本地方法需要匹配类、方法名和 JVM 描述符,符号查找与 RegisterNatives 都必须遵守同一份调用约定。卡 · 方法声明与注册
  • JNIEnv* 属于当前线程,不能把一个线程的 JNIEnv 直接交给另一个线程。本地创建的线程需要关联虚拟机,并在适当时机解除关联。卡 · 线程关联
  • 局部引用、全局引用和弱全局引用有不同生命周期,全局引用必须显式删除,弱全局引用也必须先安全提升后使用。卡 · JNI 引用的生命周期
  • 字符串和数组访问可能复制数据,取得元素指针不等于取得永久稳定的对象内存地址,释放规则不依赖是否发生了复制。卡 · 数组借用、复制与临界区
  • Java 异常不会像 C++ 异常那样自动展开本地栈,调用后需要检查待处理异常,并完成允许的资源清理。卡 · 异常与资源清理
  • JNI 调用有跨边界成本,应控制调用频率、传输规模和资源所有权,再用真实场景测量收益。卡 · 调用成本与适用范围

2. 方法声明与注册​

2.1 类名、方法名和描述符​

Java 用 native 声明本地方法,Kotlin 使用 external。虚拟机需要把这个声明与共享库中的某个函数关联。Android 的 System.loadLibrary("campus") 按平台规则加载 libcampus.so,参数不是完整文件名或任意网络地址。

下面用 Java 声明两个静态方法,避免 Kotlin 伴生对象、名称修饰等额外因素干扰调用关系:

package com.example;

public final class NativeMath {
static {
System.loadLibrary("campus");
}

private NativeMath() {}

public static native long add(int a, int b);
public static native long sum(int[] values);
}

方法描述符用紧凑的字符串表示参数和返回类型。add(int, int): long 的描述符是 (II)J,sum(int[]): long 是 ([I)J:括号里是参数,后面是返回类型。

Java 类型描述符示例
int、long、boolean、voidI、J、Z、V,V 用于返回值
StringLjava/lang/String;
int[][I
String[][Ljava/lang/String;

类查找使用 com/example/NativeMath 这类内部名称。应通过 javap -s 等工具确认编译后的签名,尤其是 Kotlin 属性、伴生对象或生成桥接方法涉及的接口。

2.2 RegisterNatives 与 JNI_OnLoad​

RegisterNatives 显式登记“Java 方法名、描述符、本地函数地址”的对应关系。Android 官方建议在适合的场景中集中登记,便于加载时暴露签名错误。另一种方式是按 Java_包名_类名_方法名 等规定导出符号,由虚拟机查找;重载和特殊字符也有命名规则,不能只简单拼接字符串。

下面的 C++ 实现同时展示注册和数组读取:

#include <jni.h>
#include <algorithm>

static jlong Add(JNIEnv*, jclass, jint a, jint b) {
return static_cast<jlong>(a) + static_cast<jlong>(b);
}

static jlong Sum(JNIEnv* env, jclass, jintArray values) {
if (values == nullptr) {
jclass error = env->FindClass("java/lang/IllegalArgumentException");
if (error != nullptr) {
env->ThrowNew(error, "values must not be null");
env->DeleteLocalRef(error);
}
return 0;
}

const jsize length = env->GetArrayLength(values);
jlong total = 0;
jint buffer[64];
for (jsize offset = 0; offset < length;) {
const jsize count = std::min<jsize>(64, length - offset);
env->GetIntArrayRegion(values, offset, count, buffer);
if (env->ExceptionCheck()) return 0;
for (jsize i = 0; i < count; ++i) total += buffer[i];
offset += count;
}
return total;
}

extern "C" JNIEXPORT jint JNICALL 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 type = env->FindClass("com/example/NativeMath");
if (type == nullptr) return JNI_ERR;

JNINativeMethod methods[] = {
{const_cast<char*>("add"), const_cast<char*>("(II)J"),
reinterpret_cast<void*>(Add)},
{const_cast<char*>("sum"), const_cast<char*>("([I)J"),
reinterpret_cast<void*>(Sum)}
};
const jint result = env->RegisterNatives(type, methods, 2);
env->DeleteLocalRef(type);
return result == JNI_OK ? JNI_VERSION_1_6 : JNI_ERR;
}

静态方法的第二个参数是 jclass;实例方法对应 jobject,表示接收对象。Add() 先提升到 jlong 再相加,避免先在有符号 jint 上溢出。Sum() 按固定大小分批复制数组内容,空数组返回 0,空引用引发 Java 异常。

GetIntArrayRegion() 写进调用者提供的缓冲区,因此没有需要通过 ReleaseIntArrayElements() 释放的借用指针。它不能保证其他 Java 线程不同时修改数组:如果业务需要一致快照,应在上层约定调用期间不修改,或者先做受控复制。

JNI_OnLoad 在库加载时提供初始化入口。它返回支持的 JNI 版本;注册失败时不能假装初始化成功。这里返回 JNI_VERSION_1_6 表示采用的 JNI 接口版本,与 Java 语言版本、Android API level 不属于同一编号体系。

2.3 类加载与发布构建​

FindClass() 的查找环境与调用位置有关。在 Android 的 JNI_OnLoad 中,查找可以使用加载共享库的类加载器;一个刚由 C++ 创建并关联的线程,未必具有应用方法调用栈对应的类加载环境。不能假定任意线程都能按名称找到应用类。

需要缓存类时,应保存适当的全局引用,并约定释放时机;jmethodID、jfieldID 属于成员标识,不是 jobject 引用,不应传给 NewGlobalRef()。它们的有效性与定义类的存续有关。

R8 无法从任意本地字符串自动推断所有 Java 访问。JNI 按名称查找的方法、字段和类,需要与相应保留规则配合,具体见 Android 构建。排查 UnsatisfiedLinkError 时,应分别检查库是否存在、ABI 和依赖是否匹配、库是否加载成功,以及注册或符号签名是否一致。

3. 线程关联​

JavaVM 提供进程中虚拟机的访问入口;Android 每个进程只有一个 JavaVM。JNIEnv 提供当前线程执行 JNI 操作的接口。它包含线程相关状态,所以不能保存一个线程的 JNIEnv*,然后在另一个线程直接使用。

Java 创建的线程进入本地方法时,虚拟机会传入该线程对应的 JNIEnv。本地创建的线程若要调用 Java,通常需要:

  1. 通过保存的 JavaVM* 调用 GetEnv(),判断当前线程是否已经关联。
  2. 尚未关联时调用 AttachCurrentThread(),并记录关联是由自己完成的。
  3. 使用当前线程取得的 JNIEnv 执行调用,并检查异常。
  4. 在线程退出前,对自己建立的关联调用 DetachCurrentThread()。

不能把一个原本由 Java 管理、已经关联的线程无条件解除关联。关联线程也不意味着可以更新 Android UI;调用仍在当前线程上执行,需要按 UI 框架要求切回主线程。

从本地线程回调 Java 时,还要协调对象引用、回调取消和线程退出。页面关闭后不能只删除全局引用却让工作线程继续访问它;应先结束或取消相应工作,再按同步协议释放引用。引用跨线程有效,不代表对象业务操作自动线程安全。

4. JNI 引用的生命周期​

4.1 局部、全局与弱全局引用​

JNI 的 jobject 等值是虚拟机管理的引用句柄,不能按普通 C++ 对象指针解引用。两个句柄可能指向同一个 Java 对象,比较对象身份应使用 IsSameObject(),不能依赖句柄数值相等。

引用保持对象存活作用范围和释放责任
局部引用是当前线程的局部引用作用范围;通常在本地方法返回时清理,也可 DeleteLocalRef
全局引用是可跨调用、跨已关联线程使用;必须 DeleteGlobalRef
弱全局引用否可在对象回收后失效;句柄本身仍需 DeleteWeakGlobalRef

长循环里反复创建局部引用,即使方法最终返回会清理,也可能在返回前积累过多引用。应及时删除,或使用 PushLocalFrame()、PopLocalFrame() 管理批次。本地创建且长期关联的线程,也不能指望每次普通 C++ 函数返回都会清空 JNI 局部引用。

全局引用常用于异步回调对象。它会阻止对象回收,因此若该对象间接持有 Activity,就可能造成界面泄漏。释放 native 缓冲区和删除 Java 全局引用是两项不同责任,两者都需要明确所有者。

4.2 弱全局引用需要安全提升​

弱全局引用可能在检查之后、使用之前被回收。不能先用 IsSameObject(weak, nullptr) 判断存在,再把 weak 当稳定强引用继续操作。

需要使用时,调用 NewLocalRef(weak) 尝试获得局部强引用。若返回非空,这份局部引用会在使用期间保持对象存活;使用完后删除。若返回空,应按对象已消失或创建引用失败处理,并按接口情况检查待处理异常。

这种提升只处理对象回收竞争。另一个线程若同时删除弱引用句柄本身,仍然需要额外同步,不能让 NewLocalRef() 操作一个已经被删除的句柄。

5. 字符串与数组访问​

5.1 JNI 字符串不是任意 UTF-8 字节​

Java String 以 UTF-16 代码单元表达内容。GetStringChars() 提供 jchar 序列,长度使用 GetStringLength() 取得,不能按 C 字符串遇到零就结束。

名称含 UTF 的传统 JNI 接口使用 Modified UTF-8。它与标准 UTF-8 在零字符和补充平面字符等编码上有区别。不能把任意网络 UTF-8 字节直接交给 NewStringUTF() 并假定都合法;应明确解码方式,例如在 Java 层用指定字符集建立 String,再传入。

GetStringChars()、GetStringUTFChars() 返回的指针有对应释放接口。即使 isCopy 表示没有复制,也不能跳过 Release;指针的有效期由 JNI 契约决定,不是永久地址。

5.2 数组借用、复制与临界区​

GetIntArrayElements() 等接口可能返回副本,也可能提供受虚拟机管理的直接访问。调用者必须配对释放,并理解 mode:

  • 0:必要时复制修改结果回数组,并结束这次访问。
  • JNI_COMMIT:提交副本中的修改,但不释放该副本;仍需后续释放。
  • JNI_ABORT:不把副本改动复制回去,并结束访问。若取得的是直接访问,已经写入原数组的修改不会因此撤销。

所以 JNI_ABORT 不能当作通用事务回滚。只读访问仍需 Release;不能把取得的元素指针保存到异步任务,在释放后继续使用。

只需要复制部分数据时,Region 系列接口可简化资源管理。需要使用 GetPrimitiveArrayCritical() 等临界访问时,应把范围保持很短,不在期间调用任意 JNI 接口、等待锁或执行长时间阻塞操作。它不是“永远零拷贝”的性能保证。

6. 异常与资源清理​

待处理异常是虚拟机记录的、等待返回 Java 调用路径后传播的异常。ThrowNew() 或一次 Java 方法调用产生异常后,C++ 函数不会自动像执行了 C++ throw 那样跳过后续语句。

本地代码需要检查 ExceptionCheck() 等结果,并只执行契约允许的异常查询和清理操作,例如释放已经取得的字符串、数组访问或删除引用,然后返回。不能带着异常继续任意查找类、调用 Java 业务方法,或随手清除异常后报告成功。

反过来,C++ 异常也不能越过 JNI 边界任意传播。可能抛异常的本地实现应在边界捕获,完成 C++ 对象生命周期 中的 RAII 清理,再按约定转换成 Java 异常或错误结果。std::bad_alloc、库异常和已有 Java 异常还需要避免互相覆盖。

Java 对象受到 GC 管理,不表示 native 分配会自动释放。new、文件描述符、库句柄和全局引用都需要成对管理。崩溃排查应结合符号化 native 栈、Java 调用栈和资源所有权;Android 的 CheckJNI 能帮助发现非法引用、错误线程和部分签名问题,但不能替代所有内存错误检测。

7. 调用成本与适用范围​

跨 JNI 的成本包括进入本地方法、字段或方法查找、数据转换和复制等。把每个像素都通过一次 Java 回调读取,可能比把一块像素数据一次传入更昂贵;一次传入过大的数据又可能增加复制与峰值内存。

设计时应先明确一批工作的边界,让 JNI 接口尽量窄,数据格式和所有权清楚。异步流程尽量由一侧统筹,避免 Java 和 C++ 各自维护线程池,再互相细粒度回调。若需要 native 工作线程,就把取消、回调次数和对象释放纳入接口契约。

最终使用相同输入、设备和构建条件测量端到端耗时、内存与响应性。本地代码仍可能阻塞主线程、发生数据竞争或内存泄漏,不能把“用了 NDK”当作优化已经完成。

8 · 面试题卡片​

本页题目与参考回答

8.1 NDK 是什么?​

考察重点:Android 本地开发工具与语言交互接口的分工。

相关内容:方法声明与注册、调用成本与适用范围。

参考回答

NDK 提供编译和使用 Android 本地 C/C++ 代码所需的工具与库。 需要与 Java、Kotlin 代码交互时,可通过 JNI 声明和注册方法,并管理线程、引用和异常。它适合复用本地库或处理经测量适合本地实现的任务,但需要额外处理 ABI、构建和资源生命周期,不能仅凭使用 NDK 判断性能更好。

8.2 Java 的 native 方法怎样关联到 C++ 函数?​

考察重点:库加载、描述符、注册与符号查找。

相关内容:方法声明与注册。

参考回答

加载共享库后,可以按 JNI 导出命名规则查找,也可以通过 RegisterNatives 显式登记。 登记需要类、方法名、描述符和函数地址一致,例如两个 int 返回 long 使用 (II)J。本地函数还要正确接收 JNIEnv,以及静态方法的 jclass 或实例方法的 jobject;发布构建的名称保留和 ABI 也要一致。

8.3 可以把 JNIEnv 缓存起来给工作线程使用吗?​

考察重点:线程关联、JavaVM 与解除关联责任。

相关内容:线程关联。

参考回答

不能跨线程复用 JNIEnv。 可以保存 JavaVM,让工作线程取得自己的 JNIEnv;尚未关联时调用 AttachCurrentThread,并在线程退出前解除自己建立的关联。关联不改变线程身份,也不赋予后台线程操作 UI 的权限。回调对象还需要正确引用和取消协议。

8.4 局部引用、全局引用和弱全局引用有什么区别?​

考察重点:作用范围、对象存活、释放及弱引用竞争。

相关内容:JNI 引用的生命周期。

参考回答

局部和全局引用保持对象存活,弱全局引用不保持;三者的范围和清理方式不同。 局部引用受当前线程和局部作用范围约束,全局引用可跨调用使用但需要显式删除。使用弱全局引用前应尝试建立局部强引用,不能先检查再直接使用;句柄删除本身还需要同步。

8.5 GetIntArrayElements 没有复制数据时,还需要 Release 吗?JNI_ABORT 能回滚吗?​

考察重点:访问期、复制条件与释放 mode。

相关内容:数组借用、复制与临界区。

参考回答

仍需要配对 Release;JNI_ABORT 也不是通用回滚。 有副本时它不复制修改回数组;直接访问原数组时,已经发生的修改不会自动撤销。JNI_COMMIT 提交副本但不释放副本,还需要后续释放。只读访问同样遵守释放责任。

8.6 本地代码调用 Java 方法后发生异常,可以继续执行普通 JNI 调用吗?​

考察重点:待处理异常与 C++ 栈展开的区别,边界清理。

相关内容:异常与资源清理。

参考回答

不能把待处理异常当成没有发生,继续任意 JNI 调用。 应检查异常、执行允许的清理并返回,让异常向 Java 传播;确需处理时遵守明确协议。Java 异常不会自动展开 C++ 栈,C++ 异常也不能任意越过 JNI 边界,应在边界转换并保证资源释放。

8.7 把一段 Java 计算改成 C++,一定能提高性能吗?​

考察重点:跨边界成本、批处理、数据复制和测量口径。

相关内容:调用成本与适用范围。

参考回答

不一定,收益要覆盖 JNI 转换、复制和调用成本。 需要确定计算瓶颈,控制调用粒度和资源生命周期,再在一致条件下测量端到端表现。高频细小调用、反复回调 Java 或多次复制大对象,都可能抵消本地计算的收益。

9. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明