返回首页 JNI/NDK 开发指南

JNI 调用性能测试及优化

在前面几章我们学习到了,在 Java 中声明一个 native 方法,然后生成本地接口的函数原型声明,再用 C/C++ 实现这些函数,并生成对应平台的动态共享库放到 Java 程序的类路径下,最后在 Java 程序中调用声明的 native 方法就间接的调用到了 C/C++ 编写的函数了,在 C/C++ 中写的程序可以避开 JVM 的内存开销过大的限制、处理高性能的计算、调用系统服务等功能。同时也学习到了在本地代码中通过 JNI 提供的接口,调用 Java 程序中的任意方法和对象的属性。这是 JNI 提供的一些优势。但做过 Java 的童鞋应该都明白,Java 程序是运行在 JVM 上的,所以在 Java 中调用 C/C++ 或其它语言这种跨语言的接口时,或者说在 C/C++ 代码中通过 JNI 接口访问 Java 中对象的方法或属性时,相比 Java 调用自已的方法,性能是非常低的!网上有朋友针对 Java 调用本地接口,Java 调 Java 方法做了一次详细的测试,来充分说明在享受 JNI 给程序带来优势的同时,也要接受其所带来的性能开销,请看下面一组测试数据。

Java 调用 JNI 空函数与 Java 调用 Java 空方法性能测试。

测试环境:JDK1.4.2_19、JDK1.5.0_04 和 JDK1.6.0_14,测试的重复次数都是一亿次。测试结果的绝对数值意义不大,仅供参考。因为根据 JVM 和机器性能的不同,测试所产生的数值也会不同,但不管什么机器和 JVM 应该都能反应同一个问题,Java 调用 native 接口,要比 Java 调用 Java 方法性能要低很多。

Java 调用 Java 空方法的性能:

JDK 版本 Java 调 Java 耗时 平均每秒调用次数
1.6 329ms 303951367次
1.5 312ms 320512820次
1.4 312ms 27233115次

Java 调用 JNI 空函数的性能:

JDK版本 Java调Java耗时 平均每秒调用次数
1.6 1531ms 65316786次
1.5 1891ms 52882072次
1.4 3672ms 27233115次

从上述测试数据可以看出 JDK 版本越高,JNI 调用的性能也越好。在 JDK1.5 中,仅仅是空方法调用,JNI 的性能就要比 Java 内部调用慢将近 5 倍,而在 JDK1.4 下更是慢了十多倍。

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

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

java.version = 1.6.0_14

  • JNI 字段读取 (缓存Class=false ,缓存字段ID=false) 耗时 : 79172 ms 平均每秒 : 1263072
  • JNI 字段读取 (缓存Class=true ,缓存字段ID=false) 耗时 : 25015 ms 平均每秒 : 3997601
  • JNI 字段读取 (缓存Class=false ,缓存字段ID=true) 耗时 : 50765 ms 平均每秒 : 1969861
  • JNI 字段读取 (缓存Class=true ,缓存字段ID=true) 耗时 : 2125 ms 平均每秒 : 47058823

java.version = 1.5.0_04

  • JNI 字段读取 (缓存Class=false ,缓存字段ID=false) 耗时 : 87109 ms 平均每秒 : 1147987
  • JNI 字段读取 (缓存Class=true ,缓存字段ID=false) 耗时 : 32031 ms 平均每秒 : 3121975
  • JNI 字段读取 (缓存Class=false ,缓存字段ID=true) 耗时 : 51657 ms 平均每秒 : 1935846
  • JNI 字段读取 (缓存Class=true ,缓存字段ID=true) 耗时 : 2187 ms 平均每秒 : 45724737

java.version = 1.4.2_19

  • JNI 字段读取 (缓存Class=false ,缓存字段ID=false) 耗时 : 97500 ms 平均每秒 : 1025641
  • JNI 字段读取 (缓存Class=true ,缓存字段ID=false) 耗时 : 38110 ms 平均每秒 : 2623983
  • JNI 字段读取 (缓存Class=false ,缓存字段ID=true) 耗时 : 55204 ms 平均每秒 : 1811462
  • JNI 字段读取 (缓存Class=true ,缓存字段ID=true) 耗时 : 4187 ms 平均每秒 : 23883448

