你真的会用JNI吗?这些小技巧99%的人都不知道

  欢迎大家关注一下作者开源的一个音视频库,hwvc是一个使用C/C++实现、架构设计良好的高效音视频编解码库,支持软编和硬编,包含Camera采集编码、分段录制回删、音频采集、音视频播放、低延时耳返、图片编辑等功能。使用它您可以很容易地实现常见的音视频需求,目前只支持Android,当然也可以很方便的移植到IOS。欢迎查阅学习和使用,如有BUG或建议,欢迎Issue。

  使用Java环境和语言能够开发安全的应用程序,但是某些程序需要在Java环境之外执行任务,比如:

  • 与现有的C/C++代码集成,避免重写。
  • 与C/C++代码集成,以利用Native语言的性能和系统相关的特性。
  • 使用Java类库中缺失的功能。例如,您可能需要使用Java语言实现ping的功能,这需要实现ICMP协议,但是Java基本类库没有提供这个协议的实现。
  • 解决不能使用Java代码的特殊情况。例如,核心类库的实现可能需要跨包调用或需要绕过其他Java安全检查。
      对于以上列举的这些例子,我们可以使用JNI来实现。为了清晰的分离Java代码和本地代码,JNI在Java和Native之间定义了一系列清晰的接口,用来进行通讯。Java代码与本地代码分离的架构,避免将Native代码引入JVM,保证了Java一次编译,到处运行的强大特性。
      使用JNI,本机代码可以自由地与Java对象交互,例如获取和设置字段值,或者调用方法。但是这种自由也是一把双刃剑,它为了完成前面列出的任务而牺牲了Java语言的安全性。在应用程序中使用JNI可以获得对机器资源(内存,I / O等)的强大且低级访问权限,因此您可以在没有Java语言提供的强大安全机制下工作。JNI的灵活性和强大功能为编程实践带来了的风险,这可能导致性能低下,错误甚至程序崩溃。
      本文将介绍JNI使用者所犯的10个最常见的编码和设计错误。目的是帮助您识别并避开它们,以便您可以编写稳定、安全、高效的JNI代码。

  JNI编程陷阱分为两类:

  • 性能:代码执行设计的功能,但速度很慢或导致整个程序速度变慢。
  • 正确性:代码在某些时候有效,但不能可靠地提供所需的功能; 在最坏的情况下,它会崩溃或挂起。

性能陷阱

使用JNI的程序员面临的五大性能缺陷:

  • 不缓存方法ID,字段ID和类
  • 触发数组拷贝
  • 返回而不是传递参数
  • 在本地代码和Java代码之间选择了错误的边界
  • 使用许多本地引用而不通知JVM

不缓存方法ID,字段ID和类

  要访问Java对象的字段或者调用它们的方法,本地代码必须调用FindClass()、GetFieldID()、GetMethodId()和GetStaticMethodID()来获取对应的ID。在的通常情况下,GetFieldID()、GetMethodID()和 GetStaticMethodID()为同一个​​类返回的ID在JVM进程的生命周期内都不会更改。但是获取字段或方法的ID可能需要在JVM中进行大量工作,因为字段和方法可能已经从超类继承,JVM不得不在类继承结构中查找它们。因为给定类的ID是相同的,所以您应该查找它们一次,然后重复使用它们。同样的,查找类对象也可能很耗时,因此它们也应该被缓存起来进行复用。

  例如,代码1显示了调用静态方法所需的JNI代码:

代码1.使用JNI调用静态方法

int val=1;
jmethodID method;
jclass cls;
 
