JNI 是 Java Native Interface(Java 原生接口)。它定义了 Android 从托管代码(使用 Java 或 Kotlin 编程语言编写)编译而成的字节码与原生代码(使用 C/C++ 编写)交互的方式。JNI 具有供应商中立性,支持从动态共享库加载代码,虽然有时比较繁琐,但效率相当不错。
注意:由于 Android 以类似于 Java 编程语言的方式将 Kotlin 编译为 ART 友好的字节码,因此此页面上的指南在 JNI 架构及其相关成本方面,同样适用于 Kotlin 和 Java 编程语言。要了解详情,请参阅 Kotlin 和 Android。
如果你还不熟悉 JNI,请阅读 Java Native Interface Specification,以了解 JNI 的工作原理和可用功能。接口的某些方面初读时可能并不立即显而易见,因此你可能会发现接下来的几个部分很有用。
要在 Android Studio 3.2 及更高版本的内存分析器中浏览全局 JNI 引用并查看全局 JNI 引用是在何处创建和删除的,请使用“JNI 堆”视图。
通用技巧
尽量减小 JNI 层的开销。这里有几个方面需要考虑。你的 JNI 解决方案应尽量遵循以下指南(按重要性顺序列出,最重要的排在前面)
- 尽量减少资源跨 JNI 层的封送处理。跨 JNI 层的封送处理成本不容忽视。尝试设计一个接口,最大限度地减少需要封送的数据量以及必须封送数据的频率。
- 尽可能避免托管编程语言编写的代码与 C++ 代码之间的异步通信。这将使你的 JNI 接口更易于维护。通常可以通过将异步更新保持在与 UI 相同的语言中来简化异步 UI 更新。例如,与其通过 JNI 在 Java 代码的 UI 线程中调用 C++ 函数,不如在 Java 编程语言中的两个线程之间进行回调,其中一个线程进行阻塞的 C++ 调用,然后在阻塞调用完成后通知 UI 线程。
- 尽量减少需要接触或被 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。(假设它存在;请参阅下面的 AttachCurrentThread
。)
JNIEnv 和 JavaVM 的 C 声明与 C++ 声明不同。"jni.h"
头文件根据它是被包含到 C 还是 C++ 中提供不同的 typedef。因此,在两种语言都包含的头文件中包含 JNIEnv 参数是一个不好的做法。(换句话说:如果你的头文件需要 #ifdef __cplusplus
,如果该头文件中的任何内容引用了 JNIEnv,你可能需要做一些额外的工作。)
线程
所有线程都是 Linux 线程,由内核调度。它们通常从托管代码(使用 Thread.start()
)启动,但也可以在其他地方创建,然后附加到 JavaVM
。例如,使用 pthread_create()
或 std::thread
启动的线程可以使用 AttachCurrentThread()
或 AttachCurrentThreadAsDaemon()
函数附加。在线程附加之前,它没有 JNIEnv,并且无法进行 JNI 调用。
对于需要调用 Java 代码的任何线程,通常最好使用 Thread.start()
创建。这样做可以确保你有足够的堆栈空间,处于正确的 ThreadGroup
中,并且使用与 Java 代码相同的 ClassLoader
。此外,在 Java 中设置线程名称进行调试也比从原生代码更容易(如果你有 pthread_t
或 thread_t
,请参阅 pthread_setname_np()
;如果你有 std::thread
并想要 pthread_t
,请参阅 std::thread::native_handle()
)。
附加原生创建的线程会导致构造一个 java.lang.Thread
对象并将其添加到“main” ThreadGroup
中,从而使其对调试器可见。对已经附加的线程调用 AttachCurrentThread()
是空操作。
Android 不会暂停执行原生代码的线程。如果垃圾回收正在进行中,或调试器发出了暂停请求,Android 会在该线程下一次进行 JNI 调用时暂停该线程。
通过 JNI 附加的线程在退出之前必须调用 DetachCurrentThread()
。如果直接编写此代码不方便,在 Android 2.0 (Eclair) 及更高版本中,你可以使用 pthread_key_create()
定义一个析构函数,该函数将在线程退出前调用,然后从那里调用 DetachCurrentThread()
。(使用该键和 pthread_setspecific()
将 JNIEnv 存储在线程本地存储中;这样它将作为参数传递到你的析构函数中。)
jclass、jmethodID 和 jfieldID
如果你想从原生代码访问对象的字段,你需要执行以下操作
- 使用
FindClass
获取类的类对象引用 - 使用
GetFieldID
获取字段的字段 ID - 使用适当的方法获取字段的内容,例如
GetIntField
类似地,要调用方法,你首先会获取一个类对象引用,然后获取一个方法 ID。这些 ID 通常只是指向内部运行时数据结构的指针。查找它们可能需要进行多次字符串比较,但一旦获取到它们,实际调用获取字段或调用方法的速度就非常快。
如果性能很重要,最好查找一次值并将结果缓存到你的原生代码中。由于每个进程只有一个 JavaVM 的限制,将此数据存储在静态局部结构中是合理的。
类引用、字段 ID 和方法 ID 在类卸载之前保证有效。只有当与 ClassLoader 关联的所有类都可以被垃圾回收时,类才会被卸载,这在 Android 中虽然罕见,但并非不可能。但是请注意,jclass
是一个类引用,并且必须通过调用 NewGlobalRef
来保护(请参阅下一节)。
如果你想在加载类时缓存 ID,并在类被卸载并重新加载时自动重新缓存它们,正确的初始化 ID 的方法是将类似这样的代码添加到相应的类中
Kotlin
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() } }
Java
/* * We use a 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 static native void nativeInit(); static { nativeInit(); }
在你的 C/C++ 代码中创建一个执行 ID 查找的 nativeClassInit
方法。该代码将在类初始化时执行一次。如果类被卸载然后重新加载,它将再次执行。
局部引用和全局引用
传递给原生方法的每个参数,以及 JNI 函数返回的几乎每个对象都是一个“局部引用”。这意味着它在当前线程中当前原生方法的持续时间内有效。即使对象本身在原生方法返回后仍然存在,该引用也是无效的。
这适用于 jobject
的所有子类,包括 jclass
、jstring
和 jarray
。(启用扩展 JNI 检查时,运行时会警告你大多数引用的误用。)
获取非局部引用的唯一方法是通过 NewGlobalRef
和 NewWeakGlobalRef
函数。
如果你想保留一个引用更长时间,你必须使用“全局”引用。NewGlobalRef
函数接收局部引用作为参数并返回一个全局引用。全局引用保证有效,直到你调用 DeleteGlobalRef
。
这种模式常用于缓存从 FindClass
返回的 jclass,例如
jclass localClass = env->FindClass("MyClass"); jclass globalClass = reinterpret_cast<jclass>(env->NewGlobalRef(localClass));
所有 JNI 方法都接受局部引用和全局引用作为参数。指向同一个对象的引用可能具有不同的值。例如,在同一个对象上连续调用 NewGlobalRef
的返回值可能不同。要查看两个引用是否指向同一个对象,必须使用 IsSameObject
函数。切勿在原生代码中使用 ==
比较引用。
这样做的一个结果是,你不能假设对象引用在原生代码中是常量或唯一的。代表对象的值可能在两次方法调用之间不同,并且两个不同的对象可能在连续调用中具有相同的值。不要使用 jobject
值作为键。
要求程序员“不要过度分配”局部引用。实际上,这意味着如果你正在创建大量的局部引用,例如在遍历对象数组时,你应该使用 DeleteLocalRef
手动释放它们,而不是让 JNI 为你处理。实现只需要为 16 个局部引用保留插槽,因此如果你需要更多,你应该边处理边删除,或使用 EnsureLocalCapacity
/PushLocalFrame
来预留更多。
请注意,jfieldID
和 jmethodID
是不透明类型,不是对象引用,不应传递给 NewGlobalRef
。诸如 GetStringUTFChars
和 GetByteArrayElements
等函数返回的原始数据指针也不是对象。(它们可以在线程之间传递,并且在匹配的 Release 调用之前有效。)
有一种特殊情况值得单独提及。如果你使用 AttachCurrentThread
附加了一个原生线程,则在你运行的代码中,局部引用永远不会自动释放,直到线程分离。你创建的任何局部引用都必须手动删除。一般来说,任何在循环中创建局部引用的原生代码可能都需要进行一些手动删除。
使用全局引用时要小心。全局引用可能不可避免,但它们难以调试,并可能导致难以诊断的内存(误用)行为。在其他条件相同的情况下,使用更少全局引用的解决方案可能更好。
UTF-8 和 UTF-16 字符串
Java 编程语言使用 UTF-16。为了方便起见,JNI 也提供了处理 Modified UTF-8 的方法。修改后的编码对于 C 代码很有用,因为它将 \u0000 编码为 0xc0 0x80,而不是 0x00。这样做的好处是你可以使用 C 风格的零终止字符串,适合与标准 libc 字符串函数一起使用。缺点是不能将任意 UTF-8 数据传递给 JNI 并期望它能正确工作。
要获取 String
的 UTF-16 表示形式,请使用 GetStringChars
。请注意,UTF-16 字符串不是零终止的,并且允许包含 \u0000,因此你需要保留字符串长度以及 jchar 指针。
别忘了 Release
你 Get
到的字符串。字符串函数返回 jchar*
或 jbyte*
,它们是 C 风格的指向原始数据的指针,而不是局部引用。它们保证在调用 Release
之前有效,这意味着它们在原生方法返回时不会被释放。
传递给 NewStringUTF 的数据必须是 Modified UTF-8 格式。一个常见的错误是读取文件或网络流中的字符数据,然后未经过滤地将其传递给 NewStringUTF
。除非你知道数据是有效的 MUTF-8(或 7 位 ASCII,它是兼容的子集),否则你需要去除无效字符或将其转换为正确的 Modified UTF-8 形式。否则,UTF-16 转换很可能产生意外结果。CheckJNI(默认在模拟器上开启)会扫描字符串,如果接收到无效输入,则会中止 VM。
在 Android 8 之前,使用 UTF-16 字符串通常更快,因为 Android 在 GetStringChars
中不需要复制,而 GetStringUTFChars
需要分配内存并转换为 UTF-8。Android 8 更改了 String
表示形式,对 ASCII 字符串使用每字符 8 位(以节省内存),并开始使用移动式垃圾回收器。这些特性极大地减少了 ART 可以在不进行复制的情况下提供指向 String
数据指针的情况数量,即使对于 GetStringCritical
也是如此。然而,如果代码处理的大多数字符串都很短,则可以通过使用栈分配的缓冲区和 GetStringRegion
或 GetStringUTFRegion
在大多数情况下避免分配和释放。例如
constexpr size_t kStackBufferSize = 64u; jchar stack_buffer[kStackBufferSize]; std::unique_ptr<jchar[]> heap_buffer; jchar* buffer = stack_buffer; jsize length = env->GetStringLength(str); if (length > kStackBufferSize) { heap_buffer.reset(new jchar[length]); buffer = heap_buffer.get(); } env->GetStringRegion(str, 0, length, buffer); process_data(buffer, length);
原始类型数组
JNI 提供了访问数组对象内容的功能。虽然对象数组必须逐个条目访问,但原始类型数组可以直接读写,就像它们在 C 中声明一样。
为了在不限制 VM 实现的情况下使接口尽可能高效,Get<PrimitiveType>ArrayElements
系列调用允许运行时返回指向实际元素的指针,或者分配一些内存并进行复制。无论哪种方式,返回的原始指针都保证在发出相应的 Release
调用之前有效(这意味着,如果数据没有被复制,数组对象将被固定住,并且无法作为堆压缩的一部分进行重新定位)。你必须 Release
你 Get
的每个数组。此外,如果 Get
调用失败,你必须确保你的代码以后不会尝试 Release
空指针。
你可以通过为 isCopy
参数传递一个非 NULL 指针来确定数据是否已被复制。这很少有用。
Release
调用接受一个 mode
参数,该参数可以具有三个值之一。运行时执行的操作取决于它返回的是指向实际数据的指针还是数据的副本
0
- 实际数据:数组对象解除固定。
- 副本:数据被复制回去。包含副本的缓冲区被释放。
JNI_COMMIT
- 实际数据:不执行任何操作。
- 副本:数据被复制回去。包含副本的缓冲区不被释放。
JNI_ABORT
- 实际数据:数组对象解除固定。先前的写入操作不会中止。
- 副本:包含副本的缓冲区被释放;对其所做的任何更改都将丢失。
检查 isCopy
标志的一个原因是了解在更改数组后是否需要使用 JNI_COMMIT
调用 Release
— 如果你正在交替进行更改和执行使用数组内容的代码,你可能会跳过无操作的提交。检查该标志的另一个可能原因是高效处理 JNI_ABORT
。例如,你可能想获取一个数组,就地修改它,将部分内容传递给其他函数,然后丢弃更改。如果你知道 JNI 正在为你创建一个新副本,则无需创建另一个“可编辑”的副本。如果 JNI 将原始数据传递给你,那么你确实需要自己创建一个副本。
一个常见的错误(在示例代码中也重复出现)是假设如果 *isCopy
为 false,就可以跳过 Release
调用。情况并非如此。如果没有分配复制缓冲区,则原始内存必须被固定,并且不能被垃圾回收器移动。
另请注意,JNI_COMMIT
标志不会释放数组,最终你需要使用不同的标志再次调用 Release
。
区域调用
除了 Get<Type>ArrayElements
和 GetStringChars
等调用之外,还有一种替代方法,当你只想复制数据进出时,它可能非常有用。考虑以下代码
jbyte* data = env->GetByteArrayElements(array, NULL); if (data != NULL) { memcpy(buffer, data, len); env->ReleaseByteArrayElements(array, data, JNI_ABORT); }
这段代码获取数组,从中复制前 len
个字节元素,然后释放数组。根据实现的不同,Get
调用会固定或复制数组内容。代码复制数据(可能第二次复制),然后调用 Release
;在这种情况下,JNI_ABORT
确保不会进行第三次复制。
可以用更简单的方式实现同样的事情
env->GetByteArrayRegion(array, 0, len, buffer);
这有几个优点
- 只需一次 JNI 调用而不是两次,从而降低开销。
- 无需固定或额外的数据复制。
- 降低了程序员出错的风险 — 不会忘记在出错后调用
Release
。
类似地,你可以使用 Set<Type>ArrayRegion
调用将数据复制到数组中,以及使用 GetStringRegion
或 GetStringUTFRegion
将字符从 String
中复制出来。
异常
在异常挂起时,你不得调用大多数 JNI 函数。你的代码应该注意到异常(通过函数的返回值、ExceptionCheck
或 ExceptionOccurred
)并返回,或者清除异常并处理它。
在异常挂起时,你唯一允许调用的 JNI 函数是
DeleteGlobalRef
DeleteLocalRef
DeleteWeakGlobalRef
ExceptionCheck
ExceptionClear
ExceptionDescribe
ExceptionOccurred
MonitorExit
PopLocalFrame
PushLocalFrame
Release<PrimitiveType>ArrayElements
ReleasePrimitiveArrayCritical
ReleaseStringChars
ReleaseStringCritical
ReleaseStringUTFChars
许多 JNI 调用可能会抛出异常,但通常提供了一种更简单的检查失败的方法。例如,如果 NewString
返回非 NULL 值,则无需检查异常。但是,如果你调用方法(使用 CallObjectMethod
等函数),你必须始终检查异常,因为如果抛出了异常,返回值将无效。
请注意,托管代码抛出的异常不会解开原生堆栈帧。(并且通常不鼓励在 Android 上使用 C++ 异常,它们不能跨越 JNI 转换边界从 C++ 代码抛到托管代码。)JNI 的 Throw
和 ThrowNew
指令只是在当前线程中设置一个异常指针。从原生代码返回到托管代码时,异常将被注意并得到适当处理。
原生代码可以通过调用 ExceptionCheck
或 ExceptionOccurred
来“捕获”异常,并使用 ExceptionClear
清除它。和往常一样,不处理而直接丢弃异常可能会导致问题。
没有内置函数用于操作 Throwable
对象本身,因此如果你想(比如)获取异常字符串,你需要找到 Throwable
类,查找 getMessage "()Ljava/lang/String;"
的方法 ID,调用它,如果结果非 NULL,则使用 GetStringUTFChars
获取可以传递给 printf(3)
或类似函数的内容。
扩展检查
JNI 很少进行错误检查。错误通常会导致崩溃。Android 还提供一种称为 CheckJNI 的模式,在该模式下,JavaVM 和 JNIEnv 函数表指针会切换到执行一系列扩展检查的函数表,然后再调用标准实现。
额外检查包括
- 数组:尝试分配负大小的数组。
- 无效指针:将无效的 jarray/jclass/jobject/jstring 传递给 JNI 调用,或将 NULL 指针传递给接受非 NULL 参数的 JNI 调用。
- 类名称:向 JNI 调用传递除“java/lang/String”样式之外的任何类名称。
- 关键调用:在“关键”获取及其相应的释放之间进行 JNI 调用。
- 直接 ByteBuffers:向
NewDirectByteBuffer
传递无效参数。 - 异常:在异常挂起时进行 JNI 调用。
- JNIEnv*:使用错误线程的 JNIEnv*。
- jfieldID:使用 NULL jfieldID,或使用 jfieldID 将字段设置为错误类型的值(例如,尝试将 StringBuilder 赋给 String 字段),或使用静态字段的 jfieldID 设置实例字段,反之亦然,或使用来自一个类的 jfieldID 处理另一个类的实例。
- jmethodID:在进行
Call*Method
JNI 调用时使用错误类型的 jmethodID:返回类型不正确,静态/非静态不匹配,‘this’ 的类型不正确(对于非静态调用),或类不正确(对于静态调用)。 - 引用:对错误类型的引用使用
DeleteGlobalRef
/DeleteLocalRef
。 - 释放模式:向释放调用传递错误的释放模式(不是
0
、JNI_ABORT
或JNI_COMMIT
)。 - 类型安全:从你的原生方法返回不兼容的类型(例如,从声明返回 String 的方法返回 StringBuilder)。
- UTF-8:向 JNI 调用传递无效的 Modified UTF-8 字节序列。
(方法和字段的可访问性仍未检查:访问限制不适用于原生代码。)
有几种方法可以启用 CheckJNI。
如果你使用模拟器,CheckJNI 默认开启。
如果你有已获得 root 权限的设备,可以使用以下命令序列重启运行时并启用 CheckJNI
adb shell stop adb shell setprop dalvik.vm.checkjni true adb shell start
在上述任一情况下,运行时启动时你会在 logcat 输出中看到类似以下内容
D AndroidRuntime: CheckJNI is ON
如果你有常规设备,可以使用以下命令
adb shell setprop debug.checkjni 1
这不会影响已经在运行的应用,但从那时起启动的任何应用都将启用 CheckJNI。(将属性更改为任何其他值或简单地重启将再次禁用 CheckJNI。)在这种情况下,下一次应用启动时,你将在 logcat 输出中看到类似以下内容
D Late-enabling CheckJNI
你也可以在应用的 manifest 文件中设置 android:debuggable
属性,以便仅为你自己的应用开启 CheckJNI。请注意,Android 构建工具会自动为某些构建类型执行此操作。
原生库
你可以使用标准的 System.loadLibrary
从共享库加载原生代码。
实际上,旧版本的 Android 在 PackageManager 中存在错误,导致原生库的安装和更新不可靠。ReLinker 项目为此以及其他原生库加载问题提供了解决方法。
从静态类初始化器中调用 System.loadLibrary
(或 ReLinker.loadLibrary
)。参数是“未修饰”的库名称,因此要加载 libfubar.so
,你需要传入 "fubar"
。
如果你的应用只有一个包含原生方法的类,将 System.loadLibrary
调用放在该类的静态初始化器中是有意义的。否则,你可能希望从 Application
中进行调用,以确保库始终加载,并且尽早加载。
运行时查找原生方法有两种方式。你可以使用 RegisterNatives
显式注册它们,或者让运行时使用 dlsym
动态查找它们。RegisterNatives
的优点在于你可以提前检查符号是否存在,此外,通过只导出 JNI_OnLoad
,可以拥有更小更快的共享库。让运行时发现函数的好处是编写的代码量稍微少一些。
要使用 RegisterNatives
- 提供一个
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved)
函数。 - 在你的
JNI_OnLoad
中,使用RegisterNatives
注册所有原生方法。 - 使用版本脚本(首选)或使用
-fvisibility=hidden
构建,以便只有你的JNI_OnLoad
从库中导出。这会生成更快、更小的代码,并避免与加载到应用中的其他库发生潜在冲突(但如果应用在原生代码中崩溃,这会导致堆栈跟踪不太有用)。
静态初始化器应如下所示
Kotlin
companion object { init { System.loadLibrary("fubar") } }
Java
static { System.loadLibrary("fubar"); }
如果使用 C++ 编写,JNI_OnLoad
函数应类似于这样
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) { JNIEnv* env; if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) { return JNI_ERR; } // Find your class. JNI_OnLoad is called from the correct class loader context for this to work. jclass c = env->FindClass("com/example/app/package/MyClass"); if (c == nullptr) return JNI_ERR; // Register your class' native methods. static const JNINativeMethod methods[] = { {"nativeFoo", "()V", reinterpret_cast<void*>(nativeFoo)}, {"nativeBar", "(Ljava/lang/String;I)Z", reinterpret_cast<void*>(nativeBar)}, }; int rc = env->RegisterNatives(c, methods, sizeof(methods)/sizeof(JNINativeMethod)); if (rc != JNI_OK) return rc; return JNI_VERSION_1_6; }
若改用原生方法的“发现”机制,则需要以特定的方式命名它们(详情请参阅JNI 规范)。这意味着如果方法签名错误,直到该方法实际首次被调用时你才会发现。
从 JNI_OnLoad
进行的任何 FindClass
调用都会在加载共享库的类加载器上下文中解析类。从其他上下文调用时,FindClass
会使用与 Java 堆栈顶部方法相关联的类加载器,如果不存在(因为调用来自刚刚附加的原生线程),它会使用“系统”类加载器。系统类加载器不知道你的应用类,因此在该上下文中你将无法使用 FindClass
查找你自己的类。这使得 JNI_OnLoad
成为一个方便的位置来查找和缓存类:一旦拥有有效的 jclass
全局引用,就可以从任何附加的线程中使用它。
使用 @FastNative
和 @CriticalNative
实现更快的原生调用
原生方法可以使用 @FastNative
或 @CriticalNative
(但不能同时使用)进行标注,以加快托管代码和原生代码之间的转换速度。然而,这些标注会带来某些行为变化,在使用前需要仔细考虑。虽然我们在下面简要提及这些变化,但请参阅文档以获取详细信息。
@CriticalNative
注解只能应用于不使用托管对象(参数中、返回值中或作为隐式的 this
)的原生方法,并且此注解会改变 JNI 转换 ABI。原生实现必须在其函数签名中排除 JNIEnv
和 jclass
参数。
执行 @FastNative
或 @CriticalNative
方法时,垃圾回收无法暂停线程以进行必要工作,并且可能会被阻塞。不要对长时间运行的方法使用这些注解,包括通常很快但通常不受限的方法。特别是,代码不应执行重要的 I/O 操作或获取可能长时间持有的原生锁。
这些注解自Android 8 起被实现用于系统使用,并在 Android 14 中成为经过 CTS 测试的公共 API。这些优化也可能在 Android 8-13 设备上工作(尽管没有强大的 CTS 保证),但原生方法的动态查找仅在 Android 12+ 上受支持,在 Android 8-11 版本上运行严格要求使用 JNI RegisterNatives
进行显式注册。这些注解在 Android 7- 版本上会被忽略,@CriticalNative
的 ABI 不匹配将导致错误的参数封送处理并可能导致崩溃。
对于需要这些注解的性能关键方法,强烈建议使用 JNI RegisterNatives
显式注册方法,而不是依赖于基于名称的原生方法“发现”。为了获得最佳应用启动性能,建议在基线配置文件中包含 @FastNative
或 @CriticalNative
方法的调用方。自 Android 12 起,从编译后的托管方法调用 @CriticalNative
原生方法的开销几乎与 C/C++ 中的非内联调用一样小,前提是所有参数都适合寄存器(例如,在 arm64 上最多支持 8 个整型参数和最多 8 个浮点参数)。
有时最好将一个原生方法拆分成两个,一个非常快但可能会失败的方法,以及另一个处理慢速情况的方法。例如
Kotlin
fun writeInt(nativeHandle: Long, value: Int) { // A fast buffered write with a `@CriticalNative` method should succeed most of the time. if (!nativeTryBufferedWriteInt(nativeHandle, value)) { // If the buffered write failed, we need to use the slow path that can perform // significant I/O and can even throw an `IOException`. nativeWriteInt(nativeHandle, value) } } @CriticalNative external fun nativeTryBufferedWriteInt(nativeHandle: Long, value: Int): Boolean external fun nativeWriteInt(nativeHandle: Long, value: Int)
Java
void writeInt(long nativeHandle, int value) { // A fast buffered write with a `@CriticalNative` method should succeed most of the time. if (!nativeTryBufferedWriteInt(nativeHandle, value)) { // If the buffered write failed, we need to use the slow path that can perform // significant I/O and can even throw an `IOException`. nativeWriteInt(nativeHandle, value); } } @CriticalNative static native boolean nativeTryBufferedWriteInt(long nativeHandle, int value); static native void nativeWriteInt(long nativeHandle, int value);
64 位注意事项
为了支持使用 64 位指针的架构,在 Java 字段中存储指向原生结构的指针时,请使用 long
字段而不是 int
。
不支持的功能/向后兼容性
支持所有 JNI 1.6 功能,但以下例外
DefineClass
未实现。Android 不使用 Java 字节码或类文件,因此传入二进制类数据无效。
为了与旧版 Android 版本向后兼容,你可能需要注意
- 原生函数的动态查找
直到 Android 2.0 (Eclair),在搜索方法名称时,字符 '$' 未正确转换为 "_00024"。解决此问题需要使用显式注册或将原生方法移出内部类。
- 分离线程
直到 Android 2.0 (Eclair),无法使用
pthread_key_create
析构函数来避免“线程退出前必须分离”的检查。(运行时也使用一个 pthread 键析构函数,所以会存在哪个先被调用的竞争。) - 弱全局引用
直到 Android 2.2 (Froyo),弱全局引用尚未实现。旧版本会强烈拒绝使用它们。你可以使用 Android 平台版本常量来测试支持情况。
直到 Android 4.0 (Ice Cream Sandwich),弱全局引用只能传递给
NewLocalRef
、NewGlobalRef
和DeleteWeakGlobalRef
。(规范强烈鼓励程序员在对弱全局引用进行任何操作之前创建对它们的强引用,因此这应该完全没有限制。)从 Android 4.0 (Ice Cream Sandwich) 开始,弱全局引用可以像其他任何 JNI 引用一样使用。
- 局部引用
直到 Android 4.0 (Ice Cream Sandwich),局部引用实际上是直接指针。Ice Cream Sandwich 添加了必要的间接层以支持更好的垃圾回收器,但这意味在旧版本中许多 JNI 错误无法检测。有关更多详细信息,请参阅 ICS 中 JNI 局部引用的变化。
在早于Android 8.0 的 Android 版本中,局部引用的数量受到版本特定的限制。从 Android 8.0 开始,Android 支持无限数量的局部引用。
- 使用
GetObjectRefType
确定引用类型直到 Android 4.0 (Ice Cream Sandwich),由于使用直接指针(见上文),无法正确实现
GetObjectRefType
。相反,我们使用了一种启发式方法,按弱全局表、参数、局部表和全局表的顺序查找。第一次找到你的直接指针时,它会报告你的引用是它正在检查的类型。这意味着,例如,如果你对作为静态原生方法隐式参数传递的全局 jclass 调用GetObjectRefType
,你将得到JNILocalRefType
,而不是JNIGlobalRefType
。 @FastNative
和@CriticalNative
直到 Android 7,这些优化注解都被忽略。
@CriticalNative
的 ABI 不匹配将导致错误的参数封送处理并可能导致崩溃。@FastNative
和@CriticalNative
方法的原生函数动态查找在 Android 8-10 中未实现,在 Android 11 中存在已知错误。在不使用 JNIRegisterNatives
进行显式注册的情况下使用这些优化可能会导致 Android 8-11 版本上的崩溃。FindClass
抛出ClassNotFoundException
为了向后兼容,当
FindClass
找不到类时,Android 会抛出ClassNotFoundException
,而不是NoClassDefFoundError
。此行为与 Java 反射 APIClass.forName(name)
一致。
常见问题:为什么我会收到 UnsatisfiedLinkError
?
在编写原生代码时,看到类似这样的失败是很常见的
java.lang.UnsatisfiedLinkError: Library foo not found
在某些情况下,它就是字面意思 — 库未找到。在其他情况下,库存在但无法通过 dlopen(3)
打开,失败的详细信息可以在异常的详细消息中找到。
可能遇到“库未找到”异常的常见原因
- 库不存在或应用无法访问。使用
adb shell ls -l <path>
检查其是否存在和权限。 - 库不是使用 NDK 构建的。这可能导致依赖于设备上不存在的函数或库。
另一类 UnsatisfiedLinkError
失败看起来像
java.lang.UnsatisfiedLinkError: myfunc at Foo.myfunc(Native Method) at Foo.main(Foo.java:10)
在 logcat 中,你将看到
W/dalvikvm( 880): No implementation found for native LFoo;.myfunc ()V
这意味着运行时尝试查找匹配的方法但未成功。一些常见原因如下
- 库未加载。检查 logcat 输出中有关库加载的消息。
- 由于名称或签名不匹配而导致方法未找到。这通常由以下原因引起
- 对于延迟方法查找,未能使用
extern "C"
和适当的可见性(JNIEXPORT
)声明 C++ 函数。请注意,在 Ice Cream Sandwich 之前,JNIEXPORT 宏是错误的,因此在新 GCC 中使用旧的jni.h
将无法工作。你可以使用arm-eabi-nm
查看符号在库中的显示情况;如果它们看起来已损坏(例如_Z15Java_Foo_myfuncP7_JNIEnvP7_jclass
而不是Java_Foo_myfunc
),或者符号类型是小写 't' 而不是大写 'T',则需要调整声明。 - 对于显式注册,输入方法签名时的小错误。请确保你传递给注册调用的内容与日志文件中的签名匹配。记住 'B' 是
byte
,'Z' 是boolean
。签名中的类名称组件以 'L' 开头,以 ';' 结尾,使用 '/' 分隔包名/类名,并使用 '$' 分隔内部类名(例如Ljava/util/Map$Entry;
)。
- 对于延迟方法查找,未能使用
使用 javah
自动生成 JNI 头文件可能有助于避免一些问题。
常见问题:为什么 FindClass
没有找到我的类?
(这些建议中的大部分同样适用于使用 GetMethodID
或 GetStaticMethodID
未找到方法,或使用 GetFieldID
或 GetStaticFieldID
未找到字段的情况。)
确保类名字符串格式正确。JNI 类名以包名开头,并使用斜杠分隔,例如 java/lang/String
。如果要查找数组类,你需要以适当数量的方括号开头,并且必须用 'L' 和 ';' 包围类,因此 String
的一维数组将是 [Ljava/lang/String;
。如果要查找内部类,请使用 '$' 而不是 '.'。一般来说,对 .class 文件使用 javap
是找出类内部名称的好方法。
如果你启用了代码缩减,请确保你配置要保留的代码。配置正确的保留规则非常重要,否则代码缩减器可能会删除仅从 JNI 使用的类、方法或字段。
如果类名看起来正确,你可能遇到了类加载器问题。FindClass
希望在与你的代码关联的类加载器中开始类搜索。它会检查调用堆栈,堆栈看起来会是这样
Foo.myfunc(Native Method) Foo.main(Foo.java:10)
最顶层的方法是 Foo.myfunc
。FindClass
会找到与 Foo
类相关联的 ClassLoader
对象并使用它。
这通常是你想要的结果。如果你自己创建了一个线程(可能通过调用 pthread_create
然后使用 AttachCurrentThread
附加),就可能遇到问题。这时你的应用中没有堆栈帧。如果从这个线程调用 FindClass
,JavaVM 将从“系统”类加载器开始查找,而不是与你的应用相关联的类加载器,因此查找应用特定类将失败。
有几种方法可以解决这个问题
- 在
JNI_OnLoad
中执行一次FindClass
查找,并缓存类引用以供以后使用。作为执行JNI_OnLoad
的一部分进行的任何FindClass
调用都将使用与调用System.loadLibrary
的函数关联的类加载器(这是一条特殊规则,旨在使库初始化更方便)。如果你的应用代码正在加载库,FindClass
将使用正确的类加载器。 - 通过声明你的原生方法接受一个 Class 参数,然后传入
Foo.class
,将类的实例传递给需要它的函数。 - 在方便的地方缓存对
ClassLoader
对象的引用,并直接发出loadClass
调用。这需要一些努力。
常见问题:如何与原生代码共享原始数据?
你可能会遇到需要在托管代码和原生代码中访问大量原始数据缓冲区的情况。常见示例包括位图或声音采样的操作。有两种基本方法。
你可以将数据存储在 byte[]
中。这使得从托管代码进行非常快的访问。但是,在原生端,不保证可以在不复制数据的情况下访问数据。在某些实现中,GetByteArrayElements
和 GetPrimitiveArrayCritical
将返回托管堆中原始数据的实际指针,但在其他实现中,它将在原生堆上分配一个缓冲区并复制数据。
另一种方法是将数据存储在直接字节缓冲区中。可以使用 java.nio.ByteBuffer.allocateDirect
或 JNI 函数 NewDirectByteBuffer
创建这些缓冲区。与常规字节缓冲区不同,存储不是分配在托管堆上,并且始终可以直接从原生代码访问(使用 GetDirectBufferAddress
获取地址)。根据直接字节缓冲区访问的实现方式,从托管代码访问数据可能非常慢。
选择哪种方法取决于两个因素
- 大多数数据访问将发生在 Java 代码还是 C/C++ 代码中?
- 如果数据最终要传递给系统 API,它必须采用什么形式?(例如,如果数据最终传递给接受 byte[] 的函数,那么在直接
ByteBuffer
中进行处理可能不明智。)
如果没有明确的赢家,请使用直接字节缓冲区。JNI 中直接内置了对它们的支持,性能应该在未来版本中得到改善。