查看原文
其他

Android JNI 开发准则

轻口味 字节流动 2022-09-15

1. 背景

JNI 定义了 Android 使用 Java 或 Kotlin 编程语言编的代码编译的字节码与原生代码(使用 C/C++ 编写)互动的方式。

JNI 是一套标准的协议,不受硬件限制,支持从动态共享库加载代码,在一些情况对比直接使用Java高效。我们可以使用 Android Studio 3.2 及更高版本的内存性能剖析器中的 JNI 堆视图来查看全局 JNI 引用,并查看这些引用创建和删除的位置。

本文基于Android NDK官方文档中的提示信息结合自己开发过程的思考,从性能、可维护性、鲁棒性等角度总结JNI开发准则。

2. 一般准则

我们要知道一点,JNI调用是耗费资源的,所以我们应该按重要程度(从最重要的开始)尝试遵循以下准则:

  • 尽量减少跨 Java层与native调用次数。跨 JNI 调用会产生资源消耗,我们在进行接口设计时尽量要减少Java层与native交互频率。

  • 尽量避免在Java层编写的代码与C++ 层编写的代码之间进行异步通信。这样可使我们的JNI 接口更易于维护。一般情况,我们可以采用UI层的编程语言保持异步更新,以简化异步UI更新。比如使用 Java 语言在两个线程之间进行回调(其中一个线程发出阻塞 C++ 调用,然后在阻塞调用完成时通知界面线程),而不是通过 JNI 从使用 Java 代码的UI线程调用 C++ 函数。

  • 尽可能减少需要接触 JNI 或被 JNI 接触的线程数。如果我们确实需要使用 Java 和 C++ 这两种语言的线程池,要尽量保持在池所有者之间(而不是各个工作器线程之间)进行 JNI 通信。

  • 将接口代码保存在少量易于识别的 C++ 和 Java 源位置,以便将来进行重构。把JNI层代码尽可能放到一个文件里面,比如放在javah 自动生成的文件中。

3. JavaVM 和 JNIEnv

JNI 定义了两个关键数据结构,即“JavaVM”和“JNIEnv”。两者本质上都是指向函数表的二级指针。(在 C++ 版本中,它们是一些类,这些类具有指向函数表的指针,并具有通过该函数表间接调用的 JNI 函数的成员函数。)

JavaVM 提供“调用接口”函数,我们可以利用这些函数创建和销毁 JavaVM。理论上,每个进程可以有多个 JavaVM,但 Android 只允许有一个。

JNIEnv 提供了大部分 JNI 函数。我们的原生函数都会收到 JNIEnv 作为第一个参数。

该 JNIEnv 将用于线程本地存储。因此,我们无法在线程之间共享 JNIEnv。如果一段代码无法通过其他方法获取自己的 JNIEnv,我们可以共享相应 JavaVM,然后使用 GetEnv 来获取线程的 JNIEnv。(如果该线程没有包含 JNIEnv;则需要调用 AttachCurrentThread来关联。)

JNIEnv 和 JavaVM 的 C 声明与 C++ 声明不同。"jni.h" 头文件会提供不同的类型定义符,具体取决于该文件是包含在 C 还是 C++ 中。

因此,不建议包含两种语言的的头文件中添加 JNIEnv 参数。(换句话说:如果我们的的头文件需要 #ifdef __cplusplus,且在该头文件中任何位置引用了 JNIEnv,我们可能都必须进行一些额外操作。)

4. 线程

所有线程都是 Linux 线程,由内核调度。线程通常从受管理代码启动(使用 Thread.start()),但也可以在其他位置创建,然后附加到 JavaVM。例如,通过 pthread_create()std::thread 启动的线程可以使用 AttachCurrentThread()AttachCurrentThreadAsDaemon() 函数附加到JavaVM。在附加之前,线程不包含任何 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 不会挂起执行原生代码的线程。如果正在进行垃圾回收,或者调试程序已发出挂起请求,则在线程下次调用 JNI 时才会将其挂起。

通过 JNI 附加的线程在退出之前必须调用 DetachCurrentThread()。如果直接对此进行编码会很棘手,在 Android 2.0 (Eclair) 及更高版本中,您可以先使用 pthread_key_create() 定义将在线程退出之前调用的析构函数,在这里调用 DetachCurrentThread()。(将该键与 pthread_setspecific() 搭配使用,将 JNIEnv 存储在线程本地存储中;这样一来,这个键将作为参数传递到我们的的析构函数中。)

5. jclass、jmethodID 和 jfieldID

如果要通过原生代码访问对象的字段,一般执行以下操作:

  • 使用 FindClass 获取类的类对象引用

  • 使用 GetFieldID 获取字段的字段 ID

  • 使用适当函数获取字段的内容,例如 GetIntField

同样,如果需要调用方法,首先要获取类对象引用,然后获取方法 ID。方法 ID 通常只是指向内部运行时数据结构的指针。查找方法 ID 可能需要进行多次字符串比较,但当我们获取此类 方法ID后,就可以非常快速地获取字段或调用方法。

我们考虑性能的话,我们需要查找一次这些值并将结果缓存在原生代码中。由于每个进程只能包含一个 JavaVM,因此可以将这些数据存储在本地静态结构。

类引用、字段 ID 和方法 ID 在类卸载之前要保证有效。只有在与 ClassLoader 关联的所有类可以进行垃圾回收时,系统才会卸载类,这种情况很少见,但在 Android 中并非不可能。我们要注意,jclass 是类引用,必须通过调用 NewGlobalRef 来保护它,防止被释放。

如果您想在加载类时缓存方法 ID,并在取消加载类后并重新加载时自动重新缓存方法 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()
        }
    }

