JNI 提示

JNI 是 Java Native Interface。它定义了一种方法,用于让 Android 从托管代码(用 Java 或 Kotlin 编程语言编写)编译的字节码与原生代码(用 C/C++ 编写)进行交互。JNI 是供应商中立的,支持从动态共享库加载代码,虽然有时很麻烦,但效率却相当高。

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

如果您还不熟悉 JNI,请阅读 Java Native Interface 规范,了解 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 或被 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++ 中。因此,将 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函数。**不要在本机代码中使用==比较引用。

这带来的一个结果是,您**不能假设对象引用在本机代码中是恒定或唯一的**。表示对象的 value 可能在方法的一次调用到下次调用之间不同,并且可能有两个不同的对象在连续调用中具有相同的 value。不要使用jobject value 作为键。

程序员需要“不要过度分配”本地引用。实际上,这意味着如果您正在创建大量本地引用,也许是在遍历对象数组时,您应该使用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(默认情况下在模拟器上启用)会扫描字符串,并在收到无效输入时中止 VM。

在 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 中声明一样直接读取和写入原始数组。

为了在不限制 VM 实现的情况下尽可能高效地使用接口,Get<PrimitiveType>ArrayElements 函数族允许运行时要么返回指向实际元素的指针,要么分配一些内存并进行复制。无论哪种方式,返回的原始指针都保证在发出相应的 Release 调用之前有效(这意味着,如果数据没有被复制,数组对象将被固定,并且不能作为压缩堆的一部分重新分配)。您必须对每个您 Get 的数组进行 Release 操作。 此外,如果 Get 调用失败,您必须确保您的代码不会尝试在稍后对 NULL 指针进行 Release 操作。

您可以通过为 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 这样的函数),则必须始终检查异常,因为如果抛出异常,返回值将无效。

请注意,由托管代码抛出的异常不会反解本地堆栈帧。(并且 C++ 异常(通常在 Android 上不建议使用)不能跨 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 指针传递给具有非空可为空参数的 JNI 调用。
  • 类名:将除“java/lang/String”样式的类名以外的任何内容传递给 JNI 调用。
  • 关键调用:在“关键”获取与其对应的释放之间进行 JNI 调用。
  • 直接字节缓冲区:将错误的参数传递给 NewDirectByteBuffer
  • 异常:在出现异常时进行 JNI 调用。
  • JNIEnv*s:从错误的线程使用 JNIEnv*。
  • jfieldID:使用 NULL jfieldID,或使用 jfieldID 将字段设置为错误类型的(尝试将 StringBuilder 赋值给 String 字段,比如),或使用 jfieldID 为静态字段设置实例字段,反之亦然,或使用一个类的 jfieldID 与另一个类的实例一起使用。
  • jmethodID:在进行 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 中存在错误,导致本地库的安装和更新不可靠。 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 的全局 jclass 调用 GetObjectRefType,你将获得 JNILocalRefType 而不是 JNIGlobalRefType

  • @FastNative@CriticalNative

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

    Android 8-10 中没有为 @FastNative@CriticalNative 方法实现原生函数的动态查找,并且 Android 11 中存在已知错误。在没有使用 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 中,并且性能将在未来的版本中得到提高。