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、库的加载

2、动态注册与静态注册

3、JNI方法参数

4、全局引用与局部引用

5、异常检测与异常处理

二、JNI类型与数据结构

1、基本类型与引用类型

2、变量id与方法id

3、函数签名

三、JNI函数

1、获取类的实例对象

2、对象的操作

3、反射调用Java变量

4、反射调用Java方法

5、字符串的操作

6、数组的操作

7、NIO的创建与处理

8、方法与ID的转换

四、库加载回调与JavaVM调用

1、库加载回调

2、JavaVM调用

五、堆栈崩溃排查

1、ndk-stack查看堆栈

2、addr2line查看代码位置

3、objdump查看符号表

4、readelf查看依赖库与符号表


一、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官方开发指南

Logo

智屏生态联盟致力于大屏生态发展,利用大屏快应用技术降低开发者开发、发布大屏应用门槛

更多推荐