JNI(3)--JNI函数使用(II)

1,059 阅读13分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第5天,点击查看活动详情

JNI系列文章导引

JNI(1)---JNI入门介绍

JNI(2)--JNI函数使用(I)

JNI(3)--JNI函数使用(II)

JNI(4)--JNI中的api说明

JNI函数使用

全局引用和局部引用

JNI支持三种引用:局部引用、全局引用和全局弱引用(弱引用)

局部引用和全局引用局具有不同的生命周期。当本地方法返回时,局部引用会自动释放。而全局引用和弱引用必须手动释放

局部引用和全局引用会组织GC回收它们所引用的对象,而弱引用不会

不是所有的引用可以被用于所有的场合,一个本地方法中创建一个局部引用并返回后,再对这个局部引用进行访问是非法的

局部引用

JNI函数中创建的引用就是局部引用。局部引用只在本地方法中有效,本地方法返回后,局部引用会被自动释放。

注意

1. 不能把局部引用储存在static变量中缓存起来供下次使用。变量ID,方法ID那些不属于局部对象引用。

2. 局部引用只在创建它们的线程中有效,不要在一个线程中创建局部引用并存储到全局引用中,然后到另外一个线程去使用。

如本地方法中

 //在本地方法中缓存局部引用不合法
 static jclass stringCls;
 if(stringCls == NULL){
   stringCls = (*env)->FindClass(env,"java/lang/String");
 }
 ...

本地方法返回后,局部变量会被释放,再次访问无效的局部引用,会导致非法内存访问甚至崩溃。

  • 释放一个局部引用有两种方式:

    1. 本地方法执行完毕后 VM 自动释放
    2. 通过 DeleteLocalRef 手动释放

    既然 VM 会自动释放局部引用,为什么还需要手动释放呢?因为局部引用会阻止它所引用的对象被 GC 回收。

    但是也会存在方法体中创建局部变量临时使用的情况,在使用完毕后应该立刻手动释放。不然可能会导致JNI局部引用表溢出。例如在for循环中创建局部变量,当对这个元素遍历完成或,就手动释放它。

  • 本地方法中返回局部引用

    局部变量在本地方法返回后失效,那怎么才能返回一个局部引用呢?答案是使用NewLocalRef

    void f(JNIEnv *env,...){
      ...    
        //返回局部引用,使用NewLocalRef
        jstring jstr = MyNewString1(env, "hahha", strlen("hahha"));
      return (*env)->NewLocalRef(env, jstr);
    }
    
  • 管理局部引用

    管理局部引用的函数:EnsureLocalCapacity,PushLocalFrame,PopLocalFrameNewLocalRef

    JNI规范中,VM会确保每个本地方法创建至少16个局部引用,这个数量可以满足大部分的方法。如果真的需要创建更多的引用,本地方法可以调用EnsureLocalCapacity来支持更多的局部引用,但是不能保证分配成功,如果失败,VM会退出。PushLocalFrame为一定数量的局部引用创建了一个使用堆栈,而PopLocalFrame负责销毁堆栈栈顶的引用

    //在for循环中可以使用的最大局部引用数量
    jint N_REFS = 10;
    for (int i = 0; i < 10; ++i) {
      if ((*env)->PushLocalFrame(env, N_REFS) < 0) {
        //OOM
      }
      //create local object
      //....
    
      //销毁局部变量
      (*env)->PopLocalFrame(env, NULL);
    }
    

全局引用

全局引用可以跨方法、跨线程使用,直到它被手动释放才会失效。全局引用也会阻止所引用对象被GC回收。

全局引用只能使用NewGlobalRef创建。如果不手动释放,即使不使用了,VM也不会回收这个所引用的对象。

JNIEXPORT void JNICALL
Java_com_example_jnifirst_ObjectReference_globalRefNative(JNIEnv *env, jobject thiz) {
  static jstring stringClass;

  //创建全局引用过程
  if (stringClass == NULL) {
    //1. 创建局部引用
    jclass jstrLocalRef = (*env)->FindClass(env, "java/lang/String");
    if (jstrLocalRef == NULL) {
      return;
    }
    //2. 将局部引用保存在全局引用中
    stringClass = (*env)->NewGlobalRef(env, jstrLocalRef);
    //3. 局部引用不再使用,可以销毁
    (*env)->DeleteLocalRef(env, jstrLocalRef);
    //4. 判断全局引用是否创建成功
    if (stringClass == NULL) {
      return;
    }
  }
  //......
}

