前言
JNI 的全称是:Java Native Interface,即连接 Java 虚拟机和本地代码的接口,它允许 Java 和本地代码之间互相调用,在 Android 平台,此处的本地代码是指使用 C/C++ 或汇编语言编写的代码,编译后将以动态链接库(.so)的形式供 Java 虚拟机加载,并按 JNI 规范互相调用。如果工作中需要大量运用 JNI,强烈建议通读 《JNI官方规范》,并结合 Google 的《JNI Tips》 一节以了解在 Android 平台的 JNI 实现有什么限制和不同。
如果只是想快速上手,同时规避一些常见问题,可以先阅读本文——本文的定位是操作手册,告知新手怎样做及为什么,并提供一些最佳实践建议。
1 从 Java 调用 Native
1.1 通过 javah 生成头文件:
1.1.1 Java 层实现
public class HelloJNI {
static {
System.loadLibrary("hello"); // Load native library at runtime
}
// Declare a native method sayHello() that receives nothing and returns void
public native void sayHello();
}
1.1.2 Native 实现
HelloJNI.h
#include <jni.h>
/* Header for class HelloJNI */
#ifndef _Included_HelloJNI
#define _Included_HelloJNI
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: HelloJNI
* Method: sayHello
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_HelloJNI_sayHello(JNIEnv *, jobject);
#ifdef __cplusplus
}
#endif
#endif
HelloJNI.c
#include <jni.h>
#include <stdio.h>
#include "HelloJNI.h"
// Implementation of native method sayHello() of HelloJNI class
JNIEXPORT void JNICALL Java_HelloJNI_sayHello(JNIEnv *env, jobject thisObj) {
printf("Hello World!\n");
}
Tips: jni.h 里会使用 #if defined(__cplusplus)
来为 JNIEnv 提供不同的 typedef,尽量不要同时在 C 和 C++ 两种语言包含的头文件里都引用 JNIEnv,避免在两种语言间传递 JNIEnv 导致类型不兼容。
1.2 注册 JNI 函数表
1.2.1 Java 层实现(略)
1.2.2 Native 实现
HellocJNI.c
#include <jni.h>
// Package name of Java class
static const char *const PACKAGE_NAME = "java/HelloJNI";
void JNICALL nativeSayHello(JNIEnv*, jobject) {
printf("Hello World!\n");
}
// Native method table
static JNINativeMethod methods[] = {
/* {"Method name", "Signature", FunctionPointer}, */
{ "sayHello", "()V", (void*)nativeSayHello },
};
jint registerNativeMethods(JNIEnv* env, const char *class_name, JNINativeMethod *methods, int num_methods) {
jclass clazz = env->FindClass(class_name);
if (NULL != clazz) {
return env->RegisterNatives(clazz, methods, num_methods);
}
return JNI_ERR;
}
// Invoked when System.loadLibrary()
jint JNI_OnLoad(JavaVM *vm, void *) {
JNIEnv *env;
if (JNI_OK != vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6)) {
return JNI_ERR;
}
if (JNI_OK != registerNativeMethods(env, PACKAGE_NAME, methods, 1)) {
return JNI_ERR;
}
return JNI_VERSION_1_6;
}
相比起第一种方式方法名以包名为前缀的做法,上面源码中的 PACKAGE_NAME 可以很容易修改,更加灵活通用,推荐使用。
Tips: 注册 Native 方法的合适时机是上面代码里的 jint JNI_OnLoad(JavaVM* vm, void* reserved)
函数,它会在 Java 层 System.loadLibrary()
加载动态链接库之后被首先调用,适用于执行初始化逻辑。
2 Native 调用 Java
2.1 持有 JNIEnv 指针
从 Native 层调用 Java 方法,前提是 Native 持有 JNIEnv 指针,通过类似以下代码即可调用 Java 方法:
jstring getPackageName(JNIEnv* env, jobject contextObject) {
if (NULL != env && NULL != contextObject) {
jclass contextClazz = env->FindClass("android/content/Context");
jmethodID methodId = env->GetMethodID(contextClazz, "getPackageName", "()Ljava/lang/String;");
return (jstring) env->CallObjectMethod(contextObject, methodId);
}
return NULL;
}
Tips: GetMethodID/GetFieldID/CallXXXMethod 等方法均不接受 NULL 参数,否则程序会异常退出,在获取非文档化的类或成员后一定要先对返回值进行判空再使用。
2.2 没有 JNIEnv 指针
JNIEnv 实例保存在线程本地存储 TLS(Thread-Local Storage)中,因此不能在线程间直接共享 JNIEnv 指针。如果线程的 TLS 里存有 JNIEnv 实例,只是没有引用该实例的指针,可以通过 JavaVM 指针调用 GetEnv() 来获取指向线程自有 JNIEnv 的指针。因为 Android 下的 JavaVM 实例是全进程唯一的,所以可以被所有线程共享。
还有一种更特殊的情况:即线程根本没有 JNIEnv 实例(如代码中通过 pthread_create() 创建的原生线程),这种情况下需要先调用 JavaVM->AttachCurrentThread()
将线程依附于 JavaVM 以获得 JNIEnv 实例(Attach 到 VM 后就被视为 Java 线程)。当线程退出时要配对调用 JavaVM->DetachCurrentThread()
以释放 JVM 里的资源。
Tips: 为避免 DetachCurrentThread 未配对调用,可以通过 int pthread_key_create(pthread_key_t *key, void (*destructor)(void*));
创建一个 TLS 数据的 key,并注册一个 destructor 回调函数,它会在线程退出前被调用,因此很适合用于执行类似 DetachCurrentThread 的清理工作。另外还可以使用 key 调用 pthread_setspecific 函数,将 JNIEnv 指针保存到 TLS 中,这样一来不仅可随用随取,而且当 destructor 函数被调用时 JNIEnv 指针会作为参数传入,方便调用 Java 层的一些清理方法。部分示例如下:
JavaVM* gVM; // Global VM reference
pthread_key_t gKey; // Global TLS data key
void onThreadExit(void* tlsData) {
JNIEnv* env = (JNIEnv*)tlsData;
// Do some JNI calls with env if needed ...
gVM->DetachCurrentThread();
}
// Invoked when System.loadLibrary()
jint JNI_OnLoad(JavaVM *vm, void *) {
// ignore some initialize code ...
gVM = vm;
// Create thread-specific data key and register thread-exit callback
pthread_key_create(&gKey, onThreadExit);
return JNI_VERSION_1_6;
}
JNIEnv* getJNIEnv(JavaVM* vm) {
JNIEnv *env = (JNIEnv *) pthread_getspecific(gKey); // gKey created by pthread_key_create() before
if (NULL == env) {
if (JNI_OK != vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6)) {
if (JNI_OK == vm->AttachCurrentThread(&env, NULL)) {
pthread_setspecific(gKey, env); // Save JNIEnv* to TLS with gKey
}
}
}
return env;
}
3 对象引用
3.1 本地引用
每个传给 Native 方法的参数(对象),和几乎所有 JNI 函数返回的对象都是本地引用(Local reference)。这意味着它们只在当前线程的当前 native 方法内有效,一旦该方法返回则失效(哪怕被引用的对象仍然存在)。所以正常情况下开发者无须手动调用 DeleteLocalRef 释放,除非以下几种情况:
- Native 方法内创建大量的本地引用,例如在循环中反复创建,因为虚拟机保存本地引用的空间是有限的(Android 为512个),一旦循环中创建的引用数超出限制就会导致异常:ReferenceTable overflow (max=512);
- 通过 AttachCurrentThread() 依附到 JVM 的线程内的所有本地引用均不会被自动释放,直到调用 DetachCurrentThread() 才会统一释放,为避免线程中创建太多本地引用建议及时做手动释放;
- Native 方法本地引用了一个非常大的对象,用完后还要进行较长时间的其它运算才能返回,本地引用会阻止该对象被 GC。为降低 OutOfMemory(OOM) 风险用完后应该及时手动释放。
上面所说的对象是指 jobject 及其子类,包括 jclass, jstring, jarray,不包括 GetStringUTFChars 和 GetByteArrayElements 这类函数的返回值(皆返回原始数据指针),也不包括 jmethodID 和 jfieldID,这两者在 Android 下只要类加载之后就一直有效。
Tips: GetStringUTFChars
/ Get<PrimitiveType>ArrayElements
等函数返回的原始数据指针可以跨线程使用,并且必须手动调用对应的 ReleaseStringUTFChars
/ Release<PrimitiveType>ArrayElements
函数释放,否则会造成内存泄漏。
3.2 全局引用
与本地引用不同,全局引用可以跨方法跨线程使用,通过 NewGlobalRef 或 NewWeakGlobalRef 方法创建之后,会一直有效直到调用 DeleteGlobalRef/DeleteWeakGlobalRef 销毁。这个特性常用于缓存一些获取起来较耗时的对象,比如 jclass 通过 FindClass 获取时有反射的开销,对于同一个类而言获取一次缓存起来备用会更高效:
jclass localClass = env->FindClass("MyClass");
jclass globalClass = reinterpret_cast<jclass>(env->NewGlobalRef(localClass));
Tips: 如果想在一个在加载时将 Native jclass、jmethodID、jfieldID 缓存起来备用,可以像下面代码一样在 Java 层的静态域内调用 nativeInit 方法,该方法的 Native 层实现可以通过 FindClass、GetFieldID、GetMethodID 等方法把所有后续要使用的类对象和成员都缓存起来,避免每次使用前都查找带来的性能开销。
/*
* 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.
*/
private static native void nativeInit();
static {
nativeInit();
}
3.3 引用比较
比较两个引用是否指向同个对象需要使用 jboolean IsSameObject(JNIEnv *env, jobject ref1, jobject ref2);
方法。要注意的是 JNI 中的 NULL 指向 JVM 中的 null 对象,IsSameObject 用于弱全局引用(WeakGlobalRef)与 NULL 比较时,返回值的意义表示其引用的对象是否已经回收(JNI_TRUE 代表已回收,该弱引用已无效)。
4 线程安全
由于 Android 下的 JVM 线程底层基于 POSIX Threads,因此有两种使用对象同步(synchronized)的方式:基于 Java 的同步和基于 POSIX 的同步:
4.1 基于 Java 的同步
A. JNI 提供了类似 synchronized 语句的同步块函数:
B. 也可以直接在 Java 层用 synchronized 关键词修饰 native 方法:
public native synchronized void sayHello();
这种用法可以确保 Java 对 sayHello() 的调用是同步的,但通常不建议这么用,因为可能带来以下问题:
- 对整个 Native 方法做同步的粒度较大,可能影响性能;
- Native 和 Java 的方法声明在不同的位置,可能出现方法声明更改(如 synchronized 关键词被删除),会导致方法不再线程安全;
- 如果该 sayHello() 在 Java 之外被其它 Native 函数调用,则不是线程安全的。
Tips: 在上述同步方案中 Object.wait()/notify()/notifyAll() 等方法同样可以使用,只需从 Native 层调用对象对应的 Java 方法即可。
4.2 基于 POSIX 的同步
无论是通过 pthread 或者 Java 创建的线程,均可使用 pthread 提供的线程控制函数来实现 Native 层的同步,如: pthread_mutex_lock/pthread_mutex_unlock/pthread_cond_wait/pthread_cond_signal
等等。
5 字符编码
Java 内部是使用 UTF-16 处理字符,但 JNI 对外提供了一套函数用于将 UTF-16 转换为 UTF-8 的一个变种 Modified UTF-8(以 0xc0 0x80 而不是 0x00 来编码 \u0000),使用这个变种的好处是能兼容以 0x00 作为结束符的 C 字符处理函数,缺点是与标准或其它 UTF-8 变种之间有细微的差异,存在潜在的兼容性问题。所以在从网络或文件读入文本后,必须确认或处理为符合 Modified UTF-8 编码才能传给 NewStringUTF 方法,否则可能无法得到预期的结果。
6 数组访问
6.1 随机访问数组
对于对象数组(Array of objects) JNI 提供了 GetObjectArrayElement/SetObjectArrayElement
函数允许每次访问数组中的一个对象。而对于原始类型的 Java 数组则提供了映射为 C 数组的 NativeType *Get<PrimitiveType>ArrayElements(JNIEnv *env, ArrayType array, jboolean *isCopy)
函数族 ,让我们可以像访问 C 数组那样读写 Java 数组的内容,该函数族的完整列表如下:
PrimitiveType | ArrayType | NativeType |
---|---|---|
GetBooleanArrayElements() | jbooleanArray | jboolean |
GetByteArrayElements() | jbyteArray | jbyte |
GetCharArrayElements() | jcharArray | jchar |
GetShortArrayElements() | jshortArray | jshort |
GetIntArrayElements() | jintArray | jint |
GetLongArrayElements() | jlongArray | jlong |
GetFloatArrayElements() | jfloatArray | jfloat |
GetDoubleArrayElements() | jdoubleArray | jdouble |
如果调用成功 Get<PrimitiveType>ArrayElements
函数族会返回指向 Java 数组的堆地址或新申请的副本的指针(视 JVM 的具体实现,在 ART 里数组的堆空间若可被移动则返回副本,可以传递非 NULL 的 isCopy 指针来确认返回值是否副本),如果指针指向是 Java 数组的堆地址而非副本,在 Release<PrimitiveType>ArrayElements
之前此 Java 数组都无法被 GC 回收,所以 Get<PrimitiveType>ArrayElements
和 Release<PrimitiveType>ArrayElements
必须配对调用以避免内存泄漏。另外 Get<PrimitiveType>ArrayElements
可能因内存不足创建副本失败而返回 NULL,应先对返回值判空后再使用。
Release<PrimitiveType>ArrayElements
原型如下:
void Release<PrimitiveType>ArrayElements(JNIEnv *env, ArrayType array, NativeType *elems, jint mode);
它最后一个参数 mode 仅对 elems 为副本时有效,它可以用于避免一些非必要的副本拷贝,共有以下三种取值:
- 0:将 elems 内容回写到 Java 数组并释放 elems 占用的空间;
- JNI_COMMIT:将 elems 内容回写到 Java 数组,但不释放 elems 的空间;
- JNI_ABORT:不回写 elems 内容到 Java 数组,释放 elems 的空间。
一般来说 mode 参数直接传 0 是最安全的选择,这样不论 Get<PrimitiveType>ArrayElements
返回的是否副本都不会发生泄漏。但也有一些情况为了性能等因素考虑会使用非零值,比方说对于一个尺寸很大的数组,如果获取指针之后通过 isCopy 确认是副本,且之后没有修改过内容,那么完全可以使用 JNI_ABORT 避免回写以提高性能。
另一种可能的情况是 Native 修改数组和 Java 读取数组在交替进行(如多线程环境),如果通过 isCopy 确认获取的数组是副本,可以通过 JNI_COMMIT 调用 Release<PrimitiveType>ArrayElements
来提交修改,由于 JNI_COMMIT 不会释放副本,所以最终还需要使用别的 mode 值再调用 Release 以避免副本泄漏。
Tips: 一种常见的错误用法是当 isCopy 为 false 时跳过使用 Release,此时虽未创建副本,但 Java 数组的堆内存被引用后会阻止 GC 回收,因此也必须配对调用 Release 函数。
6.2 块拷贝
上一节讲解了如何访问 Java 数组,考虑一下这种场景:Native 层需要从/往 Java 数组拷贝一块内容,根据上面的内容很容易写出以下代码:
jbyte* data = env->GetByteArrayElements(javaArray, NULL);
if (data != NULL) {
memcpy(buffer, data, len);
env->ReleaseByteArrayElements(javaArray, data, JNI_ABORT);
}
先获取指向 Java 数组堆内存(或者副本)的指针,将头 len 个字节拷贝到 buffer 后调用 Release 释放。由于没有改变数组内容,因此使用 JNI_ABORT 避免回写开销。
但其实有更简单的做法,就是调用块拷贝函数:
env->GetByteArrayRegion(javaArray, 0, len, buffer);
相比前一种方式,块拷贝有以下优点:
- 只需要一次 JNI 调用,减少开销;
- 无需创建副本或引用 Java 数组的内存(不影响 GC)
- 降低编程出错的风险——不会因漏调用 Release 函数而引起泄漏。
对于字符串也有类似的拷贝函数,下面是原型:
// Region copy for Array.
// Throw ArrayIndexOutOfBoundsException if one of the indexes in the region is not valid
void Get<PrimitiveType>ArrayRegion(JNIEnv *env, ArrayType array, jsize start, jsize len, NativeType *buf);
void Set<PrimitiveType>ArrayRegion(JNIEnv *env, ArrayType array, jsize start, jsize len, const NativeType *buf);
// Region copy for String.
// Throws StringIndexOutOfBoundsException on index overflow
void GetStringRegion(JNIEnv *env, jstring str, jsize start, jsize len, jchar *buf);
void GetStringUTFRegion(JNIEnv *env, jstring str, jsize start, jsize len, char *buf);
前两个函数族的 PrimitiveType、ArrayType、NativeType 之定义请参考上一节的表格。
6.3 性能敏感场景
上面两种数组访问方式都会涉及到拷贝(Get<PrimitiveType>ArrayElements
虽不一定创建副本,但开发者无法控制),在性能敏感的场景下拷贝带来的耗时往往不可接受,因此需要一种无拷贝的方式来访问数组。在 Android 下可以使用以下两种方式:
6.3.1 临界访问
void* GetPrimitiveArrayCritical(JNIEnv *env, jarray array, jboolean *isCopy);
void ReleasePrimitiveArrayCritical(JNIEnv *env, jarray array, void *carray, jint mode);
如上所示,JNI 提供了数组临界访问函数,虽然从参数上仍保留了 iSCopy 和 mode,但使用这对函数时有非常严格的限制:Get 和 Release 之间被视为临界区,临界区里的代码应该尽快执行完,而且不允许调用其它 JNI 函数,以及任何可能导致当前线程阻塞并等待另一个 Java 线程的系统调用(比如当前线程不能调用 read 函数读取另一个 Java 线程正在写的流)。
这些严格的限制实际是为了便于 VM 直接返回数组堆内存的指针,比如采用 Moving GC 的 VM 可以在临界区内暂停 GC 来保证 Get 返回的数组地址不会改变。
6.3.2 Direct ByteBuffer
上一种方式虽然可以应付性能敏感的场景但限制颇多。JNI 还提供了 Direct ByteBuffer 方案,可以通过 java.nio.ByteBuffer.allocateDirect
方法或 JNI 函数 NewDirectByteBuffer
创建,它和普通的 ByteBuffer 差异在于其内部使用的内存不是在 Java 堆上分配的,而可以通过 GetDirectBufferAddress
函数获取地址后直接在 Native 代码访问,从 Java 层访问可能会比较慢。
以上两种方式的选择取决于以下因素:
- 数据主要是在 Java 还是 C/C++ 代码访问?
如果主要是在 C/C++ 里访问首选 DirectByteBuffer,速度快限制少。 - 如果数据最终要被传回 Java API,是作为什么类型的参数传递的?
如果 Java API 需要一个 byte[] 参数,那么就不要使用 DirectByteBuffer(调用其byte[] array ()
方法会抛 UnsupportedOperationException 异常)。 - 如果上两种方式都可以使用且没有明显的优劣,建议优先选用 DirectByteBuffer,没有临界区的限制代码扩展性更好,且随着 JVM 实现的优化,从 Java 层访问的性能也会得到提升。
7 异常处理
部分 JNI 调用可能会抛出异常,当异常发生后 Native 代码仍可继续执行,但此时绝大多数 JNI 函数都不能被调用(调用即Crash),直到异常被 Native 或 Java 层处理。一般在 Native 调用可能产生异常的 Java 方法都应该进行异常检查和处理,避免程序非正常退出。一个常见的异常处理逻辑如下:
// ...
env->CallVoidMethod(clazz, methodName, params); // Call a Java method which may throws exception
if (env->ExceptionCheck()) { // If exception occurred, ExceptionCheck() return JNI_TRUE
if (Native can handle exception) {
// handle it
// ...
// Clear the exception, so program can continue
env->ExceptionClear();
} else {
// Native can't handle exception, return and let Java code do that
return ;
}
}
// If not clear exception in line 8, then program will crash when it calls next JNI function:
env->NewStringUTF("WhatEver");
Tips: 只有以下 JNI 函数可以在异常未处理时调用而不会导致 Crash:
- DeleteGlobalRef
- DeleteLocalRef
- DeleteWeakGlobalRef
- ExceptionCheck
- ExceptionClear
- ExceptionDescribe
- ExceptionOccurred
- MonitorExit
- PopLocalFrame
- PushLocalFrame
- Release<PrimitiveType>ArrayElements
- ReleasePrimitiveArrayCritical
- ReleaseStringChars
- ReleaseStringCritical
- ReleaseStringUTFChars
8 扩展检查
JNI 几乎没有错误检查,出错通常都会导致崩溃。Android 额外提供了一种名为 CheckJNI 的模式,该模式下会将 JavaVM 和 JNIEnv 的函数表指针重定向到带检查能力的函数表,该表里函数会先执行扩展检查再调用实际的 JNI 函数。
扩展检查项包括:
- 数组:尝试分配一个负数长度的数组;
- 错误的指针:将错误的jarray / jclass / jobject / jstring传递给JNI调用,或者将NULL指针传递给具有不可空参数的JNI调用;
- 类名称:将错误样式的类名传递给JNI调用;
- 临界调用:在临界区中进行 JNI 调用;
- Direct ByteBuffers:将错误的参数传递给NewDirectByteBuffer;
- 异常:在有待处理异常时进行 JNI 调用;
- JNIEnv指针:跨线程使用 JNIEnv;
- jfieldIDs:使用 NULL jfieldID 或使用 jfieldID 设置值时类型不正确,或使用 jfieldID 设置未持有该 jfieldID 的类成员等;
- jmethodIDs:同 jfieldIDs;
- 引用:在错误的引用类型上调用 DeleteGlobalRef/DeleteLocalRef;
- Release modes:调用 Release 时传入错误的 mode 参数(例如传入除 0,JNI_ABORT,JNI_COMMIT 之外的值);
- 类型安全:Native 方法返回一个与声明不兼容的类型;
- UTF-8:将一个非法的 Modified UTF-8 字符串传给 JNI 调用。
以下方式可以打开扩展检查能力:
- 如果使用模拟器,则默认开启了全局 CheckJNI;
- 如果编译的是Debug版本的App,也默认开启了;
- root过的手机可以用以下命令开启:
adb shell stop
adb shell setprop dalvik.vm.checkjni true
adb shell start
- 未 root 的可以用:
adb shell setprop debug.checkjni 1
通过以下 Logcat 内容可以确认是否开启成功:
D AndroidRuntime: CheckJNI is ON