JNI/NDK学习笔记(三)

*** 说明:本文不代表博主观点,均是由以下资料整理的读书笔记。 ***

【参考资料】

1、向您的Android Studio项目添加C/C++代码
2、Google开发者文档 -- 添加C++代码到现有Android Studio项目中
3、JNI Tips 英文原版
4、JNI Tips 中文
5、极客学院 JNI/NDK 开发指南
6、极客学院 深入理解 JNI
7、使用CMake构建JNI环境
8、使用C和C++的区别
9、Google官方 NDK 文档
10、极客学院 NDK开发课程
11、ndk-build 构建 JNI 环境
12、开发自己的NDK程序
13、JNI/NDK开发教程
14、JNI层修改参数值
15、JNI引用和垃圾回收
16、《Android高级进阶》-- 顾浩鑫
17、《Android C++ 高级编程 -- 使用 NDK》 -- Onur Cinar


九、JNI 调用构造方法和父类实例方法

这一节会详细介绍初始一个对象的两种方式,以及如何调用子类对象重写的父类实例方法。下面通过一个示例来了解在 JNI 中是如何调用对象构造方法和父类实例方法的。为了让示例能清晰的体现构造方法和父类实例方法的调用流程,定义了 Animal 和 Cat 两个类,Animal 定义了一个 String 形参的构造方法,一个成员变量 name、两个成员函数 run 和 getName,Cat 继承自 Animal,并重写了 run 方法。在 JNI 中实现创建 Cat 对象的实例,调用 Animal 类的 run 和 getName 方法。代码如下所示:

** Java代码:**

public class Animal {

    protected String name;

    public Animal(String name) {
        this.name = name;
        Log.i("Miomin", "Animal Construct call...");
    }

    public String getName() {
        Log.i("Miomin", "Animal.getName Call...");
        return this.name;
    }

    public void run() {
        Log.i("Miomin", "Animal.run...");
    }
}

public class Cat extends Animal {

    public Cat(String name) {
        super(name);
        Log.i("Miomin", "Cat Construct call....");
    }

    @Override
    public String getName() {
        return "My name is " + this.name;
    }

    @Override
    public void run() {
        Log.i("Miomin", name + " Cat.run...");
    }
}

Native代码:

extern "C"
JNIEXPORT void JNICALL
Java_com_scu_miomin_learncmake_NativeLib_callSuperInstanceMethod(
        JNIEnv *env,
        jclass cls) {

    jclass cls_cat;
    jclass cls_animal;
    jmethodID mid_cat_init;
    jmethodID mid_run;
    jmethodID mid_getName;
    jstring c_str_name;
    jobject obj_cat;
    const char *name = NULL;

    // 1、获取Cat类的class引用
    cls_cat = env->FindClass("com/scu/miomin/learncmake/Cat");
    if (cls_cat == NULL) {
        return;
    }

    // 2、获取Cat的构造方法ID(构造方法的名统一为:<init>)
    mid_cat_init = env->GetMethodID(cls_cat, "<init>", "(Ljava/lang/String;)V");
    if (mid_cat_init == NULL) {
        return; // 没有找到只有一个参数为String的构造方法
    }

    // 3、创建一个String对象,作为构造方法的参数
    c_str_name = env->NewStringUTF("汤姆猫");
    if (c_str_name == NULL) {
        return; // 创建字符串失败(内存不够)
    }

    //  4、创建Cat对象的实例(调用对象的构造方法并初始化对象)
    obj_cat = env->NewObject(cls_cat, mid_cat_init, c_str_name);
    if (obj_cat == NULL) {
        return;
    }

    //-------------- 5、调用Cat父类Animal的run和getName方法 --------------
    cls_animal = env->FindClass("com/scu/miomin/learncmake/Animal");
    if (cls_animal == NULL) {
        return;
    }


    /**
     * 例1: 调用父类的run方法
     */
    mid_run = env->GetMethodID(cls_animal, "run", "()V");    // 获取父类Animal中run方法的id
    if (mid_run == NULL) {
        return;
    }

    // 注意:obj_cat是Cat的实例,cls_animal是Animal的Class引用,mid_run是Animal类中的方法ID
    env->CallNonvirtualVoidMethod(obj_cat, cls_animal, mid_run);


    /**
     * 例2:调用父类的getName方法
     */
    // 获取父类Animal中getName方法的id
    mid_getName = env->GetMethodID(cls_animal, "getName", "()Ljava/lang/String;");
    if (mid_getName == NULL) {
        return;
    }

    c_str_name = (jstring) env->CallNonvirtualObjectMethod(obj_cat, cls_animal, mid_getName);
    name = env->GetStringUTFChars(c_str_name, NULL);
    LOGV("In C: Animal Name is %s\n", name);

    // 释放从java层获取到的字符串所分配的内存
    env->ReleaseStringUTFChars(c_str_name, name);

    quit:
    // 删除局部引用(jobject或jobject的子类才属于引用变量),允许VM释放被局部变量所引用的资源
    env->DeleteLocalRef(cls_cat);
    env->DeleteLocalRef(cls_animal);
    env->DeleteLocalRef(c_str_name);
    env->DeleteLocalRef(obj_cat);
}