弱全局引用

弱引用使用NewGlobalWeakRef创建,使用DleteGlobalWeakRef释放。弱引用可以跨方法、跨线程使用;但是弱引用不会阻止GC回收所指向的对象。如果不手动释放,即使不使用了,VM会回收这个所引用的对象,但是弱引用本身在引用表中所占的内存永远不会被回收。

当本地代码中缓存的引用不一定要阻止GC回收它所指向的对象是,弱引用就是一个很好的选择。

JNIEXPORT void JNICALL
Java_com_example_jnifirst_ObjectReference_globalWeakRefNative(JNIEnv *env, jobject thiz) {
  static jstring stringClass;

  //创建全局弱引用过程,判断弱引用是否被回收;使用obj == NULL或者IsSameObject(env,obj1,NULL)==JNI_TRUE均可
  //若是判断对象相等,需要使用IsSameObject(env,obj1,obj2)
  if (stringClass == NULL) {
    //1. 创建局部引用
    jclass jstrLocalRef = (*env)->FindClass(env, "java/lang/String");
    if (jstrLocalRef == NULL) {
      return;
    }
    //2. 将局部引用保存在全局弱引用中
    stringClass = (*env)->NewWeakGlobalRef(env, jstrLocalRef);
    //3. 局部引用不再使用,可以销毁
    (*env)->DeleteLocalRef(env, jstrLocalRef);
    //4. 判断全局弱引用是否创建成功
    if (stringClass == NULL) {
      return;
    }
  }
  //....
}

弱引用必须检查缓存过的弱引用指向的活动对象是否活动。

引用比较

两个引用,可以使用IsSameObject来判断是否指向相同的对象,相同会返回JNI_TRUE,否则返回JNI_FALSE

如果是引用NULL,可以使用(*env)->IsSameObject(env,obj,NULL)或者obj == NULL检查obj是否为NULL。

若使用IsSameObject判断弱引用,如果弱引用指向的对象已经被回收就会返回JNI_TRUE,如果指向的对象任然活动,就会返回JNI_FALSE

异常

很多情况下,本地代码左JNI调用或都要检查是否有错误发生。

处理异常

例如,在Java方法中定义一个方法供JNI中调用,但是这个方法可能会抛出异常,下面展示了怎么检查异常,打印异常,清除异常,以及抛出一个新的异常

fun catchTrowInJni() {
  try {
    throwInJni()
  } catch (e: IllegalArgumentException) {
    e.printStackTrace()
  }
}

//在java中抛出异常,jni中调用java方法
private fun callbackThrow() {
  throw NullPointerException("java throw exception")
}

/**
  *
  * @throws IllegalArgumentException
  */
private external fun throwInJni()
JNIEXPORT void JNICALL
Java_com_example_jnifirst_CatchThrow_throwInJni(JNIEnv *env, jobject thiz) {
  //jni中检查java方法中的异常;并抛出新的异常,处理异常

  //调用java方法,并检查异常
  jclass cls = (*env)->GetObjectClass(env, thiz);
  jmethodID mid = (*env)->GetMethodID(env, cls, "callbackThrow", "()V");
  if (mid == NULL) {
    return;
  }
  (*env)->CallVoidMethod(env, thiz, mid);
  //检查异常,ExceptionOccurred获取异常的指针
  jthrowable exc = (*env)->ExceptionOccurred(env);
  if (exc) {
    //发生异常,打印异常信息,clear异常,这样相当于异常被抓住
    (*env)->ExceptionDescribe(env);
    (*env)->ExceptionClear(env);

    //抛出新的异常,出现异常后若不做clear,就做资源回收操作,不应该继续进行后续操作
    jclass illegalExceptionCls = (*env)->FindClass(env, "java/lang/IllegalArgumentException");
    (*env)->ThrowNew(env, illegalExceptionCls, "throw exception from C");
  }
}

本地方法中使用ExceptionOccurred检查异常的发生,当异常被检测到时,使用ExceptionDescribe输出异常信息,并通过调用ExceptionClear清除异常信息,最后使用ThrowNew抛出一个新的异常。

