JNI 提示

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_tthread_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 的正确方法是将类似如下的一段代码添加到相应类:

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();
    }

在执行 ID 查找的 C/C++ 代码中创建 nativeClassInit 方法。该代码将在初始化类时执行一次。如果取消加载该类后又重新加载,它将再次执行。

局部引用和全局引用

传递给原生方法的每个参数,以及 JNI 函数返回的几乎每个对象都是“局部引用”。这意味着,它在当前线程中的当前原生方法运行期间有效。 在原生方法返回后,即使对象本身继续存在,该引用也无效。

这适用于 jobject 的所有子类,包括 jclassjstringjarray。(启用扩展 JNI 检查后,运行时会针对大多数引用误用向您发出警告。)

获取非局部引用的唯一方法是通过 NewGlobalRefNewWeakGlobalRef 函数。

如果您想长时间保留某个引用,则必须使用“全局”引用。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 预留更多槽位。

请注意,jfieldIDjmethodID 是不透明类型,不是对象引用,不应传递给 NewGlobalRef。函数返回的原始数据指针(如 GetStringUTFCharsGetByteArrayElements)也不属于对象。(它们可以在线程之间传递,在匹配的 Release 调用完成之前一直有效。)

还有一种不寻常的情况值得单独提及。如果使用 AttachCurrentThread 附加原生线程,那么在线程分离之前,运行的代码绝不会自动释放局部引用。您创建的任何本地引用都必须手动删除。一般来说,在循环中创建局部引用的任何原生代码可能需要执行一些手动删除操作。

请谨慎使用全局引用。全局引用不可避免,但难以调试,并且可能会导致难以诊断的内存(不良)行为。在所有其他条件相同的情况下,全局引用数量较少的解决方案可能更好。

UTF-8 和 UTF-16 字符串

Java 编程语言使用的是 UTF-16。为方便起见,JNI 还提供了使用修改后的 UTF-8 的方法。修改后的编码对 C 代码非常有用,因为它将 \u0000 编码为 0xc0 0x80,而不是 0x00。这样做的好处是,您可以依赖以零结束的 C 样式字符串,这种字符串适合与标准 libc 字符串函数搭配使用。其缺点是,您无法将任意 UTF-8 数据传递给 JNI 并指望它能够正常运行。

如需获取 String 的 UTF-16 表示法,请使用 GetStringChars。请注意,UTF-16 字符串不是以零终止的,并且允许使用 \u0000,因此您需要保留字符串长度和 jchar 指针。

不要忘记 ReleaseGet 的字符串。字符串函数会返回 jchar*jbyte*,它们是指向原始数据而非局部引用的 C 样式指针。它们在调用 Release 之前保证有效,这意味着在原生方法返回时不会释放它们。

传递给 NewStringUTF 的数据必须采用修改后的 UTF-8 格式。一种常见的错误是,从文件或网络流中读取字符数据,并在未过滤的情况下将其传递给 NewStringUTF。除非您知道数据是有效的 MUTF-8(或 7 位 ASCII,这是一个兼容的子集),否则您需要剔除无效字符或将其转换为适当的修改后的 UTF-8 形式。如果不这样做,UTF-16 转换可能会产生意外的结果。 CheckJNI 默认针对模拟器启用,它会扫描字符串,并在收到无效输入时中止虚拟机。

在 Android 8 之前,使用 UTF-16 字符串通常能更快执行操作,因为 Android 不需要在 GetStringChars 中复制副本,而 GetStringUTFChars 则需要分配并转换为 UTF-8 格式。Android 8 将 String 的表示法改为了 ASCII 字符串的每个字符使用 8 位(以节省内存),并开始使用移动垃圾回收器。这些功能大大减少了 ART 无需复制即可提供指向 String 数据的指针的情况,即便对于 GetStringCritical 也是如此。不过,如果代码处理的大多数字符串都很短,在大多数情况下,可以通过使用堆栈分配的缓冲区和 GetStringRegionGetStringUTFRegion 来避免分配和取消分配。例如:

    constexpr size_t kStackBufferSize = 64u;
    jchar stack_buffer[kStackBufferSize];
    std::unique_ptr 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 语言中声明的一样。

为了在不限制虚拟机实现的情况下使接口尽可能高效,Get<PrimitiveType>ArrayElements 系列调用允许运行时返回指向实际元素的指针,或者分配一些内存并制作副本。无论采用哪种方式,在发出相应的 Release 调用之前,返回的原始指针保证有效(这意味着,如果未复制数据,数组对象将被固定,并且无法在压缩堆的过程中重新分配位置)。您必须 Release 自己 Get 的每个数组。此外,如果 Get 调用失败,您必须确保自己的代码稍后不会尝试 Release NULL 指针。

您可以通过传入 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>ArrayElementsGetStringChars 等调用,该方法会非常有用。请注意以下几点:

    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 调用将数据复制到数组中,以及使用 GetStringRegionGetStringUTFRegionString 中复制字符。

例外情况