根据上面的测试数据得知,查找 class 和 ID (属性和方法 ID)消耗的时间比较大。只是读取字段值的时间基本上跟上面的 JNI 空方法是一个数量级。而如果每次都根据名称查找 class 和 field 的话,性能要下降高达40倍。读取一个字段值的性能在百万级上,在交互频繁的 JNI 应用中是不能忍受的。 消耗时间最多的就是查找class,因此在 native 里保存 class 和 member id 是很有必要的。class 和 member id 在一定范围内是稳定的,但在动态加载的 class loader 下,保存全局的 class 要么可能失效,要么可能造成无法卸载classloader,在诸如 OSGI 框架下的 JNI 应用还要特别注意这方面的问题。在读取字段值和查找 FieldID 上,JDK1.4 和 1.5、1.6 的差距是非常明显的。但在最耗时的查找 class 上,三个版本没有明显差距。

通过上面的测试可以明显的看出,在调用 JNI 接口获取方法 ID、字段 ID 和 Class 引用时,如果没用使用缓存的话,性能低至 4 倍。所以在 JNI 开发中,合理的使用缓存技术能给程序提高极大的性能。缓存有两种,分别为使用时缓存和类静态初始化时缓存,区别主要在于缓存发生的时刻。

使用时缓存

字段 ID、方法 ID 和 Class 引用在函数当中使用的同时就缓存起来。下面看一个示例:

package com.study.jnilearn;

public class AccessCache {

    private String str = "Hello";

    public native void accessField(); // 访问str成员变量
    public native String newString(char[] chars, int len); // 根据字符数组和指定长度创建String对象

    public static void main(String[] args) {
        AccessCache accessCache = new AccessCache();
        accessCache.nativeMethod();
        char chars[] = new char[7];
        chars[0] = '中';
        chars[1] = '华';
        chars[2] = '人';
        chars[3] = '民';
        chars[4] = '共';
        chars[5] = '和';
        chars[6] = '国';
        String str = accessCache.newString(chars, 6);
        System.out.println(str);
    }

    static {
        System.loadLibrary("AccessCache");
    }
}

javah 生成的头文件:com_study_jnilearn_AccessCache.h

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_study_jnilearn_AccessCache */
#ifndef _Included_com_study_jnilearn_AccessCache
#define _Included_com_study_jnilearn_AccessCache
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     com_study_jnilearn_AccessCache
 * Method:    accessField
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_com_study_jnilearn_AccessCache_accessField(JNIEnv *, jobject);

/*
 * Class:     com_study_jnilearn_AccessCache
 * Method:    newString
 * Signature: ([CI)Ljava/lang/String;
 */
JNIEXPORT jstring JNICALL Java_com_study_jnilearn_AccessCache_newString(JNIEnv *, jobject,
jcharArray, jint);

#ifdef __cplusplus
}
#endif
#endif

实现头文件中的函数:AccessCache.c

// AccessCache.c
#include "com_study_jnilearn_AccessCache.h"

