Android JNI 编程实践

作者: 全站工程师 | 来源:发表于2017-07-10 10:20 被阅读214次

    前言

    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 释放,除非以下几种情况:

    1. Native 方法内创建大量的本地引用,例如在循环中反复创建,因为虚拟机保存本地引用的空间是有限的(Android 为512个),一旦循环中创建的引用数超出限制就会导致异常:ReferenceTable overflow (max=512);
    2. 通过 AttachCurrentThread() 依附到 JVM 的线程内的所有本地引用均不会被自动释放,直到调用 DetachCurrentThread() 才会统一释放,为避免线程中创建太多本地引用建议及时做手动释放;
    3. 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 语句的同步块函数:

    image.png

    B. 也可以直接在 Java 层用 synchronized 关键词修饰 native 方法:

       public native synchronized void sayHello();
    

    这种用法可以确保 Java 对 sayHello() 的调用是同步的,但通常不建议这么用,因为可能带来以下问题:

    1. 对整个 Native 方法做同步的粒度较大,可能影响性能;
    2. Native 和 Java 的方法声明在不同的位置,可能出现方法声明更改(如 synchronized 关键词被删除),会导致方法不再线程安全;
    3. 如果该 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>ArrayElementsRelease<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);
    

    相比前一种方式,块拷贝有以下优点:

    1. 只需要一次 JNI 调用,减少开销;
    2. 无需创建副本或引用 Java 数组的内存(不影响 GC)
    3. 降低编程出错的风险——不会因漏调用 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 层访问可能会比较慢。

    以上两种方式的选择取决于以下因素:

    1. 数据主要是在 Java 还是 C/C++ 代码访问?
      如果主要是在 C/C++ 里访问首选 DirectByteBuffer,速度快限制少。
    2. 如果数据最终要被传回 Java API,是作为什么类型的参数传递的?
      如果 Java API 需要一个 byte[] 参数,那么就不要使用 DirectByteBuffer(调用其 byte[] array () 方法会抛 UnsupportedOperationException 异常)。
    3. 如果上两种方式都可以使用且没有明显的优劣,建议优先选用 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

    9 附录

    一、Android NDK Stable API

    image.png

    相关文章

      网友评论

        本文标题:Android JNI 编程实践

        本文链接:https://www.haomeiwen.com/subject/colyhxtx.html