赞
踩
JNI 定义了 Android 使用 Java 或 Kotlin 编程语言编的代码编译的字节码与原生代码(使用 C/C++ 编写)互动的方式。JNI 是一套标准的协议,不受硬件限制,支持从动态共享库加载代码,在一些情况对比直接使用Java高效。我们可以使用 Android Studio 3.2 及更高版本的内存性能剖析器中的 JNI 堆视图来查看全局 JNI 引用,并查看这些引用创建和删除的位置。本文基于Android NDK官方文档中的提示信息结合自己开发过程的思考,从性能、可维护性、鲁棒性等角度总结JNI开发准则。
我们要知道一点,JNI调用是耗费资源的,所以我们应该按重要程度(从最重要的开始)尝试遵循以下准则:
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,我们可能都必须进行一些额外操作。)
所有线程都是 Linux 线程,由内核调度。线程通常从受管理代码启动(使用 Thread.start()
),但也可以在其他位置创建,然后附加到 JavaVM
。例如,通过 pthread_create()
或 std::thread
启动的线程可以使用 AttachCurrentThread()
或 AttachCurrentThreadAsDaemon()
函数附加到JavaVM。在附加之前,线程不包含任何 JNIEnv,也无法调用 JNI。
通常,我们尽量使用 Thread.start()
创建需要在Java中调用的任何线程, 这样做可以确保我们有足够的堆栈空间、属于正确的 ThreadGroup
且与我们的 Java 代码使用相同的 ClassLoader
。而且,在 Java 设置线程名称来调试也比通过原生代码更容易(如果我们用 pthread_t
或 thread_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 存储在线程本地存储中;这样一来,这个键将作为参数传递到我们的的析构函数中。)
如果要通过原生代码访问对象的字段,一般执行以下操作:
FindClass
获取类的类对象引用GetFieldID
获取字段的字段 IDGetIntField
同样,如果需要调用方法,首先要获取类对象引用,然后获取方法 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的查找。当初始化类时,这段代码会执行一次。如果要取消加载类之后再重新加载,这段代码将再次被执行。
传递给原生方法的每个参数,以及 JNI 函数返回的几乎每个对象都属于“局部引用”。局部引用只有在当前线程中的当前原生方法运行期间有效。**在原生方法返回后,即使对象本身继续存在,该引用也无效。**局部引用只有在当前方法内有效。
这适用于 jobject
的所有子类,包括 jclass
、jstring
和 jarray
。(启用扩展的 JNI 检查时,运行时会针对大部分引用误用问题发出警告。)
获取非局部引用的唯一方法是通过 NewGlobalRef
和 NewWeakGlobalRef
函数来创建全局引用。
如果我们希望长时间保留某个引用,则必须使用“全局”引用。NewGlobalRef
函数将局部引用作为参数,然后返回全局引用。在调用 DeleteGlobalRef
之前,全局引用保证有效。
这种模式通常在缓存 FindClass
返回的 jclass 时使用,例如:
jclass localClass = env->FindClass("MyClass");
jclass globalClass = reinterpret_cast<jclass>(env->NewGlobalRef(localClass));
所有 JNI 方法都接受局部引用和全局引用作为参数。对同一对象的引用可能具有不同的值。例如,对同一对象连续调用 NewGlobalRef
所返回的值可能有所不同。**如我们想知道两个引用是否引用同一对象,必须使用 IsSameObject
函数。**在原生代码中使用 ==
比较各个引用会有问题。
我们不能假设对象引用在原生代码中是常量或唯一值。在两次调用同一个方法时,某个对象的 32 位值可能有所不同;相应的&
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。