在异常挂起时,不得调用大多数 JNI 函数。您的代码应该会注意到异常(通过函数的返回值 ExceptionCheckExceptionOccurred)并返回,或者清除异常并进行处理。

在异常待处理时,您只能调用以下 JNI 函数:

  • DeleteGlobalRef
  • DeleteLocalRef
  • DeleteWeakGlobalRef
  • ExceptionCheck
  • ExceptionClear
  • ExceptionDescribe
  • ExceptionOccurred
  • MonitorExit
  • PopLocalFrame
  • PushLocalFrame
  • Release<PrimitiveType>ArrayElements
  • ReleasePrimitiveArrayCritical
  • ReleaseStringChars
  • ReleaseStringCritical
  • ReleaseStringUTFChars

许多 JNI 调用都会抛出异常,但通常会提供一种更简单的失败检查方法。例如,如果 NewString 返回非 NULL 值,则无需检查异常。但是,如果您调用方法(使用 CallObjectMethod 等函数),则必须始终检查异常,因为如果系统抛出异常,返回值将无效。

请注意,受管理代码抛出的异常不会展开原生堆栈帧。(此外,通常不建议在 Android 上出现 C++ 异常,不得跨越从 C++ 代码到受管理代码的 JNI 转换边界抛出 C++ 异常。)JNI ThrowThrowNew 指令只是在当前线程中设置了异常指针。从原生代码返回到受管理代码后,系统会记录到异常并进行相应处理。

原生代码可通过调用 ExceptionCheckExceptionOccurred 来“捕获”异常,然后使用 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 调用。
  • 类名称:将类名称的“java/lang/String”样式之外的所有内容传递给 JNI 调用。
  • 关键调用:在“关键”get 及其相应 release 之间调用 JNI。
  • 直接字节缓冲区:将错误参数传递给 NewDirectByteBuffer
  • 异常:在异常挂起时调用 JNI。
  • JNIEnv*:使用错误线程中的 JNIEnv*。
  • jfieldID:使用 NULL jfieldID,或者使用 jfieldID 将字段设置为错误类型的值(例如,尝试将 StringBuilder 分配给 String 字段),或者使用静态字段的 jfieldID 设置实例字段(反之亦然),或者将一个类中的 jfieldID 与另一个类的实例搭配使用。
  • jmethodID:在调用 Call*Method JNI 时使用错误类型的 jmethodID:返回类型不正确、静态/非静态不匹配、“this”类型错误(对于非静态调用)或类错误(对于静态调用)。
  • 引用:对错误类型的引用使用 DeleteGlobalRef/DeleteLocalRef
  • Release 模式:将错误的 release 模式传递给 release 调用(除 0JNI_ABORTJNI_COMMIT 之外的内容)。
  • 类型安全:从原生方法返回不兼容的类型(例如,从声明返回 String 的方法返回 StringBuilder)。
  • UTF-8:将无效的修改后的 UTF-8 字节序列传递给 JNI 调用。

(仍未检查方法和字段的可访问性:访问限制不适用于原生代码。)

您可以通过以下几种方法启用 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

您还可以在应用清单中设置 android:debuggable 属性,以便仅为您的应用启用 CheckJNI。请注意,Android 构建工具会自动针对某些 build 类型执行此操作。

原生库

您可以使用标准 System.loadLibrary 从共享库加载原生代码。

实际上,旧版 Android 的 PackageManager 存在 bug,导致原生库的安装和更新不可靠。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。原生实现必须从其函数签名中排除 JNIEnvjclass 参数。

在执行 @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 开始,只要所有参数都能放入寄存器中(例如,最多 8 个整数参数和最多 8 个 arm64 浮点参数),通过已编译的受管理方法调用 @CriticalNative 原生方法的开销几乎与 C/C++ 中的非内嵌调用一样便宜。

有时,最好将一个原生方法一分为二,一种非常快速但可能会失败的方法,另一种方法可处理速度缓慢的情况。例如:

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) 之前,弱全局引用只能传递给 NewLocalRefNewGlobalRefDeleteWeakGlobalRef。(该规范强烈建议编程人员先对弱全局创建硬引用,然后再对其执行操作,因此这不应该有任何限制。)

    从 Android 4.0 (Ice Cream Sandwich) 开始,您可以像使用任何其他 JNI 引用一样使用弱全局引用。

  • 局部引用

    在 Android 4.0 (Ice Cream Sandwich) 之前,局部引用实际上是直接指针。Ice Cream Sandwich 添加了支持更好的垃圾回收器所需的间接,但这意味着无法检测到较低版本的许多 JNI bug。如需了解详情,请参阅 ICS 中的 JNI 局部引用更改

    Android 8.0 之前的 Android 版本中,局部引用的数量取决于版本特定的限制。从 Android 8.0 开始,Android 支持无限制的局部引用。

  • 使用 GetObjectRefType 确定引用类型

    在 Android 4.0 (Ice Cream Sandwich) 之前,由于使用了直接指针(参见上文),因此无法正确实现 GetObjectRefType。我们采用启发法,按顺序查看弱全局表、参数、局部表和全局表。第一次找到您的直接指针时,它会报告您的引用类型正好是要检查的类型。这意味着,例如,如果您对全局 jclass 调用了 GetObjectRefType,而该 jclass 正好与作为隐式参数传递给静态原生方法的 jclass 相同,则会得到 JNILocalRefType 而不是 JNIGlobalRefType

  • @FastNative@CriticalNative

    在 Android 7 及更早版本中,这些优化注解会被忽略。@CriticalNative 的 ABI 不匹配会导致参数编组错误,并且可能会发生崩溃。

    @FastNative@CriticalNative 方法的原生函数动态查找在 Android 8-10 中未实现,并且包含 Android 11 中的已知 bug。如果在没有向 JNI RegisterNatives 明确注册的情况下使用这些优化功能,可能会导致 Android 8-11 发生崩溃。

  • FindClass 抛出 ClassNotFoundException

    为了向后兼容,当 FindClass 找不到某个类时,Android 会抛出 ClassNotFoundException,而不是 NoClassDefFoundError。此行为与 Java 反射 API Class.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 找不到我的类?