如果一个方法被定义在父类中,在子类中被覆盖,也可以调用父类中的这个实例方法。JNI 提供了一系列函数CallNonvirtualXXXMethod 来支持调用各种返回值类型的实例方法。其实在开发当中,这种调用父类实例方法的情况是很少遇到的,通常在 JAVA 中可以很简单地做到: super.func()。


十、JNI 调用性能测试及优化

在 C/C++ 中写的程序可以避开 JVM 的内存开销过大的限制、处理高性能的计算、调用系统服务等功能。

10.1 Java 调用 JNI 空函数与 Java 调用 Java 空方法性能

Java 程序是运行在 JVM 上的,所以在 Java 中调用 C/C++ 或其它语言这种跨语言的接口时,或者说在 C/C++ 代码中通过 JNI 接口访问 Java 中对象的方法或属性时,相比 Java 调用自已的方法,性能是非常低的!JDK 版本越高,JNI 调用的性能也越好。在 JDK1.5 中,仅仅是空方法调用,JNI 的性能就要比 Java 内部调用慢将近 5 倍,而在 JDK1.4 下更是慢了十多倍。

10.2 JNI查找方法ID、字段ID、Class引用性能

当我们在 Native 代码中要访问 Java 对象的字段或调用它们的方法时,Native 代码必须调用 FindClass()、GetFieldID()、GetStaticFieldID、GetMethodID()和 GetStaticMethodID()。对于 GetFieldID()、GetStaticFieldID、GetMethodID() 和 GetStaticMethodID(),为特定类返回的 ID 不会在 JVM 进程的生存期内发生变化。但是,获取字段或方法的调用有时会需要在 JVM 中完成大量工作,因为字段和方法可能是从超类中继承而来的,这会让 JVM 向上遍历类层次结构来找到它们。由于 ID 对于特定类是相同的,因此只需要查找一次,然后便可重复使用。同样,查找类对象的开销也很大,因此也应该缓存它们。

消耗时间最多的就是查找class,因此在 native 里保存 class 和 member id 是很有必要的。class 和 member id 在一定范围内是稳定的,但在动态加载的 class loader 下,保存全局的 class 要么可能失效,要么可能造成无法卸载classloader,在诸如 OSGI 框架下的 JNI 应用还要特别注意这方面的问题。

两种缓存方式参考 :

http://wiki.jikexueyuan.com/project/jni-ndk-developer-guide/performance.html

http://www.2cto.com/kf/201501/368946.html


十一、JNI 局部引用、全局引用和弱全局引用

引用是在 JNI 中最容易出错的一个点,如果使用不当,容易使程序造成内存溢出,程序崩溃等现象。

在 Java 中内存管理是完全透明的,内存分配和释放交给 GC 就可以了,C/C++ 则需要自己控制内存的分配和释放;但是凡事有利也有弊,比如在做 Android 开发的时候,内存使用就受虚拟机的限制,从最初版本的16~24M,到后来的 32M 到 64M。但在 C/C++ 这层,就完全不受虚拟机的限制了。比如要在 Android 中要存储一张超高清的图片,刚好这张图片的大小超过了 Dalivk 虚拟机对每个应用的内存大小限制,Java 此时就显得无能为力了,但在C/C++ 看来就是小菜一碟了,malloc(1024102450)。

