Android的JNI开发全面介绍与最佳实践
JNI全称是Java Native Interface,为Java本地接口,是连接Java层与Native层的桥梁。在Android进行JNI开发时,可能会遇到couldn't find "xxx.so"问题,或者内存泄漏问题,或者令人头疼的JNI底层崩溃问题。Java层如何调用Native方法?Java方法的参数如何传递给Native层?而Native层又如何反射调用Java方法?这些问题在本文
JNI全称是Java Native Interface,为Java本地接口,是连接Java层与Native层的桥梁。在Android进行JNI开发时,可能会遇到couldn't find "xxx.so"问题,或者内存泄漏问题,或者令人头疼的JNI底层崩溃问题。Java层如何调用Native方法?Java方法的参数如何传递给Native层?而Native层又如何反射调用Java方法?这些问题在本文将得到答案,带着问题去阅读会事半功倍,接下来我们开始全方位介绍与最佳代码实践。
关于ndk编译脚本:Android.mk or CMake
关于JNI开发规范:JNI开发的最佳Tips
目录
一、JNI整体设计
1、库的加载
在Android提供System.loadLibrary()或者System.load()来加载库。示例如下:
static {
try {
System.loadLibrary("hello");
} catch (UnsatisfiedLinkError error) {
Log.e(TAG, "load library error=" + error.getMessage());
}
}
需要注意的是,如果.so动态库或.a静态库不存在时,会抛出couldn't find "libxxx.so"异常:
load library error=dalvik.system.PathClassLoader[DexPathList[[
zip file "/data/app/com.frank.ffmpeg/base.apk"],
nativeLibraryDirectories=[/data/app/com.frank.ffmpeg/lib/arm64,
data/app/com.frank.ffmpeg/base.apk!/lib/arm64-v8a,/system/lib64, /vendor/lib64, /product/lib64]]]
couldn't find "libhello.so"
如果期待加载的是64bit的库,却加载到32bit的,会报错如下:
java.lang.UnsatisfiedLinkError: dlopen failed: "xxx.so" is 32-bit instead of 64-bit
System.loadLibrary()内部调用Runtime.getRuntime().loadLibrary0(),源码如下:
synchronized void loadLibrary0(ClassLoader loader, String libraryName) {
if (loader != null) {
// 1、调用classLoader查找库
String filename = loader.findLibrary(libraryName);
if (filename == null) {
throw new UnsatisfiedLinkError(loader + " couldn't find \"" +
System.mapLibraryName(libraryName) + "\"");
}
// 2、调用native方法来加载
String error = nativeLoad(filename, loader);
if (error != null) {
throw new UnsatisfiedLinkError(error);
}
return;
}
// 3、拼接完整库名,比如由hello拼接成libhello.so
String filename = System.mapLibraryName(libraryName);
List<String> candidates = new ArrayList<String>();
String lastError = null;
for (String directory : getLibPaths()) {
String candidate = directory + filename;
candidates.add(candidate);
if (IoUtils.canOpenReadOnly(candidate)) {
// 4、调用native方法来加载
String error = nativeLoad(candidate, loader);
if (error == null) {
return; // 加载library成功
}
lastError = error;
}
}
if (lastError != null) {
throw new UnsatisfiedLinkError(lastError);
}
throw new UnsatisfiedLinkError("Library " + libraryName + " not found; tried " + candidates);
}
这里的nativeLoad()属于runtime底层的jni方法,接着调用art/runtime/java_vm_ext.cc的load_NativeLibrary(),最终调用dlopen()来打开so库或a库。 调用过程如下图:
2、动态注册与静态注册
java层调用带native关键字的JNI方法,需要注册java层与native层的对应关系,有静态注册和动态注册两种方式。静态注册一般是应用层使用,绑定包名+类名+方法名,在调用JNI方法时,通过类加载器查找对应的函数。动态注册一般是framework层使用,在JNI_OnLoad()回调时,把JNINativeMethod注册到函数表。静态注册的缺点是包名、类名或方法名发生修改时,native层的jni方法名也得对应修改。
以java层声明的函数名为hello的JNI方法为例:
private native void hello(int num);
静态注册的示例(如果是c++文件(.cpp/.cc/.cxx),需要加extern "C"关键字):
#ifdef __cplusplus
extern "C" {
#endif
JNIEXPORT void JNICALL Java_com_frank_ffmpeg_FFmpegCmd_hello(JNIEnv *env, jclass thiz, jint num) {
}
#ifdef __cplusplus
}
#endif
如果觉得每个JNI方法都这样写比较麻烦,我们可以写个宏定义:
#define FFMPEG_FUNC(RETURN_TYPE, FUNC_NAME, ...) \
JNIEXPORT RETURN_TYPE JNICALL Java_com_frank_ffmpeg_FFmpegCmd_ ## FUNC_NAME \
(JNIEnv *env, jclass thiz, ##__VA_ARGS__)\
动态注册的示例:
JNINativeMethod nativeMethods[] {
{"hello", "(I)V", (void *)"native_hello"},
{"world", "(J)V", (void *)"native_world"}
};
jint JNI_OnLoad(JavaVM* vm, void* reserved) {
JNIEnv *env = NULL;
vm->GetEnv((void **)&env, JNI_VERSION_1_6);
jclass clazz = env->FindClass("com/frank/ffmpeg/handler/FFmpegHandler");
int numMethods = sizeof(nativeMethods) / sizeof(nativeMethods[0]);
// 注册本地方法到函数表
env->RegisterNatives(clazz, nativeMethods, numMethods);
env->DeleteLocalRef(clazz);
return JNI_VERSION_1_6;
}
JNINativeMethod的结构体位于jni.h,定义如下:
typedef struct {
const char* name;
const char* signature;
void* fnPtr;
} JNINativeMethod;
3、JNI方法参数
JNI方法前两个参数分别是JNIEnv和jclass,其中JNIEnv是上下文环境,而jclass是类的实例对象。其他参数为带j开头,比如jint、jstring。
4、全局引用与局部引用
JNI提供局部引用和全局引用,还有全局弱引用。顾名思义,局部引用的作用域为局部,在本地方法返回时被GC主动回收,通过如下方法创建:
jobject NewLocalRef(JNIEnv *env, jobject ref);
全局引用的作用域为全局,不会被GC回收,需要手动释放引用资源,否则导致内存泄漏。全局引用的创建与释放如下:
// new global reference
jobject NewGlobalRef(JNIEnv *env, jobject obj);
// delete global reference
void DeleteGlobalRef(JNIEnv *env, jobject globalRef);
全局弱引用与全局引用不同的是,它可以被GC回收。另外,它关联到虚引用,用于感知何时被GC回收。全局弱引用的创建与释放如下:
// new weak global reference
jweak NewWeakGlobalRef(JNIEnv *env, jobject obj);
// delete weak global reference
void DeleteWeakGlobalRef(JNIEnv *env, jweak obj);
5、异常检测与异常处理
JNI提供检测异常、抛出异常和清除异常。使用ExceptionOccurred()进行异常检测。在检测到异常后通过ThrowNew()抛出异常,方法如下:
jint ThrowNew(JNIEnv *env, jclass clazz, const char *message);
最后是清除异常,使用ExceptionClear()。完整的示例代码如下:
// 检测异常
if (env->ExceptionOccurred() != NULL) {
// 抛出异常
jclass clazz = env->FindClass("java/lang/NullPointerException");
env->ThrowNew(clazz, "This is a null pointer...");
// 清除异常
env->ExceptionClear();
}
二、JNI类型与数据结构
1、基本类型与引用类型
JNI类型包括基本类型和引用(对象)类型。基本类型包括:jint、jbyte、jshort、jlong、jdouble、jboolean、jchar、jfloat等,如下图所示:
引用类型的负类是jobject,包含jclass、jstring、jarray,而jarray又包含各种基本类型对应的数组。层级关系如下图所示:
2、变量id与方法id
变量id用jfieldID表示,方法id用jmethodID表示。使用场景为反射Java变量或Java方法。比如,在反射Java方法时,先获取对应的jmethodID,再调用对应的method。
3、函数签名
函数签名由参数类型和返回值组成,用参数个数、参数类型和返回值来区分同名方法,即解决方法重载问题。java基本类型对应的签名如下:
至于引用对象类型,使用类的全限定名作为签名。 比如String对应签名为Ljava/lang/String;
三、JNI函数
1、获取类的实例对象
我们该如何反射调用java方法呢?首先要获取类的实例对象,然后获取方法id,最后根据方法id来调用方法。获取类的实例对象有两种方式:GetObjectClass()和FindClass(),示例如下:
void get_class(JNIEnv *env, jobject object) {
// 通过类的实例获取
jclass clazz = env->GetObjectClass(object);
// 通过类加载器查找指定的类
jclass claxx = env->FindClass("java/lang/NullPointerException");
}
2、对象的操作
我们可以通过GetObjectRefType()获取引用类型,包括如下引用类型:
JNIInvalidRefType = 0
JNILocalRefType = 1
JNIGlobalRefType = 2
JNIWeakGlobalRefType = 3
如果要判断是否属于某个类的实例,方法如下:
jboolean IsInstanceOf(JNIEnv *env, jobject obj, jclass clazz);
如果要判断两个对象是否相同,方法如下:
jboolean IsSameObject(JNIEnv *env, jobject ref1, jobject ref2);
3、反射调用Java变量
反射Java的变量分为两步,首先获取变量的jfieldID,然后获取/设置变量值,示例如下:
jclass clazz = env->GetObjectClass(object);
jfieldID fieldId = env->GetFieldID(clazz, "level", "I");
env->SetIntField(object, fieldId, 8);
4、反射调用Java方法
反射Java的方法也分为两步,首先获取方法的jmethodID,然后调用方法,示例如下:
jclass clazz = env->GetObjectClass(object);
jmethodID methodId = env->GetMethodID(clazz, "setLevel", "(I)V");
env->CallIntMethod(object, methodId, 8);
5、字符串的操作
如果要读取来自Java层的字符串,可以调用GetStringUTFChars(),使用完毕不要忘记释放资源,否则导致内存泄漏。示例代码如下:
void get_string_from_java(JNIEnv *env, jobject object, jstring jstr) {
const char *str = env->GetStringUTFChars(jstr, JNI_FALSE);
int len = env->GetStringUTFLength(jstr);
printf("from java str=%s, len=%d", str, len);
env->ReleaseStringUTFChars(jstr, str);
}
如果要返回字符串给Java层,使用NewStringUTF(),示例代码如下:
jstring set_string_to_java(JNIEnv *env, jobject object) {
const char *str = "hello, world";
return env->NewStringUTF(str);
}
6、数组的操作
如果要读取来自Java层的数组,可以调用GetXxxArrayElements()。也可以调用GetXxxArrayRegion(),该函数比较灵活,支持指定数组区间。还有没有第三种方式呢?答案是有的,可以调用GetPrimitiveArrayCritical()获取原始数组,采用内存映射实现。示例代码如下:
void get_array_from_java(JNIEnv *env, jobject object, jintArray jarray) {
int len = env->GetArrayLength(jarray);
// 1、使用GetIntArrayElements,使用完释放内存
jint *array = env->GetIntArrayElements(jarray, JNI_FALSE);
for (int i = 0; i < len; ++i) {
printf("from java array=%d", array[i]);
}
env->ReleaseIntArrayElements(jarray, array, JNI_ABORT);
// 2、使用GetIntArrayRegion,内部会释放内存
env->GetIntArrayRegion(jarray, 0, len, array);
// 3、使用GetPrimitiveArrayCritical获取原始数组
array = (jint*) env->GetPrimitiveArrayCritical(jarray, JNI_FALSE);
}
如果要返回数组给Java层,先创建JNI数组,然后把数据拷贝给数据,示例代码如下:
jintArray set_array_to_java(JNIEnv *env, jobject object) {
jint data[] = {1, 2, 3, 4, 5, 6};
int size = sizeof(data)/sizeof(data[0]);
jintArray array = env->NewIntArray(size);
env->SetIntArrayRegion(array, 0, size, data);
return array;
}
7、NIO的创建与处理
我们可以在本地方法访问java.nio的DirectBuffer。先科普一下,DirectBuffer为堆外内存,实现零拷贝,提升Java层与native层的传输效率。而HeapBuffer为堆内存,在native层多一次拷贝,效率相对低。两者对比如下:
内存位置 | 使用场景 | 优点 | 缺点 | |
DirectBuffer | 堆外内存 | 调用频率高、数据多 | 零拷贝,效率高 | 创建耗时 |
HeapBuffer | 堆内存 | 调用频率低、数据少 | 创建相对快 | 存在拷贝,效率低 |
DirectBuffer在Native层的使用,可以在Java层创建,然后把对象传递到Native层。获取到内存地址后,把数据拷贝给DirectBuffer。整个过程如下:
void copy_to_directBuffer(JNIEnv *env, jobject object, jobject buf) {
uint8_t data[] = {1, 2, 3, 4, 5, 6};
uint8_t *buf_addr = (uint8_t *) (env->GetDirectBufferAddress(buf));
int buf_size = env->GetDirectBufferCapacity(buf);
int data_size = sizeof(data)/sizeof(data[0]);
int size = data_size > buf_size ? buf_size : data_size;
memcpy(buf_addr, data, size);
}
8、方法与ID的转换
上面提及到反射调用Java方法,如果要根据method去获取对应id,API方法如下:
jmethodID FromReflectedMethod(JNIEnv *env, jobject method);
相反地,如果要根据id去获取对应method,API方法如下:
jobject ToReflectedMethod(JNIEnv *env, jclass cls, jmethodID methodID, jboolean isStatic);
四、库加载回调与JavaVM调用
1、库加载回调
调用System.loadLibrary()时,系统在加载库成功后,会回调JNI_OnLoad(JavaVM *vm, void *reserved)。带有JavaVM参数可以保存为全局变量,返回值为JNI版本号。示例代码如下:
jint JNI_OnLoad(JavaVM *vm, void *reserved) {
javaVM = vm;
return JNI_VERSION_1_6;
}
当类加载器包含的本地库已经被垃圾回收器回收了,虚拟机会回调JNI_OnUnload()方法。 在该方法回调时,我们可以做内存清理工作。
2、JavaVM调用
2.1 创建JavaVM
创建JavaVM需要传入JavaVM指针、JNIEnv指针和VM参数。当前线程变成主线程,得到的env作为主线程的上下文环境。创建JVM方法为JNI_CreateJavaVM()。
2.2 关联JavaVM
当工作线程需要使用env时,必须先调用AttachCurrentThread()方法来关联JVM,因为env是线程私有的上下文环境。如果已经关联,不执行任何操作。需要注意的是,一个本地线程不能关联两个JVM。
2.3 脱离JavaVM
当使用完env时,调用DetachCurrentThread()方法来脱离JVM。
2.4 销毁JavaVM
当不再需要使用JavaVM时,调用DestroyJavaVM()方法用于卸载JVM和清除内存。任何线程,不管有没关联JVM,都可以调用该方法。
JavaVM的完整使用过程如下:
void callJVM() {
JNIEnv *env = nullptr;
JavaVM *jvm = nullptr;
// 1、创建jvm
JNI_CreateJavaVM(&jvm, &env, nullptr);
// 2、关联jvm
jvm->AttachCurrentThread(&env, nullptr);
// 3、do something with env
// 4、脱离jvm
jvm->DetachCurrentThread();
// 5、销毁jvm
jvm->DestroyJavaVM();
}
五、堆栈崩溃排查
做JNI/NDK开发时,经常遇到堆栈崩溃问题,只有一堆杂乱地址,实在让人摸不着头脑。堆栈信息包括:ABI架构、pid进程号、出错信号、崩溃原因、寄存器状态、堆栈地址。空指针引起的崩溃如下图所示:
字符串编码不同而引起的崩溃如下:
Abort message: 'JNI DETECTED ERROR IN APPLICATION: input is not valid Modified UTF-8: illegal continuation byte 0
string: '�'
input: '0xf4'
1、ndk-stack查看堆栈
遇到native层崩溃时,我们可用ndk-stack查看堆栈地址,命令如下:
adb logcat | ndk-stack -sym xxx/libxxx.so
2、addr2line查看代码位置
// 0x12345678为堆栈地址,替换为实际崩溃地址
aarch64-linux-android-addr2line -e libxxx.so 0x12345678
3、objdump查看符号表
objdump可以用-syms查看符号表,命令如下:
objdump -syms libxxx.so
4、readelf查看依赖库与符号表
readelf是用来查看ELF文件的工具,ELF(Executable and Linkable Format)是一种可执行、可重定向的二进制目标文件。命令参数选项如下:
使用readelf -d libxxx.so查看其依赖库:
使用readelf -s libxxx.so查看其符号表:
参考链接:JNI官方开发指南
更多推荐
所有评论(0)