和Java中的异常机制不一样,JNI抛出的异常不处理的话,不会立刻终止本地方法的执行。异常发生后,必须手动处理。

  • 制作抛出异常工具方法

    抛出异常需要两步:通过FindClass找到异常类,之后调用ThrowNew抛出异常。简化这个过程,设计一个工具方法

    void JUN_ThrowByName(JNIEnv *env, const char *name, const char *msg) {
        jclass cls = (*env)->FindClass(env, name);
        if (cls == NULL) {
            return;
        }
        (*env)->ThrowNew(env, cls, msg);
        (*env)->DeleteLocalRef(env, cls);
    }
    
  • 检查异常的方法

    1. 大部分的JNI函数会通过特定的返回值来表示已经发生了一个错误,并且当前线程中有一个异常需要处理,这在C语言中很常见。

    2. 使用ExceptionOccurred或者ExceptionCheck检查异常

      两则的区别:

      ExceptionOccurred会返回异常的引用,后续可以使用ExceptionDescribeExceptionClear等处理异常。若没有发生异常,会返回NULL

      ExceptionCheck如果已经发生了异常,则返回JNI_TRUE,否则返回JNI_FALSE

  • 异常处理

    1. 异常发生后立刻返回,让调用者处理这个异常
    2. 通过ExceptionClear清除异常,然后执行自己的异常处理代码。一旦发生,必须先检查、处理、清除异常后再做其他JNI函数调用,不然结果未知。当异常发生时,释放资源是很重要的事情。

JNI和线程

JNI可以在相同的地址空间内执行多个线程。多线程可以共享资源,增加了程序的复杂性。

  • 约束限制

    1. JNIEnv指针只在它所在的线程中有效,不能跨线程传递和使用。不同线程调用一个本地方法时,传入的JNIEnv指针是不同的
    2. 局部引用只在创建它们的线程中有效,不能跨线程传递,但是可以转化成全局引用供多线程使用
  • 实现

    使用MonitorEnterMonitorExit实现进入同步块和退出同步块,功能类似Java中的synchorized代码块。

    jint result = (*env)->MonitorEnter(env, obj);
    if (result != JNI_OK) {
      //进入失败,处理错误
    }
    //同步代码块
    ...
    result = (*env)->MonitorExit(env, obj);
    if (result != JNI_OK) {
      //退出失败,处理错误
    }
    

    程序必须先进入obj的监视器,再执行同步代码块中的代码。一个线程A进入obj的监视器后,另一个线程B想要进入,此时线程B就会阻塞等待,只有当线程A退出后线程B才能继续执行。

    如果当前线程不拥有监视器的情况下调用MonitorExit,会抛出IllegalMonitorStateException。同时MonitorEnterMonitorExit的调用可能出现调用错误,必须检查异常。

    只调用MonitorEnter而不调用MonitorExit很可能会引起死锁。

    总的来说,对比Java中的实现,JNI中的处理要复杂的多,尽量用Java来做同步,将同步相关的代码放在Java中。

  • 监视器的等待和唤醒

    JNI中没有提供对等Java线程等待唤醒的函数,只能调用Java中的方法

    //线程wait和notify jni没有提供方法,只能自行调用java中的
    static jmethodID MID_Object_wait;
    static jmethodID MID_Object_notify;
    static jmethodID MID_Object_notifyAll;
    
    void MonitorWait(JNIEnv *env, jobject obj, jlong timeout) {
      (*env)->CallVoidMethod(env, obj, MID_Object_wait, timeout);
    }
    
    void MonitorNotify(JNIEnv *env, jobject obj) {
      (*env)->CallVoidMethod(env, obj, MID_Object_notify);
    }
    
    void MonitorNotifyAll(JNIEnv *env, jobject obj) {
      (*env)->CallVoidMethod(env, obj, MID_Object_notifyAll);
    }
    

获取JNIEnv指针

JNEnv 指针只在当前线程中有效。那么有没有办法可以从本地代码的任意地方获取到 JNIEnv 指针呢?比如,一个操作系统的回调函数中,本地代码是无法通过传参的方式获取到 JNIENnv 指针的

可以通过调用JNIInvokeInterface中的 AttachcurrentThread 方法来获取到当前线程中的 JNIEnv指针:

JavaVM *jvm;
void f(){
  JNIEnv *env;
  (*jvm)->AttachCurrentThread(jvm,(void **)&env,NULL);
  ...
}

一旦当前线程被附加到JVM上,AttachCurrentThread就会返回一个属于当前线程的JNIEnv指针。

可以使用JNIInvokeInterface中的GetEnv检查当前线程是否有附加到JVM上,然后返回属于当前线程中的 JNIEnv指针。一旦每附加上去,那么AttachcurrentThreadGetEnv是等价的。