在 JNI 规范中定义了三种引用:局部引用(Local Reference)、全局引用(Global Reference)、弱全局引用(Weak Global Reference)。

创建局部引用的本地方法返回后(注意:这里是指返回到java方法),局部引用将变成无效。而全局引用以及弱全局引用在本地方法返回后,仍然有效。垃圾回收器无法回收本地方法创建的局部引用和全局引用,但可以回收本地方法创建的弱全局引用。

11.1 局部引用

(1)局部引用

通过 NewLocalRef 和各种 JNI 接口创建(FindClass、NewObject、GetObjectClass和NewCharArray等)。会阻止 GC 回收所引用的对象,不在本地函数中跨函数使用,不能跨线程使用。Native 方法返回到 Java 层之后,如果 Java 层没有对返回的局部引用使用的话,局部引用就会被 JVM 自动释放,或调用 DeleteLocalRef 释放。(当Native函数执行完成之后,如果局部引用没有被Native代码显式删除,那么局部引用在Java虚拟机中还是有效的。Java虚拟机来决定在什么时候来删除这个对象,而且直到JAVA层没有对它的引用,可以通过Native函数返回而把它引用到JAVA层,它才能被JVM回收并释放。)

局部引用不能作为静态变量,也不能作为全局变量。

jclass cls_string = (*env)->FindClass(env, "java/lang/String");
jcharArray charArr = (*env)->NewCharArray(env, len);
jstring str_obj = (*env)->NewObject(env, cls_string, cid_string, elemArray);
jstring str_obj_local_ref = (*env)->NewLocalRef(env,str_obj);   // 通过NewLocalRef函数创建

(2)错误的引用缓存

你可能会为了提高程序的性能,在函数中将局部引用存储在静态变量中缓存起来,供下次调用时使用。这种方式是错误的,因为函数返回后局部引很可能马上就会被释放掉,静态变量中存储的就是一个被释放后的内存地址,成了一个野针对,下次再使用的时候就会造成非法地址的访问,使程序崩溃。

(3)释放局部引用

JNI 会将创建的局部引用都存储在一个局部引用表中,如果这个表超过了最大容量限制,就会造成局部引用表溢出,使程序崩溃。Android 上的 JNI 局部引用表最大数量是 512 个。当我们在实现一个本地方法时,可能需要创建大量的局部引用,如果没有及时释放,就有可能导致 JNI 局部引用表的溢出,所以,在不需要局部引用时就立即调用 DeleteLocalRef 手动删除。比如,在下面的代码中,本地代码遍历一个特别大的字符串数组,每遍历一个元素,都会创建一个局部引用,当对使用完这个元素的局部引用时,就应该马上手动释放它。

for (i = 0; i < len; i++) {
     jstring jstr = (*env)->GetObjectArrayElement(env, arr, i);
     ... /* 使用jstr */
     (*env)->DeleteLocalRef(env, jstr); // 使用完成之后马上释放
}

/* 假如这是一个本地方法实现 */
JNIEXPORT void JNICALL Java_pkg_Cls_func(JNIEnv *env, jobject this)
{
   lref = ...              /* lref引用的是一个大的Java对象 */
   ...                     /* 在这里已经处理完业务逻辑后,这个对象已经使用完了 */
   (*env)->DeleteLocalRef(env, lref); /* 及时删除这个对这个大对象的引用,GC就可以对它回收,并释放相应的资源*/
   lengthyComputation();   /* 在里有个比较耗时的计算过程 */
   return;                 /* 计算完成之后,函数返回之前所有引用都已经释放 */
}

