当前位置:   article > 正文

Android C++系列:JNI开发准则

Android C++系列:JNI开发准则

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()
        }
    }
    
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

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));
  • 1
  • 2

所有 JNI 方法都接受局部引用和全局引用作为参数。对同一对象的引用可能具有不同的值。例如,对同一对象连续调用 NewGlobalRef 所返回的值可能有所不同。**如我们想知道两个引用是否引用同一对象,必须使用 IsSameObject 函数。**在原生代码中使用 == 比较各个引用会有问题。

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

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/秋刀鱼在做梦/article/detail/797924
推荐阅读
相关标签
  

闽ICP备14008679号