JNIEXPORT void JNICALL Java_com_study_jnilearn_AccessCache_accessField
  (JNIEnv *env, jobject obj)
{
    // 第一次访问时将字段存到内存数据区,直到程序结束才会释放,可以起到缓存的作用
    static jfieldID fid_str = NULL;
    jclass cls_AccessCache;
    jstring j_str;
    const char *c_str;
    cls_AccessCache = (*env)->GetObjectClass(env, obj); // 获取该对象的Class引用
    if (cls_AccessCache == NULL) {
        return;
    }

    // 先判断字段ID之前是否已经缓存过,如果已经缓存过则不进行查找
    if (fid_str == NULL) {
        fid_str = (*env)->GetFieldID(env,cls_AccessCache,"str","Ljava/lang/String;");

        // 再次判断是否找到该类的str字段
        if (fid_str == NULL) {
            return;
        }
    }

    j_str = (*env)->GetObjectField(env, obj, fid_str);  // 获取字段的值
    c_str = (*env)->GetStringUTFChars(env, j_str, NULL);
    if (c_str == NULL) {
        return; // 内存不够
    }
    printf("In C:\n str = \"%s\"\n", c_str);
    (*env)->ReleaseStringUTFChars(env, j_str, c_str);   // 释放从从JVM新分配字符串的内存空间

    // 修改字段的值
    j_str = (*env)->NewStringUTF(env, "12345");
    if (j_str == NULL) {
        return;
    }
    (*env)->SetObjectField(env, obj, fid_str, j_str);

    // 释放本地引用
    (*env)->DeleteLocalRef(env,cls_AccessCache);
    (*env)->DeleteLocalRef(env,j_str);
}

JNIEXPORT jstring JNICALL Java_com_study_jnilearn_AccessCache_newString
(JNIEnv *env, jobject obj, jcharArray j_char_arr, jint len)
{
    jcharArray elemArray;
    jchar *chars = NULL;
    jstring j_str = NULL;
    static jclass cls_string = NULL;
    static jmethodID cid_string = NULL;
    // 注意:这里缓存局引用的做法是错误,这里做为一个反面教材提醒大家,下面会说到。
    if (cls_string == NULL) {
        cls_string = (*env)->FindClass(env, "java/lang/String");
        if (cls_string == NULL) {
            return NULL;
        }
    }

    // 缓存String的构造方法ID
    if (cid_string == NULL) {
        cid_string = (*env)->GetMethodID(env, cls_string, "<init>", "([C)V");
        if (cid_string == NULL) {
            return NULL;
        }
    }

    printf("In C array Len: %d\n", len);
    // 创建一个字符数组
    elemArray = (*env)->NewCharArray(env, len);
    if (elemArray == NULL) {
        return NULL;
    }

    // 获取数组的指针引用,注意:不能直接将jcharArray作为SetCharArrayRegion函数最后一个参数
    chars = (*env)->GetCharArrayElements(env, j_char_arr,NULL);
    if (chars == NULL) {
        return NULL;
    }
    // 将Java字符数组中的内容复制指定长度到新的字符数组中
    (*env)->SetCharArrayRegion(env, elemArray, 0, len, chars);

    // 调用String对象的构造方法,创建一个指定字符数组为内容的String对象
    j_str = (*env)->NewObject(env, cls_string, cid_string, elemArray);

    // 释放本地引用
    (*env)->DeleteLocalRef(env, elemArray);

    return j_str;
}

例1、在 Java_com_study_jnilearn_AccessCache_accessField 函数中定义了一个静态变量fid_str用于存储字段的 ID,每次调用函数的时候

 static jfieldID fid_str = NULL;

在代码段

 // 先判断字段ID之前是否已经缓存过,如果已经缓存过则不进行查找
    if (fid_str == NULL) {
        fid_str = (*env)->GetFieldID(env,cls_AccessCache,"str","Ljava/lang/String;");

        // 再次判断是否找到该类的str字段
        if (fid_str == NULL) {
            return;
        }
    }

判断字段 ID 是否已经缓存,如果没有先取出来存到fid_str中,下次再调用的时候该变量已经有值了,不用再去JVM中获取,起到了缓存的作用。

