JNI 是指 Java 原生接口。它定义了 Android 从受管理代码(使用 Java 或 Kotlin 编程语言编写)编译的字节码与原生代码(使用 C/C++ 编写)进行交互的方式。JNI 不依赖于供应商,支持从动态共享库加载代码,虽然有时较为繁琐,但效率尚可。
注意:由于 Android 采用与 Java 编程语言类似的方式将 Kotlin 编译为适合 ART 的字节码,因此您可以根据 JNI 架构及其相关成本将本页中的指南应用于 Kotlin 和 Java 编程语言。 如需了解详情,请参阅 Kotlin 和 Android。
如果您还不熟悉 JNI,请仔细阅读 Java 原生接口规范,了解 JNI 的工作原理以及可用的功能。首次阅读时,接口的某些方面不会立即显而易见,因此接下来的几个部分可能会对您有所帮助。
如需浏览全局 JNI 引用并查看创建和删除全局 JNI 引用的位置,请使用 Android Studio 3.2 及更高版本的内存分析器中的 JNI 堆视图。
一般提示
尽量减少 JNI 层的占用空间。此时,需要考虑几个维度。 您的 JNI 解决方案应尝试遵循以下准则(按重要性顺序列出,从最重要的开始):
- 尽可能减少跨 JNI 层编组资源的次数。跨 JNI 层编组的费用很高。尝试设计一个接口,以尽可能减少需要编组的数据量以及必须进行数据编组的频率。
- 尽可能避免在使用受管理编程语言编写的代码与使用 C++ 编写的代码之间进行异步通信。 这样可使 JNI 接口更易于维护。通常,您可以采用与界面相同的语言来简化异步界面更新。例如,最好使用 Java 编程语言在两个线程之间执行回调,其中一个线程发出阻塞 C++ 调用,然后在阻塞调用完成后通知界面线程,而不是通过 JNI 从 Java 代码中的界面线程调用 C++ 函数。
- 最大限度地减少需要接触 JNI 或被 JNI 接触的线程数。 如果您确实需要使用 Java 和 C++ 语言的线程池,请尝试在池所有者之间(而不是各个工作器线程之间)保持 JNI 通信。
- 将接口代码保存在少量易于识别的 C++ 和 Java 源代码位置,便于未来的重构工作。请考虑视情况使用 JNI 自动生成库。
JavaVM 和 JNIEnv
JNI 定义了两个关键数据结构,即“JavaVM”和“JNIEnv”。两者本质上都是指向函数表的指针。(在 C++ 版本中,它们是一些类,这些类具有指向函数表的指针,以及通过表间接传递的每个 JNI 函数的成员函数)。JavaVM 提供“调用接口”函数,用于创建和销毁 JavaVM。理论上,每个进程可以有多个 JavaVM,但 Android 只允许有一个。
JNIEnv 提供了大部分 JNI 函数。您的原生函数都会接收 JNIEnv 作为第一个参数,但 @CriticalNative
方法除外,请参阅更快的原生调用。
该 JNIEnv 将用于线程本地存储。因此,您无法在线程之间共享 JNIEnv。如果代码段无法通过其他方法获取其 JNIEnv,您应该共享 JavaVM,并使用 GetEnv
发现线程的 JNIEnv。(假设该线程包含一个 JNIEnv;请参阅下面的 AttachCurrentThread
。)
JNIEnv 和 JavaVM 的 C 声明与 C++ 声明不同。"jni.h"
包含文件会提供不同的类型定义符,具体取决于该文件是包含在 C 还是 C++ 中。因此,我们不建议在这两种语言包含的头文件中包含 JNIEnv 参数。(换个说法:如果您的头文件需要 #ifdef __cplusplus
,且该头文件中的任何内容引用 JNIEnv,您可能需要执行一些额外的操作。)
Threads
所有线程都是 Linux 线程,由内核调度。它们通常从受管理代码启动(使用 Thread.start()
),但也可以在其他位置创建,然后附加到 JavaVM
。例如,可以使用 AttachCurrentThread()
或 AttachCurrentThreadAsDaemon()
函数附加使用 pthread_create()
或 std::thread
启动的线程。在附加之前,线程没有 JNIEnv,也无法进行 JNI 调用。
通常,最好使用 Thread.start()
创建任何需要调用 Java 代码的线程。这样做可确保您拥有足够的堆栈空间、位于正确的 ThreadGroup
中,且与 Java 代码使用相同的 ClassLoader
。此外,设置线程名称以在 Java 中进行调试也比从原生代码中更容易(如果您有 pthread_t
或 thread_t
,请参阅 pthread_setname_np()
;如果您有 std::thread
并且需要 pthread_t
,请参阅 std::thread::native_handle()
)。
附加原生创建的线程会构建 java.lang.Thread
对象并将其添加到“主”ThreadGroup
,使其对调试程序可见。在已附加的线程上调用 AttachCurrentThread()
属于空操作。
Android 不会挂起执行原生代码的线程。如果正在进行垃圾回收,或调试程序已发出挂起请求,Android 将在下次调用 JNI 时暂停线程。
通过 JNI 附加的线程必须在退出之前调用 DetachCurrentThread()
。如果直接对此进行编码会很棘手,在 Android 2.0 (Eclair) 及更高版本中,您可以使用 pthread_key_create()
定义要在线程退出之前调用的析构函数,然后从该函数调用 DetachCurrentThread()
。(将该键与 pthread_setspecific()
搭配使用,以将 JNIEnv 存储在 thread-local-storage 中;这样一来,该键将作为参数传入您的析构函数。)
jclass、jmethodID 和 jfieldID
如果要通过原生代码访问对象的字段,请执行以下操作:
- 使用
FindClass
获取类的类对象引用 - 使用
GetFieldID
获取字段的字段 ID - 使用适当内容获取字段的内容,例如
GetIntField
同样,如需调用方法,首先要获取类对象引用,然后获取方法 ID。ID 通常只是指向内部运行时数据结构的指针。查找这些信息可能需要进行多次字符串比较,但获得这些信息后,您可以非常快速地进行实际调用以获取字段或调用相应方法。
如果性能很重要,最好查找一次值并将结果缓存在原生代码中。由于每个进程仅限一个 JavaVM,因此可以将此数据存储在静态本地结构中。
在取消加载类之前,类引用、字段 ID 和方法 ID 保证有效。只有当与 ClassLoader 关联的所有类都可以进行垃圾回收时,系统才会卸载类,这种情况很少见,但在 Android 中并非不可能。但请注意,jclass
是类引用,必须通过调用 NewGlobalRef
来保护(请参阅下一部分)。
如果您想在加载类时缓存 ID,并在类被取消加载并重新加载时自动重新缓存,初始化 ID 的正确方法是将类似如下的一段代码添加到相应类:
companion object { /* * We use a static class initializer to allow the native code to cache some * field offsets. This native function looks up and caches interesting * class/field/method IDs. Throws on failure. */ private external fun nativeInit() init { nativeInit() } }
在执行 ID 查找的 C/C++ 代码中创建 nativeClassInit
方法。该代码将在初始化类时执行一次。如果取消加载该类后又重新加载,它将再次执行。
局部引用和全局引用
传递给原生方法的每个参数,以及 JNI 函数返回的几乎每个对象都是“局部引用”。这意味着,它在当前线程中的当前原生方法运行期间有效。 在原生方法返回后,即使对象本身继续存在,该引用也无效。
这适用于 jobject
的所有子类,包括 jclass
、jstring
和 jarray
。(启用扩展 JNI 检查后,运行时会针对大多数引