获取JavaVM指针

获取JNIEnv指针需要JavaVM指针,那怎么获取呢?

  1. 在VM创建的时候记录下来
  2. 通过JNI_GetCreatedJavaVMs查询被创建的JVM
  3. JNIEnv中的GetJavaVM或者JNI_OnLoad时获取,JavaVM只要被缓存在全局引用中,是可以被跨线程使用的

总结JNI中容易出错的地方

  • 检查错误

    很容易忘记,但是很重要

  • 向JNI函数传递非法参数

    JNI不会检查参数是否正确,自己不保证参数正确,可能会出现未知的结果或错误。通常,不检查参数的有效性在C/C++库中比较常见

  • jclassjobject弄混

    jclss是类引用,jobject是对象引用。

    比如获取ID需要传入jclass作为参数,这是要从一个类中获取字段的描述,但是在Get<Type>Field获取参数的值时,传入的是jobject,这是要从一个对象实例中获取字段的值。

  • jboolean会有数据截取问题

    jboolean 是一个 8-bit unsigned 的 C 类型,可以存储 0255 的值。其中,0 对应常量 JNI_FALSE,而 1255 对应常量 JNI_TRUE。但是,32 或者 16 位的值,如果最低的 8 位是 0 的话,就会引起问题。

    比如print(256)会打印false。256是0x100,低8位都是0。解决方法就是n = 256; print(n ? JNI_TRUE : JNI_FALSE)

  • Java和C的选择

    1. 尽量让Java和C之间接口简单化,过于复杂难以维护,优化也难
    2. 尽量少些本地代码,本地代码不安全,不可移植,错误检查还麻烦
    3. 让本地方法尽量独立,本地方法尽量都在同一个包或者同一个类中
  • 混淆ID和引用

    本地方法中需要使用ID访问Java中的字段和方法。

    引用指向的是可以由本地代码管理的JVM中的资源;而ID是由JVM管理的,只由属于的类被unload时才会失效,本地代码不能显示的删除一个ID。

    本地代码可以创建多个引用并让它们指向相同的对象。比如,一个全局引用和一个局部引用可能指向相同的对象。而字段 ID 和方法 ID 是唯一的。比如类 A 定义了一个方法 f,而类 B 从类 A 中继承了方法 f,那么下面的调用结果是相同的:

    jmethodID mid_a_f = (*env)->GetMethodID(env,A,"f",()V);
    jmethodID mid_b_f = (*env)->GetMethodID(env,B,"f",()V);
    
  • 缓存字段ID和方法ID

    open class C {
      private  var i:Int=0
    
      external fun f()
    }
    
    JNIEXPORT void JNICALL
    Java_com_example_jnifirst_extendstrouble_C_f(JNIEnv *env, jobject thiz) {
      jclass cls = (*env)->FindClass(env, thiz);
      jfieldID fid_C_i = (*env)->GetFieldID(env, cls, "i", "I");
      if(fid_C_i == NULL){
        return;
      }
      jint C_i = (*env)->GetIntField(env, thiz, fid_C_i);
      //...
    }
    

    此时一切正常,获取的都是C中的变量。但是,如果D继承自C,也定义一个私有的字段i

    /**
     * 继承C,定义相同的i变量,在构造函数中调用继承自C的f方法
     */
    class D: C() {
      private val i:Int = 1
    
      init {
        //此时调用f,f中的FindClass指向的是D方法,那么读取到的i字段是D类中定义的i的值
        f()
      }
    }
    

    此时的JNI方法f被D调用时,FindClass指向的是D,那么读取到的i字段是D类中定义的i的值

    解决方案是在JNI被初始化是,把C的字段i的ID缓存起来,那么后面再使用的时候,也是C中i的ID,因此不管本地方法中接收的jobject是哪个类的实例,访问的永远都是C的i。

  • 访问权限

    本地代码中方法Java代码不受Java语言规定的限制,比如修改private和final修饰的字段。并且,JNI中可以访问和修改heap中任意位置的内存,这会造成意想不到的结果。因此对于Java中不可修改的类,也最好不要破坏。

  • 确保资源释放

    特别是在发生错误时,一定要首先释放资源

  • 使用已经失效的局部引用

    不应该把局部变量缓存在static中,局部引用只能在方法调用期间使用。

  • 跨进程使用JNIEnv指针

    JNIEnv指针只能在当前线程使用,不要跨线程使用