局部引用在Native代码显示释放非常重要。你可能会问,既然Java虚拟机会自动释放局部变量为什么还需要我在Native代码中显示释放呢?原因有以下几点:

  • 1、Java虚拟机默认为Native引用分配的局部引用数量是有限的,大部分的Java虚拟机实现默认分配16个局部引用。当然Java虚拟机也提供API(PushLocalFrame,EnsureLocalCapacity)让你申请更多的局部引用数量(Java虚拟机不保证你一定能申请到)。有限的资源当然要省着点用,否则将会被Java虚拟机无情抛弃(程序崩溃)。JNI编程中,实现Native代码时强烈建议调用PushLocalFrame,EnsureLocalCapacity来确保Java虚拟机为你准备好了局部变量空间。

  • 2、如果你实现的Native函数是工具函数,会被频繁的调用。如果你在Native函数中没有显示删除局部引用,那么每次调用该函数Java虚拟机都会创建一个新的局部引用,造成局部引用过多。尤其是该函数在Native代码中被频繁调用,代码的控制权没有交还给Java虚拟机,所以Java虚拟机根本没有机会释放这些局部变量。退一步讲,就算该函数直接返回给Java虚拟机,也不能保证没有问题,我们不能假设Native函数返回Java虚拟机之后,Java虚拟机马上就会回收Native函数中创建的局部引用,依赖于Java虚拟机实现。所以我们在实现Native函数时一定要记着删除不必要的局部引用,否则你的程序就有潜在的风险,不知道什么时候就会爆发。

  • 3、如果你Native函数根本就不返回。比如消息循环函数——死循环等待消息,处理消息。如果你不显示删除局部引用,很快将会造成Java虚拟机的局部引用内存溢出。

这里介绍一下PushLocalFrame和PopLocalFrame函数。这两个函数是成对使用的,先调用PushLocalFrame,然后创建局部引用,并对其进行处理,最后调用PushLocalFrame释放局部引用,这时Java虚拟机也可以对其指向的对象进行垃圾回收。可以用C语言的栈来理解这对JNI API,调用PushLocalFrame之后Native代码创建的所有局部引用全部入栈,当调用PopLocalFrame之后,入栈的局部引用除了需要返回的局部引用(PushLocalFrame和PopLocalFrame这对函数可以返回一个局部引用给外部)之外,全部出栈,Java虚拟机这时可以释放他们指向的对象。具体的用法可以参考手册。这两个函数使JNI的局部引用由于和C语言的局部变量用法类似,所以强烈推荐使用。

#define N_REFS ... /*最大局部引用数量*/
for (i = 0; i < len; i++) {
    if ((*env)->PushLocalFrame(env, N_REFS) != 0) {
        ... /*内存溢出*/
    }
     jstring jstr = (*env)->GetObjectArrayElement(env, arr, i);
     ... /* 使用jstr */
     (*env)->PopLocalFrame(env, NULL);
}

PS:还要注意的一个问题是,局部引用不能跨线程使用,只在创建它的线程有效。不要试图在一个线程中创建局部引用并存储到全局引用中,然后在另外一个线程中使用。

11.2 全局引用

调用 NewGlobalRef 基于局部引用创建,会阻 GC 回收所引用的对象。可以跨方法、跨线程使用。JVM 不会自动释放,必须调用 DeleteGlobalRef 手动释放。请注意NewGlobalRef的第二个参数,既可以用一个局部引用,也可以用全局引用生成一个全局引用,当然也可以用弱全局引用生成一个全局引用,但是这中情况有特殊的用途,后文会介绍。

static jclass g_cls_string;
void TestFunc(JNIEnv* env, jobject obj) {
    jclass cls_string = (*env)->FindClass(env, "java/lang/String");
    g_cls_string = (*env)->NewGlobalRef(env,cls_string);
}

全局引用可以跨方法、跨线程使用,直到它被手动释放才会失效。同局部引用一样,也会阻止它所引用的对象被 GC 回收。与局部引用创建方式不同的是,只能通过 NewGlobalRef 函数创建。下面这个版本的 newString 演示怎么样使用一个全局引用。

JNIEXPORT jstring JNICALL Java_com_study_jnilearn_AccessCache_newString
(JNIEnv *env, jobject obj, jcharArray j_char_arr, jint len)
{
    // ...
    jstring jstr = NULL;
    static jclass cls_string = NULL;
    if (cls_string == NULL) {
        jclass local_cls_string = (*env)->FindClass(env, "java/lang/String");
        if (cls_string == NULL) {
            return NULL;
        }

        // 将java.lang.String类的Class引用缓存到全局引用当中
        cls_string = (*env)->NewGlobalRef(env, local_cls_string);

        // 删除局部引用
        (*env)->DeleteLocalRef(env, local_cls_string);

        // 再次验证全局引用是否创建成功
        if (cls_string == NULL) {
            return NULL;
        }
    }

    // ....
    return jstr;
}

11.3 弱全局引用