cls = (*env)->FindClass(env, "com/ibm/example/TestClass");
if ((*env)->ExceptionCheck(env)) {
   return ERR_FIND_CLASS_FAILED;
}
method = (*env)->GetStaticMethodID(env, cls, "setInfo", "(I)V");
if ((*env)->ExceptionCheck(env)) {
   return ERR_GET_STATIC_METHOD_FAILED;
}
(*env)->CallStaticVoidMethod(env, cls, method,val);
if ((*env)->ExceptionCheck(env)) {
   return ERR_CALL_STATIC_METHOD_FAILED;

  每次我们想要调用方法时查找类和方法ID会导致六个JNI接口的调用,但实际上,如果我们把类和对应的方法ID缓存起来,则只需要两个。缓存会对应用程序的运行效率产生重大影响。我们看一下以下两个版本的方法,它们都做同样的事情。

  代码2中的版本使用缓存的字段ID:

代码2.使用缓存的字段ID

int sumValues2(JNIEnv* env, jobject obj, jobject allValues){
 
   jint avalue = (*env)->GetIntField(env, allValues, a);
   jint bvalue = (*env)->GetIntField(env, allValues, b);
   jint cvalue = (*env)->GetIntField(env, allValues, c);
   jint dvalue = (*env)->GetIntField(env, allValues, d);
   jint evalue = (*env)->GetIntField(env, allValues, e);
   jint fvalue = (*env)->GetIntField(env, allValues, f);
 
   return avalue + bvalue + cvalue + dvalue + evalue + fvalue;
}

  代码3不使用缓存的字段ID:

代码3.未缓存的字段ID

int sumValues2(JNIEnv* env, jobject obj, jobject allValues){
   jclass cls = (*env)->GetObjectClass(env,allValues);
   jfieldID a = (*env)->GetFieldID(env, cls, "a", "I");
   jfieldID b = (*env)->GetFieldID(env, cls, "b", "I");
   jfieldID c = (*env)->GetFieldID(env, cls, "c", "I");
   jfieldID d = (*env)->GetFieldID(env, cls, "d", "I");
   jfieldID e = (*env)->GetFieldID(env, cls, "e", "I");
   jfieldID f = (*env)->GetFieldID(env, cls, "f", "I");
   jint avalue = (*env)->GetIntField(env, allValues, a);
   jint bvalue = (*env)->GetIntField(env, allValues, b);
   jint cvalue = (*env)->GetIntField(env, allValues, c);
   jint dvalue = (*env)->GetIntField(env, allValues, d);
   jint evalue = (*env)->GetIntField(env, allValues, e);
   jint fvalue = (*env)->GetIntField(env, allValues, f);
   return avalue + bvalue + cvalue + dvalue + evalue + fvalue
}

  代码2的版本需要3,572 ms才能运行10,000,000次。代码3的版本需要86,217毫秒,是代码2耗时的24倍。

触发数组拷贝

  JNI在Java代码和本地代码之间提供了一系列干净的接口。为了保持这种分离,数组不得不使用不透明句柄进行传递,本地代码必须回调JVM才能使用set和get调用来操作数组元素。这些调用是否提供对数组的直接访问,还是返回数组的副本,Java规范将其留给具体JVM版本实现。例如,当JVM以不连续存储它们的方式优化数组时,它可能会返回一个副本。
  这些调用可能会导致数组元素被拷贝。例如,如果您对包含1,000个元素的数组调用GetLongArrayElements(),则可能会导致分配和复制至少8,000个字节(1,000 * 8)。然后,当您使用ReleaseLongArrayElements()通知JVM更新数组的内容时,可能会触发另一个8,000字节的拷贝来更新数组。即使您使用较新版本 GetPrimitiveArrayCritical(),规范仍允许JVM复制整个数组。
  GetTypeArrayRegion()和SetTypeArrayRegion() 方法允许您只获取或者更新数组的一部分,而不是整个数组。通过使用这些方法,您可以确保您的应用程序只操作您所需要的部分数据,从而提高执行效率。
  例如,考虑同一份代码的两个不同版本,如代码4所示:
代码4.同一方法的两个版本

jlong getElement(JNIEnv* env, jobject obj, jlongArray arr_j, 
                 int element){
   jboolean isCopy;
   jlong result;
   jlong* buffer_j = (*env)->GetLongArrayElements(env, arr_j, &isCopy);
   result = buffer_j[element];
   (*env)->ReleaseLongArrayElements(env, arr_j, buffer_j, 0);
   return result;
}
 
jlong getElement2(JNIEnv* env, jobject obj, jlongArray arr_j, 
                  int element){
     jlong result;
     (*env)->GetLongArrayRegion(env, arr_j, element,1, &result);
     return result;
}

  第一个版本可能会触发数组拷贝,而第二个版本则完全不会。使用1,000字节的数组运行第一个方法10,000,000次需要12,055 ms; 第二个版本仅需1,421毫秒。前者比后者的8.5倍!
  另外,GetTypeArrayRegion()并不是万能的,通过这个方法获取数组的每一个元素从而拷贝整个数组,如果觉得这是高效的,那您就错了。为获得最佳性能,请确保在最大的敏感块中获取和更新数组元素。如果要遍历数组中的所有元素,代码4中的两个方法都不合适。相反,您应该在一次调用中获得一个合理大小的数组块,然后遍历所有这些元素,直到覆盖整个数组。

返回而不是传递参数

  调用方法时,您通常可以选择传递包含多个字段的单个对象,或者单独传递每一个字段。对于面向对象的设计,传递对象通常提供更好的封装性,因为对象字段中的更改不需要更改方法签名。但是,对于JNI,本地代码必须通过一个或多个JNI调用返回到JVM,以获取所需的每个字段的值。这些调用会增加额外的开销,因为从本机代码到Java代码的转换比普通方法调用效率更低下。我们看一下代码5中的两个方法,第二个方法假设我们已经缓存了字段ID:

代码5.两个方法版本

int sumValues(JNIEnv* env, jobject obj, jint a, jint b,jint c, jint d, jint e, jint f){
   return a + b + c + d + e + f;
}
 
int sumValues2(JNIEnv* env, jobject obj, jobject allValues){
 
   jint avalue = (*env)->GetIntField(env, allValues, a);
   jint bvalue = (*env)->GetIntField(env, allValues, b);
   jint cvalue = (*env)->GetIntField(env, allValues, c);
   jint dvalue = (*env)->GetIntField(env, allValues, d);
   jint evalue = (*env)->GetIntField(env, allValues, e);
   jint fvalue = (*env)->GetIntField(env, allValues, f);
    
   return avalue + bvalue + cvalue + dvalue + evalue + fvalue;
}

  sumValues2()方法需要六次JNI回调,需要3,572 ms才能运行10,000,000次。sumValues仅需596 ms。sumValues2()比sumValues()慢了6倍之多。通过传入JNI方法所需的数据,sumValues()避免了大量的JNI开销。

在本地代码和Java代码之间选择了错误的边界

  由开发人员来判定本地代码和Java代码之间的界限。边界的选择会对应用程序的整体性能产生重大影响。从Java代码调用本地代码,从本机调用Java代码的成本明显高于普通的Java方法调用。此外,转换也可能会影响JVM优化代码执行的能力。例如,随着Java代码和本地代码之间的转换次数的增加,即时编译器的效率可能会变得低下。我们已经测量过,从Java代码到本地的调用可以比常规方法长五倍。同样的,从本地代码到Java代码的调用可能需要相当长的时间。
  因此,Java代码和本地代码之间的分离,应该尽可能的减少Java和本地代码之间的转换。只有在需要时才进行转换,并且您应该在本地代码中做足够的工作以分摊转换成本。最小化转换的关键是确保数据保持在Java/本地边界的正确一侧。如果数据位于错误的一侧,则需要另一方来获取这些数据,从而触发恒定的数据转换,最终导致效率低下。例如,如果我们想使用JNI为串口提供接口,我们可以提供两种不同的接口。代码6中有一个版本:
代码6.串口的接口:版本1

/**
 * Initializes the serial port and returns a java SerialPortConfig objects
 * that contains the hardware address for the serial port, and holds
 * information needed by the serial port such as the next buffer 
 * to write data into
 * 
 * @param env JNI env that can be used by the method
 * @param comPortName the name of the serial port
 * @returns SerialPortConfig object to be passed ot setSerialPortBit 
 *          and getSerialPortBit calls
 */
jobject initializeSerialPort(JNIEnv* env, jobject obj,  jstring comPortName);
 
/**
 * Sets a single bit in an 8 bit byte to be sent by the serial port
 *
 * @param env JNI env that can be used by the method
 * @param serialPortConfig object returned by initializeSerialPort
 * @param whichBit value from 1-8 indicating which bit to set
 * @param bitValue 0th bit contains bit value to be set 
 */
void setSerialPortBit(JNIEnv* env, jobject obj, jobject serialPortConfig, 
  jint whichBit,  jint bitValue);
 
/**
 * Gets a single bit in an 8 bit byte read from the serial port
 *
 * @param env JNI env that can be used by the method
 * @param serialPortConfig object returned by initializeSerialPort
 * @param whichBit value from 1-8 indicating which bit to read
 * @returns the bit read in the 0th bit of the jint 
 */
jint getSerialPortBit(JNIEnv* env, jobject obj, jobject serialPortConfig, 
  jint whichBit);
 
/**
 * Read the next byte from the serial port
 * 
 * @param env JNI env that can be used by the method
 */
void readNextByte(JNIEnv* env, jobject obj);
 
/**
 * Send the next byte
 *
 * @param env JNI env that can be used by the method
 */
void sendNextByte(JNIEnv* env, jobject obj);

  在代码6中,串行端口的所有配置数据都存储在initializeSerialPort()方法返回的Java对象中 ,Java代码完全控制在硬件中设置的每个单独的位。代码6的版本中的几个问题将导致比代码7中的版本更差的性能:

代码7.串口的接口:版本2

/**
 * Initializes the serial port and returns an opaque handle to a native
 * structure that contains the hardware address for the serial port 
 * and holds information needed by the serial port such as 
 * the next buffer to write data into
 *
 * @param env JNI env that can be used by the method
 * @param comPortName the name of the serial port
 * @returns opaque handle to be passed to setSerialPortByte and 
 *          getSerialPortByte calls 
 */
jlong initializeSerialPort2(JNIEnv* env, jobject obj, jstring comPortName);
 
/**
 * sends a byte on the serial port
 * 
 * @param env JNI env that can be used by the method
 * @param serialPortConfig opaque handle for the serial port
 * @param byte the byte to be sent
 */
void sendSerialPortByte(JNIEnv* env, jobject obj, jlong serialPortConfig, 
    jbyte byte);
 
/**
 * Reads the next byte from the serial port
 * 
 * @param env JNI env that can be used by the method
 * @param serialPortConfig opaque handle for the serial port
 * @returns the byte read from the serial port
 */
jbyte readSerialPortByte(JNIEnv* env, jobject obj,  jlong serialPortConfig);

  最明显的问题是代码6中的接口对每个位的获取或设置、以及从串行端口读取字节或向串口写入字节都将触发JNI接口调用。这导致每个字节读取或写入的JNI调用次数成倍增加。第二个问题是代码6将串行端口的配置信息存储在Java对象中,该Java对象位于使用数据的Java/本地代码边界的错误一侧,我们只有本地代码需要这些配置数据。将它存储在Java端将导致从本地到Java的多次回调才能设置/获取这些配置信息。代码7将配置信息存储在本机结构(例如,C struct)中,这意味着当本地代码运行时,它可以直接通过结构体获取配置数据,而无需通过JNI接口回调Java代码以获取这些配置信息。因此,使用代码7版本的实现将会更加高效。

使用许多本地引用而不通知JVM

  为JNI函数返回的任何对象创建本地引用。例如,在调用时GetObjectArrayElement()时,将会返回一个数组对象的本地引用。我们看看代码8中的代码在非常大的数组上运行时,可能会使用多少本地引用:

代码8.创建本地引用

void workOnArray(JNIEnv* env, jobject obj, jarray array){
   jint i;
   jint count = (*env)->GetArrayLength(env, array);
   for (i=0; i < count; i++) {
      jobject element = (*env)->GetObjectArrayElement(env, array, i);
      if((*env)->ExceptionOccurred(env)) {
         break;
      }
       
      /* do something with array element */
   }
}

  每次GetObjectArrayElement()调用时,都会为元素创建一个本地引用,并且在本地代码执行完成之前不会释放该引用。数组越大,创建的本地引用就越多。
  本地代码执行完成时,将自动释放这些本地引用。JNI规范要求每个本机能够创建至少16个本地引用。虽然这对于许多方法来说已经足够,但是某些方法可能需要在其生命周期内访问更多的数据。在这种情况下,您应该删除不再需要的引用,调用JNI方法DeleteLocalRef()、或者通知JVM您将使用大量本地引用。
  代码9在代码8的基础上增加了DeleteLocalRef()的调用,通知JVM不再需要本地引用,可以将一次存在的本地引用的数量限制为合理的数量,不管数组有多大。

代码9.添加 DeleteLocalRef()

void workOnArray(JNIEnv* env, jobject obj, jarray array){
  jint i;
  jint count = (*env)->GetArrayLength(env, array);
  for (i=0; i < count; i++) {
     jobject element = (*env)->GetObjectArrayElement(env, array, i);
     if((*env)->ExceptionOccurred(env)) {
        break;
     }
      
     /* do something with array element */

     (*env)->DeleteLocalRef(env, element);
  }
}

  您还可以调用JNI方法EnsureLocalCapacity()告诉JVM您将使用超过16个本地引用。这允许JVM优化对本机的本地引用的处理。如果无法创建所需的本地引用,或者由于JVM使用的本地引用管理与所使用的本地引用数不匹配而导致性能不佳,则会导致FatalError,从而无法正确通知JVM 。

JNI代码使用错误

  JNI的五大使用错误:

  • 错用 JNIEnv
  • 不检查异常
  • 不检查返回值
  • 错误地使用数组方法
  • 错误地使用全局引用

错用JNIEnv

  子线程执行本地代码,尝试通过JNIEnv调用JNI方法,但JNIEnv不仅仅是用来调用JNI方法。JNI规范声明每个JNIEnv都必须是线程所拥有的。JVM可以根据此规范,在其中存储包含JNIEnv的其他线程的本地信息。在一个线程中使用来自另一个线程的JNIEnv实例可能会导致意料之外的错误和崩溃。
  一个线程可以通过调用GetEnv() 得到一个属于自己的JNIEnv实例。如果要调用GetEnv() 方法,则必须有JavaVM实例,这个实例可以通过GetJavaVM()方法来获取,此实例是被高速缓存并跨线程共享的。因此,通过缓存JavaVM对象的副本, 任何有权访问缓存对象的线程都可以在必要时访问自己的JNIEnv对象。

不检查异常

  本地可以调用的许多JNI方法可能会在执行线程上引发异常。当Java代码执行时,这些异常会导致执行流程发生改变,从而自动调用异常处理代码。当本地调用JNI方法时,可能引发异常,这时候需要本地去检查异常,并采取适当的操作。常见的JNI编程错误是调用了JNI方法而不检查异常,并忽略异常继续执行。这可能导致严重的错误和崩溃。
  例如,如果找不到请求的字段,则GetFieldID()会抛出NoSuchFieldError异常。如果本地代码在没有检查异常的情况下继续进行并使用它认为正确的字段ID,就可能发生崩溃。例如,代码10中的代码,如果修改了Java类中的charField字段可能会导致崩溃,而不是抛出 NoSuchFieldError异常:

代码10.未检查异常

jclass objectClass;
jfieldID fieldID;
jchar result = 0;
 
objectClass = (*env)->GetObjectClass(env, obj);
fieldID = (*env)->GetFieldID(env, objectClass, "charField", "C");
result = (*env)->GetCharField(env, obj, fieldID);

  包含检查异常的代码比引起崩溃再尝试调试要容易得多。通常,您可以简单地检查是否发生了异常,如果是,则立即返回到Java代码,以便抛出异常。然后使用普通的Java异常处理过程处理或显示它。例如,代码11检查异常:

代码11.检查异常

jclass objectClass;
jfieldID fieldID;
jchar result = 0;
 
objectClass = (*env)->GetObjectClass(env, obj);
fieldID = (*env)->GetFieldID(env, objectClass, "charField", "C");
if((*env)->ExceptionOccurred(env)) {
   return;
}
result = (*env)->GetCharField(env, obj, fieldID);

  不检查和清除异常可能会导致意外行为。你能发现这段代码有什么问题吗?

fieldID = (*env)->GetFieldID(env, objectClass, "charField", "C");
if (fieldID == NULL){
   fieldID = (*env)->GetFieldID(env, objectClass,"charField", "D");
}
return (*env)->GetIntField(env, obj, fieldID);

  问题是即使代码处理了GetFieldID()不返回字段ID的情况,它也不会清除此调产生的异常。因此,从本地代码返回将会立即抛出该异常!

不检查返回值

  许多JNI方法都有一个返回值,指示调用是否成功。类似于不检查异常的常见代码错误,不检查返回值并且在假设调用成功的情况下继续进行,也很常见。对于大多数JNI方法,正确的设置返回值和异常状态,以确保应用程序知道方法是否运行正确。你能发现以下代码有什么问题吗?

clazz = (*env)->FindClass(env, "com/ibm/j9//HelloWorld");
method = (*env)->GetStaticMethodID(env, clazz, "main",
                   "([Ljava/lang/String;)V");
(*env)->CallStaticVoidMethod(env, clazz, method, NULL);

  如果HelloWorld找不到类或者main()方法不存在,将导致本地崩溃。

错误地使用数组方法

  GetXXXArrayElements()和ReleaseXXXArrayElements()方法允许您获取数组的本地句柄。类似的,GetPrimitiveArrayCritical()、ReleasePrimitiveArrayCritical()、GetStringCritical()和ReleaseStringCritical()允许您获取数组元素或字符串字节,它们将获得直接指向数组或字符串的指针,以最大限度地提高的可用性。使用这些方法有两个常见的错误,第一种是忘记在操作完成后调用ReleaseXXX()方法进行提交更改。这些方法无法保证您一定能够获得数组或字符串对应的指针,因为在某些JVM版本中,可能总是返回一个副本。在这些JVM中,如果您忘记调用ReleaseXXX()或者调用该方法出错,您对数组或字符串的更改将不会被应用到内存当中。

例如:

void modifyArrayWithoutRelease(JNIEnv* env, jobject obj, jarray arr1) {
   jboolean isCopy;
   jbyte* buffer = (*env)-> (*env)->GetByteArrayElements(env,arr1,&isCopy);
   if ((*env)->ExceptionCheck(env)) return; 
    
   buffer[0] = 1;
}

  在提供指向数组的直接指针的JVM上,数组将会被更新; 但是,在返回副本的JVM上,它不会。这可能会导致您的代码似乎在某些JVM上能够运行良好,但在某些JVM上却不行。您应该始终调用ReleaseXXX()方法,如代码12所示:

代码12.包括ReleaseXXX()调用

void modifyArrayWithRelease(JNIEnv* env, jobject obj, jarray arr1) {
   jboolean isCopy;
   jbyte* buffer = (*env)-> (*env)->GetByteArrayElements(env,arr1,&isCopy);
   if ((*env)->ExceptionCheck(env)) return; 
    
   buffer[0] = 1;
 
   (*env)->ReleaseByteArrayElements(env, arr1, buffer, JNI_COMMIT);
   if ((*env)->ExceptionCheck(env)) return;
}

  第二种错误,不遵守规范在GetXXXCritical()和ReleaseXXXCritical()之间施加的限制 。本地可能不会在这些方法之间进行任何JNI调用,所以可能不会出现阻塞的问题。但是如果不遵守这些限制,则可能会导致应用程序或整个JVM出现间歇性死锁。

例如,以下代码可能看起来没问题:

void workOnPrimitiveArray(JNIEnv* env, jobject obj, jarray arr1) {
   jboolean isCopy;
   jbyte* buffer = (*env)->GetPrimitiveArrayCritical(env, arr1, &isCopy); 
   if ((*env)->ExceptionCheck(env)) return; 
    
   processBufferHelper(buffer);
    
   (*env)->ReleasePrimitiveArrayCritical(env, arr1, buffer, 0); 
   if ((*env)->ExceptionCheck(env)) return;
}

  但是,我们需要检查processBufferHelper()方法所有可能被调用到的代码都不违反任何限制。这些限制包括在Get和Release调用之间执行的所有代码,无论它是否是本地代码的一部分。

错误地使用全局引用

  本地代码可以创建全局引用,以便在不再需要对象之前不会对其进行垃圾回收。常见的问题是忘记删除已创建的全局引用或完全丢失它们的引用。我们来看一个例子,创建了全局引用,但却没有在任何地方删除或存储它:

lostGlobalRef(JNIEnv* env, jobject obj, jobject keepObj) {
   jobject gref = (*env)->NewGlobalRef(env, keepObj);
}

  创建全局引用时,JVM的垃圾回收器将会把它排除。当从本地方法返回时,它不仅没有被释放,而且应用程序再也没有办法获取它的引用以便以后释放它,因此该对象将永远存在。不释放全局引用会导致问题,不仅因为它本身无法被回收,还因此导致它引用的所有对象都不会被回收。在某些情况下,这可能会导致严重的内存泄漏。

本文由作者翻译自Best practices for using the Java Native Interface,引用该译文请注明出处。


欢迎关注微信公众,第一时间获取一手多媒体技术资讯

推荐阅读更多精彩内容