(以下建议的大部分内容同样适用于无法使用 GetMethodIDGetStaticMethodID 找到方法,或者无法使用 GetFieldIDGetStaticFieldID 找到字段的情况。)

确保类名称字符串的格式正确无误。JNI 类名称以软件包名称开头,并用斜杠分隔,例如 java/lang/String。如果要查找数组类,您需要以适当数量的方括号开头,并且还必须用“L”和“;”将该类括起来,因此 String 的一维数组为 [Ljava/lang/String;。如果您要查找内部类,请使用“$”而非“.”。通常,在 .class 文件上使用 javap 是查找类的内部名称的好方法。

如果要启用代码缩减,请确保配置要保留的代码。配置适当的保留规则非常重要,因为代码缩减器可能会移除仅在 JNI 中使用的类、方法或字段。

如果类名称看起来正确,您可能会遇到类加载器问题。FindClass 希望在与您的代码关联的类加载器中启动类搜索。它会检查调用堆栈,如下所示:

    Foo.myfunc(Native Method)
    Foo.main(Foo.java:10)

最顶层的方法是 Foo.myfuncFindClass 会查找与 Foo 类关联的 ClassLoader 对象并使用该对象。

采用这种方法通常会完成您想要执行的操作。如果您自行创建线程(可能通过调用 pthread_create,然后使用 AttachCurrentThread 附加该线程),可能会遇到麻烦。现在,您的应用中没有堆栈帧。 如果您从此线程调用 FindClass,JavaVM 会在“系统”类加载器(而非与应用关联的类加载器)中启动,因此尝试查找特定于应用的类将会失败。

您可以通过以下几种方法来解决此问题:

  • JNI_OnLoad 中执行一次 FindClass 查找,然后缓存类引用以供日后使用。在执行 JNI_OnLoad 过程中进行的任何 FindClass 调用都将使用与调用 System.loadLibrary 的函数关联的类加载器(这是一条特殊规则,用于更方便地进行库初始化)。 如果您的应用代码要加载库,FindClass 将使用正确的类加载器。
  • 通过声明原生方法接受 Class 参数,然后传入 Foo.class,将类的实例传递给需要它的函数。
  • 在某个便捷位置缓存对 ClassLoader 对象的引用,然后直接发出 loadClass 调用。这需要花费一些精力。

常见问题解答:如何使用原生代码共享原始数据?

您可能会发现自己需要访问来自受管理代码和原生代码的大量原始数据缓冲区。常见示例包括操纵位图或声音样本。有两种基本方法。

您可以将数据存储在 byte[] 中。这样一来,您便可非常快速地通过受管理代码进行访问。但在原生端,则不能保证在没有复制的情况下也能访问数据。在某些实现中,GetByteArrayElementsGetPrimitiveArrayCritical 将返回指向托管堆中原始数据的实际指针,但在其他实现中,它将在原生堆上分配缓冲区并复制数据。

另一种方法是将数据存储在直接字节缓冲区中。这些依赖项可以使用 java.nio.ByteBuffer.allocateDirect 或 JNI NewDirectByteBuffer 函数创建。与常规字节缓冲区不同,存储空间不在托管堆上分配,并且始终可以直接从原生代码访问(使用 GetDirectBufferAddress 获取地址)。根据直接字节缓冲区访问的实现方式,从受管理代码访问数据可能会很慢。

选择使用哪种方法取决于以下两个因素:

  1. 大部分数据访问是否来自使用 Java 或 C/C++ 编写的代码?
  2. 如果数据最终要传递到系统 API,它必须采用什么格式?(例如,如果数据最终传递给采用 byte[] 的函数,则在直接 ByteBuffer 中处理数据可能不是明智之举。)

如果这两种方法不分伯仲,请使用直接字节缓冲区。JNI 中直接内置了对它们的支持,并且性能应该会在未来版本中得到提升。