调用 NewWeakGlobalRef 基于局部引用或全局引用创建,不会阻止 GC 回收所引用的对象,可以跨方法、跨线程使用。引用不会自动释放,在 JVM 认为应该回收它的时候(比如内存紧张的时候)进行回收而被释放。或调用 DeleteWeakGlobalRef 手动释放。

static jclass g_cls_string;
void TestFunc(JNIEnv* env, jobject obj) {
    jclass cls_string = (*env)->FindClass(env, "java/lang/String");
    g_cls_string = (*env)->NewWeakGlobalRef(env,cls_string);
}

所有的JNI方法都接收局部引用和全局引用作为参数。相同对象的引用却可能具有不同的值。例如,用相同对象连续地调用NewGlobalRef得到返回值可能是不同的。为了检查两个引用是否指向的是同一个对象,你必须使用IsSameObject函数。绝不要在本地代码中用==符号来比较两个引用。

得出的结论就是你绝不要在本地代码中假定对象的引用是常量或者是唯一的。代表一个对象的32位值从方法的一次调用到下一次调用可能有不同的值。在连续的调用过程中两个不同的对象却可能拥有相同的32位值。不要使用jobject的值作为key.

JNI API提供了引用比较函数IsSameObject,用弱全局引用和NULL进行比较,如果返回JNI_TRUE,则说明弱全局引用指向的对象已经被释放。需要重新初始化弱全局引用。应该这样写:

static jobject weak_global_ref = NULL;

if((*env)->IsSameObject(env, weak_global_ref, NULL) == JNI_TRUE) {
  weak_global_ref = NewWeakGlobalRef(...);
}
static jobject weak_global_ref = NULL;
jobject local_ref;

/* We ensure create local_ref success */
while ( week_global_ref == NULL || (local_ref = NewLocalRef(env, weak_global_ref)) == NULL ) {
 /* Init week global referrence again */
 weak_global_ref = NewWeakGlobalRef(...);
}

/* Process local_ref */

(*env)->DeleteLocalRef(env, local_ref);

11.4 引用比较

给定两个引用(不管是全局、局部还是弱全局引用),我们只需要调用 IsSameObject 来判断它们两个是否指向相同的对象。例如:env->IsSameObject(obj1, obj2),如果 obj1 和 obj2 指向相同的对象,则返回 JNI_TRUE(或者 1),否则返回 JNI_FALSE(或者 0)。

11.5 引用的释放

每一个 JNI 引用被建立时,除了它所指向的 JVM 中对象的引用需要占用一定的内存空间外,引用本身也会消耗掉一个数量的内存空间。作为一个优秀的程序员,我们应该对程序在一个给定的时间段内使用的引用数量要十分小心。短时间内创建大量而没有被立即回收的引用很可能就会导致内存溢出。     当我们的本地代码不再需要一个全局引用时,应该马上调用 DeleteGlobalRef 来释放它。如果不手动调用这个函数,即使这个对象已经没用了,JVM 也不会回收这个全局引用所指向的对象。

同样,当我们的本地代码不再需要一个弱全局引用时,也应该调用 DeleteWeakGlobalRef 来释放它,如果不手动调用这个函数来释放所指向的对象,JVM 仍会回收弱引用所指向的对象,但弱引用本身在引用表中所占的内存永远也不会被回收。

注意:弱全局引用是可以用来缓存jclass对象,但是用全局引用来缓存jclass对象将非常的危险。这里需要简单介绍一下Native的共享库的卸载。当Class Loader释放完所有的class后,然后Class Loader会卸载Native的共享库。如果我们用全局引用来缓存jclass对象的话,根据前面对全局引用对Java虚拟机垃圾回收机制的影响,将会阻止Java虚拟机回收该对象。如果我们不显式的释放全局引用(通过DeleteGlobalRef),则Class Loader也将不能释放这个jclass对象,进而造成Class Loader不能卸载Native的共享库(永远无法释放)。如果用弱全局引用来缓存将不会有这个问题,Java虚拟机随时都可以释放它指向的对象。

还有一种不常见的情况值得一提,如果你使用AttachCurrentThread连接(attach)了本地进程,正在运行的代码在线程分离(detach)之前决不会自动释放局部引用。你创建的任何局部引用必须手动删除。通常,任何在循环中创建局部引用的本地代码可能都需要做一些手动删除。