C/C++ 代码中创建 nativeClassInit 方法来执行ID的查找。当初始化类时,这段代码会执行一次。如果要取消加载类之后再重新加载,这段代码将再次被执行。

6. 局部引用和全局引用

传递给原生方法的每个参数,以及 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 函数。**在原生代码中使用 == 比较各个引用会有问题。

我们不能假设对象引用在原生代码中是常量或唯一值。在两次调用同一个方法时,某个对象的 32 位值可能有所不同;相应的,两个不同的对象可能具有相同的 32 位值。所以我们不能将 jobject 值用作键。

我们不能过度分配使用局部引用,这意味着如果我们需要要创建大量局部引用(也许是在运行对象数组时),应该在使用完成时使用 DeleteLocalRef 手动释放它们,而不是让 JNI在方法执行完成后自动帮我们回收。JNI默认实现仅有 16 个局部引用保留槽位,因此如果我们需要更多槽位,则应该删除已经无用的,或者使用 EnsureLocalCapacity/PushLocalFrame 修改配置扩展更多槽位。

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

如果使用 AttachCurrentThread 附加原生线程,那么在线程分离之前,我们运行的代码绝不会自动释放局部引用。我们创建的任何局部引用都必须手动删除。通常,在循环中创建局部引用的任何原生代码可能需要执行某些手动删除操作。之前就遇到了在一个线程中开启循环处理事件,局部引用没有手动释放,方法一直不结束,导致局部引用槽位不够引起的jni local reference table overflow crash。

全局引用我们要谨慎使用。虽然全局引用不可避免,但它们很难调试,并且可能会导致难以诊断的内存问题。在所有其他条件相同的情况下,全局引用越少,解决方案的效果可能越好。

7. UTF-8 和 UTF-16 字符串

Java 编程语言使用的是 UTF-16。为方便起见,JNI 还提供了使用修改后的 UTF-8 的方法。修改后的编码对 C 代码非常有用,因为它将 \u0000 编码为 0xc0 0x80,而不是 0x00。

这样做的好处是,我们可以依靠以零终止的 C 样式字符串,非常适合与标准 libc 字符串函数配合使用。但缺点是,我们无法将任意 UTF-8 数据传递给 JNI 并期望它能够正常工作。

如果可能,使用 UTF-16 字符串执行操作通常会更快。Android 目前不需要 GetStringChars 的副本,而 GetStringUTFChars 需要分配和转换为 UTF-8。但是UTF-16 字符串不是以零终止的,并且允许使用 \u0000,因此我们需要保留字符串长度和 jchar 指针。

要记得 Release 我们Get 的字符串。字符串函数会返回 jchar*jbyte*,它们是指向原始数据而非局部引用的 C 样式指针。这些指针在调用 Release 之前保证有效,所以导致原生方法返回时不会释放这些指针。

传递给 NewStringUTF 的数据必须采用修改后的 UTF-8 格式。一种常见的错误就是从文件或网络数据流中读取字符数据,并在未过滤的情况下将其传递给 NewStringUTF。除非我们确定数据是有效的 MUTF-8(或 7 位 ASCII,这是一个兼容子集),否则我们需要剔除无效字符或将它们相应转换为修改后的 UTF-8 格式。

如果不这样做,UTF-16 转换可能会产生意外的结果。CheckJNI 默认状态下为模拟器启用,它会扫描字符串并且在收到无效输入时会中止虚拟机。

8. 原始数组

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

9. 区域调用

