JNI 提示

JNI 是 Java 本地接口。它定义了一种方法,用于将 Android 从托管代码(使用 Java 或 Kotlin 编程语言编写)编译的字节码与原生代码(使用 C/C++ 编写)进行交互。JNI 与供应商无关,支持从动态共享库加载代码,虽然有时很繁琐,但效率相当高。

注意:由于 Android 以类似于 Java 编程语言的方式将 Kotlin 编译为 ART 友好的字节码,因此您可以将此页面上的指导应用于 Kotlin 和 Java 编程语言,就 JNI 架构及其相关成本而言。要了解更多信息,请参阅 Kotlin 和 Android

如果您还不熟悉它,请阅读 Java 本地接口规范 以了解 JNI 的工作原理以及可用的功能。有些接口方面在初次阅读时并不立即显而易见,因此您可能会发现接下来的几节内容很有用。

要浏览全局 JNI 引用并查看全局 JNI 引用在何处创建和删除,请在 Android Studio 3.2 及更高版本中使用 内存分析器 中的JNI 堆视图。

常规提示

尝试最大限度地减少 JNI 层的占用空间。这里需要考虑几个方面。您的 JNI 解决方案应尝试遵循以下准则(按重要性顺序排列,从最重要开始)

  • 最大限度地减少跨 JNI 层的资源封送处理。跨 JNI 层的封送处理具有非微不足道的成本。尝试设计一个接口,最大限度地减少您需要封送处理的数据量以及必须封送处理数据的频率。
  • 尽可能避免在托管编程语言编写的代码和 C++ 编写的代码之间进行异步通信。这将使您的 JNI 接口更易于维护。您可以通过将异步 UI 更新保留在与 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 函数。除 @CriticalNative 方法外,您的所有原生函数都将 JNIEnv 作为第一个参数接收,请参阅 更快的原生调用

JNIEnv 用于线程局部存储。因此,**您不能在多个线程之间共享 JNIEnv**。如果一段代码没有其他方法获取其 JNIEnv,则应共享 JavaVM,并使用GetEnv来查找线程的 JNIEnv。(假设它有一个;请参见下面的AttachCurrentThread。)

JNIEnv 和 JavaVM 的 C 声明与 C++ 声明不同。"jni.h"包含文件根据其包含在 C 还是 C++ 中提供不同的类型定义。因此,将 JNIEnv 参数包含在被两种语言都包含的头文件中是一个坏主意。(换句话说:如果您的头文件需要#ifdef __cplusplus,如果该头文件中的任何内容引用 JNIEnv,您可能需要做一些额外的工作。)

线程

所有线程都是 Linux 线程,由内核调度。它们通常从托管代码启动(使用Thread.start()),但它们也可以在其他地方创建,然后附加到JavaVM。例如,使用pthread_create()std::thread启动的线程可以使用AttachCurrentThread()AttachCurrentThreadAsDaemon()函数附加。在附加线程之前,它没有 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;这样它将作为参数传递到您的析构函数中。)

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++ 代码中创建一个nativeClassInit方法来执行 ID 查找。该代码将在类初始化时执行一次。如果类被卸载然后重新加载,它将再次执行。

局部引用和全局引用

传递给原生方法的每个参数以及 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 的数据必须是 Modified UTF-8 格式。一个常见的错误是从文件或网络流中读取字符数据,并在未进行过滤的情况下将其传递给 NewStringUTF。除非您知道数据是有效的 MUTF-8(或 7 位 ASCII,它是兼容的子集),否则您需要去除无效字符或将其转换为正确的 Modified UTF-8 格式。如果不这样做,UTF-16 转换很可能产生意外的结果。CheckJNI(默认情况下在模拟器上启用)会扫描字符串,如果收到无效输入,则会终止虚拟机。

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

    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 调用之前有效(这意味着,如果数据没有被复制,则数组对象将被固定,并且不能作为压缩堆的一部分而重新定位)。您必须 ReleaseGet 的每个数组。此外,如果 Get 调用失败,您必须确保您的代码稍后不会尝试 Release 一个 NULL 指针。

您可以通过为 isCopy 参数传递非 NULL 指针来确定数据是否被复制。这很少有用。

