ndkAndroid开发Android开发经验谈

Android JNI 编程实践

2017-07-10  本文已影响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 为副本时有效,它可以用于避免一些非必要的副本拷贝,共有以下三种取值:

一般来说 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:

8 扩展检查

JNI 几乎没有错误检查,出错通常都会导致崩溃。Android 额外提供了一种名为 CheckJNI 的模式,该模式下会将 JavaVM 和 JNIEnv 的函数表指针重定向到带检查能力的函数表,该表里函数会先执行扩展检查再调用实际的 JNI 函数。
扩展检查项包括:

以下方式可以打开扩展检查能力:

adb shell stop
adb shell setprop dalvik.vm.checkjni true
adb shell start

9 附录

一、Android NDK Stable API

image.png
上一篇下一篇

猜你喜欢

热点阅读