在 Java_com_study_jnilearn_AccessCache_newString 函数中定义了两个变量cls_stringcid_string,分别用于存储 java.lang.String 类的 Class 引用和 String 的构造方法 ID。在使用前会先判断是否已经缓存过,如果没有则调用 JNI 的接口从 JVM 中获取 String 的 Class 引用和构造方法 ID 存储到静态变量当中。下次再调用该函数时就可以直接使用,不需要再去找一次了,也达到了缓存的效果,大家第一反映都会这么认为。但是请注意:cls_string是一个局部引用,与方法和字段 ID 不一样,局部引用在函数结束后会被 JVM 自动释放掉,这时cls_string成为了一个野针对(指向的内存空间已被释放,但变量的值仍然是被释放后的内存地址,不为 NULL),当下次再调用 Java_com_xxxx_newString 这个函数的时候,会试图访问一个无效的局部引用,从而导致非法的内存访问造成程序崩溃。所以在函数内用 static 缓存局部引用这种方式是错误的。下篇文章会介绍局部引用和全局引用,利用全局引用来防止这种问题,请关注。

类静态初始化缓存

在调用一个类的方法或属性之前,Java 虚拟机会先检查该类是否已经加载到内存当中,如果没有则会先加载,然后紧接着会调用该类的静态初始化代码块,所以在静态初始化该类的过程当中计算并缓存该类当中的字段 ID 和方法 ID 也是个不错的选择。下面看一个示例:

package com.study.jnilearn;

public class AccessCache {

    public static native void initIDs(); 

    public native void nativeMethod();
    public void callback() {
        System.out.println("AccessCache.callback invoked!");
    }

    public static void main(String[] args) {
        AccessCache accessCache = new AccessCache();
        accessCache.nativeMethod();
    }

    static {
        System.loadLibrary("AccessCache");
        initIDs();
    }
}
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class com_study_jnilearn_AccessCache */
#ifndef _Included_com_study_jnilearn_AccessCache
#define _Included_com_study_jnilearn_AccessCache
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     com_study_jnilearn_AccessCache
 * Method:    initIDs
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_com_study_jnilearn_AccessCache_initIDs
  (JNIEnv *, jclass);

/*
 * Class:     com_study_jnilearn_AccessCache
 * Method:    nativeMethod
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_com_study_jnilearn_AccessCache_nativeMethod
  (JNIEnv *, jobject);

#ifdef __cplusplus
}
#endif
#endif
// AccessCache.c

#include "com_study_jnilearn_AccessCache.h"

jmethodID MID_AccessCache_callback;

JNIEXPORT void JNICALL Java_com_study_jnilearn_AccessCache_initIDs
(JNIEnv *env, jclass cls)
{
    printf("initIDs called!!!\n");
    MID_AccessCache_callback = (*env)->GetMethodID(env,cls,"callback","()V");
}

JNIEXPORT void JNICALL Java_com_study_jnilearn_AccessCache_nativeMethod
(JNIEnv *env, jobject obj)
{
    printf("In C Java_com_study_jnilearn_AccessCache_nativeMethod called!!!\n");
    (*env)->CallVoidMethod(env, obj, MID_AccessCache_callback);
}

JVM 加载 AccessCache.class 到内存当中之后,会调用该类的静态初始化代码块,即 static 代码块,先调用System.loadLibrary 加载动态库到 JVM 中,紧接着调用 native 方法 initIDs,会调用用到本地函数Java_com_study_jnilearn_AccessCache_initIDs,在该函数中获取需要缓存的 ID,然后存入全局变量当中。下次需要用到这些 ID 的时候,直接使用全局变量当中的即可,调用 Java 的 callback 函数。

(*env)->CallVoidMethod(env, obj, MID_AccessCache_callback);

两种缓存方式比较

如果在写 JNI 接口时,不能控制方法和字段所在类的源码的话,用使用时缓存比较合理。但比起类静态初始化时缓存来说,用使用时缓存有一些缺点:

  • 使用前,每次都需要检查是否已经缓存该 ID 或 Class 引用
  • 如果在用使用时缓存的 ID,要注意只要本地代码依赖于这个 ID 的值,那么这个类就不会被 unload。另外一方面,如果缓存发生在静态初始化时,当类被 unload 或 reload 时,ID 会被重新计算。因为,尽量在类静态初始化时就缓存字段 ID、方法 ID 和类的 Class 引用。