如果我们只是想复制数据,使用替代方法进行 Get<Type>ArrayElementsGetStringChars 等调用可能非常有方便。参考下面示例代码:

    jbyte* data = env->GetByteArrayElements(arrayNULL);
        if (data != NULL) {
            memcpy(buffer, data, len);
            env->ReleaseByteArrayElements(array, data, JNI_ABORT);
        }

这里会从array总获取前len个字节拷贝到buffer中,然后释放数组。Get 调用会复制数组内容,具体取决于实现情况。代码复制数据(可能是第二次),然后调用 Release;在这种情况下,JNI_ABORT 确保不会有机会进行第三次复制。

我们还可以用更简单的方式完成相同操作:

    env->GetByteArrayRegion(array0, len, buffer);

这种做法具有诸多优势:

  • 需要一个 JNI 调用而不是两个,从而减少开销。

  • 不需要固定或额外复制数据。

  • 降低程序员出错风险,因为不存在操作失败后忘记调用 Release 的风险。

同样,我们可以使用 Set<Type>ArrayRegion 调用将数据复制到数组中,使用 GetStringRegionGetStringUTFRegion 将字符复制到 String 中。

10. 异常

**在放生异常时,不得调用大多数 JNI 函数。**我们的代码应该要处理异常情况(通过函数的返回值 ExceptionCheckExceptionOccurred)并返回,或者清除异常后继续执行。

在异常挂起时,我们只能调用以下 JNI 函数:

  • DeleteGlobalRef

  • DeleteLocalRef

  • DeleteWeakGlobalRef

  • ExceptionCheck

  • ExceptionClear

  • ExceptionDescribe

  • ExceptionOccurred

  • MonitorExit

  • PopLocalFrame

  • PushLocalFrame

  • ReleaseArrayElements

  • ReleasePrimitiveArrayCritical

  • ReleaseStringChars

  • ReleaseStringCritical

  • ReleaseStringUTFChars

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

处理代码抛出的异常不会展开原生堆栈帧,并且 Android 还不支持 C++ 异常。JNI ThrowThrowNew 指令只是在当前线程中设置了异常指针。从原生代码返回到受管理代码后,这些指令会注意到异常并进行相应处理。

原生代码可以通过调用 ExceptionCheckExceptionOccurred 来“捕获”异常,然后使用 ExceptionClear 进行清除,在未经处理的情况下舍弃异常可能会出现问题。

因为没有可用于操控 Throwable 对象本身的内置函数,所以如果我们想要获取异常字符串,则需要找到 Throwable 类、查找 getMessage "()Ljava/lang/String;" 的方法 ID 并调用该方法;如果结果为非 NULL 值,则使用 GetStringUTFChars 获取可以传递给 printf(3) 或等效函数的内容。

11. 扩展的检查

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 构建工具会自动为某些构建类型执行此操作。

12.原生库

我们可以使用官方标准 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。这将生成速度更快且规模更小的代码,并避免与加载到应用中的其他库发生潜在冲突(但如果应用在原生代码中崩溃,则创建的堆栈轨迹用处不大)。

静态初始化程序应如下所示:

    companion object {
        init {
            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 == nullptrreturn 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;
    }

如需改为使用“discovery”原生方法,我们需要以特定方式为其命名(详情请参阅 JNI 规范)。这意味着,如果方法签名是错误的,我们要等到第一次实际调用该方法时才会知道。

JNI_OnLoad 进行的任何 FindClass 调用都会在用于加载共享库的类加载器的上下文中解析类。

从其他上下文调用时,FindClass 会使用与 Java 堆栈顶部的方法相关联的类加载器,如果没有(因为调用来自刚刚附加的原生线程),则会使用“系统”类加载器。由于系统类加载器不知道应用的类,因此我们将无法在该上下文中使用 FindClass 查找我们自己的类。

这使得 JNI_OnLoad 成为查找和缓存类的便捷位置:一旦有了有效的 jclass,我们就可以从任何附加的线程使用它。

12. 64 位注意事项

为了支持使用 64 位指针的架构,在 Java 字段中存储指向原生结构的指针时,要使用 long 字段而不是 int

原文链接: https://juejin.cn/post/7083481029347901477


推荐阅读:

NDK | 带你梳理 JNI 函数注册的方式和时机

Android NDK 开发:JNI 基础篇

Android NDK 开发:Java 与 Native 相互调用

Android NDK POSIX 多线程编程

NDK 开发中 Native 方法的静态注册与动态注册

Android NDK 开发中快速定位 Crash 问题

Android JNI 中发送 Http 网络请求

Android  NDK 减少 so 库体积方法总结

Android 引用三方库导致 so 库冲突的解决办法

Android JNI原理分析

Android JNI 动态库逆向
​​

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存