11.6 总结

  • 1、局部引用是Native代码中最常用的引用。大部分局部引用都是通过JNI API返回来创建,也可以通过调用NewLocalRef来创建。另外强烈建议Native函数返回值为局部引用。局部引用只在当前调用上下文中有效,所以局部引用不能用Native代码中的静态变量和全局变量来保存。另外时刻要记着Java虚拟机局部引用的个数是有限的,编程的时候强烈建议调用EnsureLocalCapacity,PushLocalFrame和PopLocalFrame来确保Native代码能够获得足够的局部引用数量。

  • 2、全局变量必须要通过NewGlobalRef创建,通过DeleteGlobalRef删除。主要用来缓存Field ID和Method ID。全局引用可以在多线程之间共享其指向的对象。在C语言中以静态变量和全局变量来保存。

  • 3、全局引用和局部引用可以阻止Java虚拟机回收其指向的对象。

  • 4、弱全局引用必须要通过NewWeakGlobalRef创建,通过DeleteWeakGlobalRef销毁。可以在多线程之间共享其指向的对象。在C语言中通过静态变量和全局变量来保持弱全局引用。弱全局引用指向的对象随时都可能会被Java虚拟机回收,所以使用的时候需要时刻注意检查其有效性。弱全局引用经常用来缓存jclass对象。

  • 5、全局引用和弱全局引用可以在多线程中共享其指向对象,但是在多线程编程中需要注意多线程同步。强烈建议在JNI_OnLoad初始化全局引用和弱全局引用,然后在多线程中进行读全局引用和弱全局引用,这样不需要对全局引用和弱全局引用同步(只有读操作不会出现不一致情况)。


十二、JNI 异常

JNI的错误检查很少。错误发生时通常会导致崩溃。Android也提供了一种模式,叫做CheckJNI,这当中JavaVM和JNIEnv函数表指针被换成了函数表,它在调用标准实现之前执行了一系列扩展检查的:

  • 数组:试图分配一个长度为负的数组。
  • 坏指针:传入一个不完整jarray/jclass/jobject/jstring对象到JNI函数,或者调用JNI函数时使用空指针传入到一个不能为空的参数中去。
  • 类名:传入了除“java/lang/String”之外的类名到JNI函数。
    关键调用:在一个“关键的(critical)”get和它对应的release之间做出JNI调用。
  • 直接的ByteBuffers:传入不正确的参数到NewDirectByteBuffer。
    异常:当一个异常发生时调用了JNI函数。
  • JNIEnvs:在错误的线程中使用一个JNIEnv。
  • jfieldIDs:使用一个空jfieldID,或者使用jfieldID设置了一个错误类型的值到字段(比如说,试图将一个StringBuilder赋给String类型的域),或者使用一个静态字段下的jfieldID设置到一个实例的字段(instance field)反之亦然,或者使用的一个类的jfieldID却来自另一个类的实例。
  • jmethodIDs:当调用Call*Method函数时时使用了类型错误的
  • jmethodID:不正确的返回值,静态/非静态的不匹配,this的类型错误(对于非静态调用)或者错误的类(对于静态类调用)。
    引用:在类型错误的引用上使用了DeleteGlobalRef/DeleteLocalRef。
  • 释放模式:调用release使用一个不正确的释放模式(其它非 0,JNI_ABORT,JNI_COMMIT的值)。
  • 类型安全:从你的本地代码中返回了一个不兼容的类型(比如说,从一个声明返回String的方法却返回了StringBuilder)。
  • UTF-8:传入一个无效的变形UTF-8字节序列到JNI调用。

如果你正在使用模拟器,CheckJNI默认是打开的。

如果你有一台root过的设备,你可以使用下面的命令序列来重启运行时(runtime),启用CheckJNI。

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

随便哪一种,当运行时(runtime)启动时你将会在你的日志输出中见到如下的字符:

D AndroidRuntime: CheckJNI is ON

如果你有一台常规的设备,你可以使用下面的命令:

adb shell setprop debug.checkjni 1

这将不会影响已经在运行的app,但是从那以后启动的任何app都将打开CheckJNI(改变属性为其它值或者只是重启都将会再次关闭CheckJNI)。这种情况下,你将会在下一次app启动时,在日志输出中看到如下字符:

D Late-enabling CheckJNI

推荐阅读更多精彩内容