Release 调用接受一个 mode 参数,该参数可以具有三个值之一。运行时执行的操作取决于它是否返回指向实际数据的指针或其副本。

  • 0
    • 实际数据:取消固定数组对象。
    • 副本:数据被复制回。释放包含副本的缓冲区。
  • JNI_COMMIT
    • 实际数据:不执行任何操作。
    • 副本:数据被复制回。包含副本的缓冲区不会被释放
  • JNI_ABORT
    • 实际数据:取消固定数组对象。之前的写入不会被中止。
    • 副本:释放包含副本的缓冲区;对其进行的任何更改都将丢失。

检查 isCopy 标志的一个原因是了解是否需要在对数组进行更改后使用 JNI_COMMIT 调用 Release——如果您在进行更改和执行使用数组内容的代码之间交替进行,则您也许可以跳过无操作提交。检查该标志的另一个可能原因是有效处理 JNI_ABORT。例如,您可能希望获取一个数组,就地修改它,将其部分传递给其他函数,然后丢弃更改。如果您知道 JNI 正在为您创建一个新副本,则无需创建另一个“可编辑”副本。如果 JNI 传递给您的是原始数据,那么您确实需要创建您自己的副本。

一个常见的错误(在示例代码中重复出现)是假设如果 *isCopy 为假,则可以跳过 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 调用而不是 2 个,从而减少了开销。
  • 不需要固定或额外的复制数据。
  • 降低了程序员出错的风险——在某些操作失败后忘记调用 Release 的风险。

类似地,您可以使用 Set<Type>ArrayRegion 调用将数据复制到数组中,并使用 GetStringRegionGetStringUTFRegion 将字符从 String 中复制出来。

异常

在存在未处理异常的情况下,您不能调用大多数 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 转变边界抛出。)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 指针传递给具有非空参数的 JNI 调用。
  • 类名:将除“java/lang/String”样式的类名以外的任何内容传递给 JNI 调用。
  • 关键调用:“关键”获取与其相应的释放之间进行 JNI 调用。
  • 直接 ByteBuffers:将错误的参数传递给 NewDirectByteBuffer
  • 异常:在存在未处理异常的情况下进行 JNI 调用。
  • JNIEnv*s:使用来自错误线程的 JNIEnv*。
  • jfieldIDs:使用 NULL jfieldID,或使用 jfieldID 将字段设置为错误类型的数值(例如,尝试将 StringBuilder 分配给 String 字段),或使用 jfieldID 用于静态字段来设置实例字段反之亦然,或使用来自一个类的 jfieldID 和另一个类的实例。
  • jmethodIDs:在进行 Call*Method JNI 调用时使用错误类型的 jmethodID:返回值类型不正确、静态/非静态不匹配、“this”(对于非静态调用)的类型错误或类错误(对于静态调用)。
  • 引用:在错误类型的引用上使用 DeleteGlobalRef/DeleteLocalRef
  • 释放模式:将错误的释放模式传递给释放调用(除 0JNI_ABORTJNI_COMMIT 之外的任何内容)。
  • 类型安全:从本机方法返回不兼容的类型(例如,从声明为返回 String 的方法返回 StringBuilder)。
  • UTF-8:将无效的 Modified 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 构建工具会为某些构建类型自动执行此操作。

原生库

您可以使用标准 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+ 上受支持,使用 JNI RegisterNatives 的显式注册严格要求在 Android 8-11 版本上运行。这些注释在 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)之前,弱全局引用只能传递给 NewLocalRefNewGlobalRefDeleteWeakGlobalRef。(规范强烈鼓励程序员在对弱全局变量执行任何操作之前创建对它们的硬引用,因此这应该完全没有限制。)

    从 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,而该 jclass 恰好与作为隐式参数传递给您的静态原生方法的 jclass 相同,那么您将获得 JNILocalRefType 而不是 JNIGlobalRefType

  • @FastNative@CriticalNative

    在 Android 7 之前,这些优化注释被忽略。@CriticalNative 的 ABI 不匹配会导致参数编组错误并可能导致崩溃。

    Android 8-10 中未实现 @FastNative@CriticalNative 方法的原生函数动态查找,并且 Android 11 中包含已知的错误。在 Android 8-11 上使用这些优化而不使用 JNI RegisterNatives 进行显式注册可能会导致崩溃。

  • 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++函数。请注意,在冰淇淋三明治之前,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中,并且性能在将来的版本中应该会得到提高。