← 返回蜂巢洞察

如何通过正确管理局部引用和全局引用来避免JNI导致的程序崩溃

大多数JNI崩溃并不是由复杂的逻辑错误引起的,而是由于在对象引用方面存在一些小问题造成的:例如,在引用已经失效后仍然继续持有它;创建引用的速度快于释放它们的速度;或者将某个引用传递给根本不应该使用它的线程。 这些错误很难被发现,因为程序崩溃通常发生在与引发问题的那行代码相距很远的地方,有时甚至是在垃圾回收器运行几分钟后才发生的。 Java本地接口为C和C++代码提供了一种调用JVM并操作Java对象的方法。本地代码永远不会获得对Java对象的原始指针,而是会得到一个引用,而每种类型的引用都有其自身的规则,规定它有效的时间长度、哪些线程可以使用它,以及谁负责释放它。一旦这些规则被明确了解,大多数

大多数JNI崩溃并不是由复杂的逻辑错误引起的,而是由于在对象引用方面存在一些小问题造成的:例如,在引用已经失效后仍然继续持有它;创建引用的速度快于释放它们的速度;或者将某个引用传递给根本不应该使用它的线程。

这些错误很难被发现,因为程序崩溃通常发生在与引发问题的那行代码相距很远的地方,有时甚至是在垃圾回收器运行几分钟后才发生的。

Java本地接口为C和C++代码提供了一种调用JVM并操作Java对象的方法。本地代码永远不会获得对Java对象的原始指针,而是会得到一个引用,而每种类型的引用都有其自身的规则,规定它有效的时间长度、哪些线程可以使用它,以及谁负责释放它。一旦这些规则被明确了解,大多数JNI崩溃问题就很容易被预防和诊断了。

本文详细解释了局部引用、全局引用以及弱全局引用的工作原理,列举了在使用这些引用时最常出现的错误,并提供了相应的修复方法。最后还介绍了一些工具和模式(如CheckJNI和RAII封装机制),这些工具和模式可以帮助在问题进入生产环境之前就发现它们。

虽然示例中使用了C++和Android的相关约定,但引用的规则实际上是基于JNI规范制定的,因此适用于任何JVM环境。

目录

先决条件

您应该能够熟练阅读C++和Java代码,并且至少曾经编写过或了解过基本的JNI函数(即那些用extern "C" JNIEXPORT声明的、可以从Java的native方法中调用的函数)。

如果熟悉Android NDK,会对理解与Android相关的部分有所帮助,但掌握它并不是理解JNI核心概念的必要条件。

JNI引用的工作原理

Java对象存储在托管堆中,垃圾收集器在压缩内存时可以随时移动这些对象。

如果本地代码持有对该对象的原始指针,那么在下一次内存压缩后,该指针就会失效。而JNI通过为本地代码提供一种“不透明”的引用方式来避免这种情况。jobject、jclass、jstring和jobjectArray这类类型都属于这种引用方式。

  本地代码                        JVM引用表                    托管堆
 +-------------+                            +----------------------+         +------------+
 | jobject h     | --------> | slot 17 : 指针        | ------>|  用户对象    |
 +-------------+                            +----------------------+         +------------+
                              (当对象位置发生变化时,垃圾收集器会更新该指针)

上图展示了使JNI引用变得安全的间接引用机制。本地代码持有的是一个引用句柄h,而这个句柄指向JVM拥有的引用表中的一个槽位;该槽位中存储着对象在托管堆上的实际地址。

当垃圾收集器移动对象的位置时,它会更新引用表中的指针值,而本地代码中的引用句柄仍然有效。此外,这个槽位还充当了垃圾收集器的“根节点”——只要这个槽位还存在,其所引用的对象就不会被回收。

关于引用的所有规则归根结底都可以归结为一个问题:这个槽位到底在什么情况下会被释放?

JNI有三种类型的引用,它们的区别就在于槽位的释放时机不同:局部引用的槽位会在本地方法返回时自动被释放;全局引用的槽位会一直存在,直到本地代码明确将其删除;弱全局引用的槽位也会一直存在,但它们不会使对象保持存活状态,因此所引用的对象随时都可能被回收。

局部引用

几乎所有返回Java对象的JNI函数都会返回局部引用。FindClass、NewStringUTF、GetObjectArrayElement、CallObjectMethod和GetObjectClass等函数都是如此。传递给本地方法的参数(包括用于静态方法的thiz或jclass)也属于局部引用。

局部引用仅在其创建所在的本地方法调用范围内有效,且仅在创建它的线程中有效。当本地方法返回到Java环境时,JVM会一次性释放在该次调用过程中创建的所有局部引用。对于那些长度较短的函数来说,这种机制非常方便;也正是因此,大多数JNI代码根本不会调用DeleteLocalRef这个方法。

这种便利性也是有限度的。本地引用表在每次调用时都只能容纳固定数量的引用。JNI规范最多只允许存储16个本地引用,如果需要存储更多引用,就必须调用EnsureLocalCapacity函数。

实际上,JVM提供的容量要远大于这个限制。旧版本的Android系统中,该表的容量上限为512个条目,一旦超过这个限度,程序就会因为“本地引用表溢出(最大容量为512)”而终止运行。较新版本的ART系统大大提高了这一限制,但任何在无限循环中不断创建本地引用的代码,最终仍然会遇到容量不足的问题。

误区1:在循环中导致本地引用泄漏

最常见的本地引用相关错误,就是那些在每次迭代时都会创建新的引用却从不释放它们的循环代码。

extern "C" JNIEXPORT void JNICALL
Java_com_example_NameProcessor_processNames(JNIEnv* env, jobject thiz,
                                            jobjectArray names) {
  jsize count = env->GetArrayLength(names);
  for (jsize i = 0; i < count; i++) {
    jstring name = static_cast(env->GetObjectArrayElement(names, i));
    const char* utf = env->GetStringUTFChars(name, nullptr);
    if (utf == nullptr) return;
    LogName(utf);
    env->ReleaseStringUTFChars(name, utf);
  }
}

这段代码会遍历一个Java数组,并记录其中的每个元素。每次调用GetObjectArrayElement时,都会创建一个新的本地引用并将其存储在引用表中。

虽然这段代码会使用ReleaseStringUTFChars正确地释放UTF-8缓冲区,但这样做只能释放字符数据本身,并不能清除对jstring》对象的引用。

当处理的数据量较少时(比如10个名称),这种写法不会出现问题;但当数据量非常大时(比如100,000个名称),引用表就会很快被填满,从而导致程序崩溃。由于单元测试通常会针对较小的数据集进行测试,因此这种错误很容易被忽略。

直接的解决方法是在不再需要某个引用时立即将其删除。

for (jsize i = 0; i < count; i++) {
  jstring name = static_cast(env->GetObjectArrayElement(names, i));
  const char* utf = env->GetStringUTFChars(name, nullptr);
  if (utf == nullptr) {
    env->DeleteLocalRef(name);
    return;
  }
  LogName(utf);
  env->ReleaseStringUTFChars(name, utf);
  env->DeleteLocalRef(name);
}

现在,这个循环在每次迭代结束后以及提前返回时都会调用DeleteLocalRef函数,因此引用表中任何时候都不会同时保存来自这个循环的多个引用。

提前返回这一点非常重要:GetStringUTFChars在无法分配内存时会返回nullptr,在这种情况下,系统也会抛出OutOfMemoryError异常,因此立即返回到Java环境才是正确的处理方式。

当一次迭代会产生多个引用时(比如一个类对象、一个方法返回值或一个字符串),手动删除每一个引用会非常繁琐且容易出错。PushLocalFrame和PopLocalFrame这两个函数可以用来解决这类问题。

for (jsize i = 0; i < count; i++) {
  if (env->PushLocalFrame(8) != 0) return;
  jobject user = env->GetObjectArrayElement(users, i);
  jstring name = static_cast(env->CallObjectMethod(user, gGetName));
  jstring email = static_cast(env->CallObjectMethod(user, gGetEmail));
  SaveUser(env, name, email);
  env->PopLocalFrame(nullptr);
}

PushLocalFrame(8)会在本地引用表中创建一个新的作用域,该作用域至少可以容纳8个引用。此后创建的所有本地引用都属于这个新的作用域。PopLocalFrame(nullptr)会一次性释放所有这些引用,因此每次迭代结束时,user、name和email都会被一起释放。

如果PushLocalFrame返回非零值,说明JVM无法为该作用域分配内存,此时会抛出OutOfMemoryError异常,因此函数会立即返回。

如果你需要让某个引用在作用域结束后仍然存在,可以将其传递给PopLocalFrame而不是传入null,这样函数就会在外部作用域中返回一个对该对象的新本地引用。

陷阱2:在静态变量中缓存本地引用

查找类和方法ID的速度相对较慢,因此本地库通常会将其结果缓存起来。但错误在于将类本身作为本地引用进行缓存。

static jclass gUserClass;
static jmethodID gGetName;

JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
  JNIEnv* env = nullptr;
  if (vm->GetEnv(reinterpret_castFindClass("com/example/User");  // 错误:这里使用了本地引用
  gGetName = env->GetMethodID(gUserClass, "getName", "()Ljava/lang/String;");
  return JNI_VERSION_1_6;
}

JNI_OnLoad在库被加载时会被执行一次,它会将FindClass的结果存储在一个静态变量中以供后续使用。问题在于FindClass返回的是一个本地引用,而这个引用会在JNI_OnLoad执行完毕后立即被释放。因此,之后对gUserClass的任何调用都会通过一个已经被释放、且可能已被重新用于其他对象的变量来进行。

令人困惑的是,这样的代码往往看起来能够正常工作。在某些JVM上,过时的引用仍然会暂时指向正确的类;而在启用了CheckJNI的ART环境中,程序会因为“访问了过时的本地引用”而抛出错误并终止运行。如果没有启用CheckJNI,程序则可能会出现随机崩溃或调用错误的对象的情况。

解决这个问题的方法是在将类存储到静态变量之前,先将其提升为全局引用。

jclass localClass = env->FindClass("com/example/User");
if (localClass == nullptr) return JNI_ERR;
gUserClass = static_cast(env->NewGlobalRef(localClass));
env->DeleteLocalRef(localClass);
if (gUserClass == nullptr) return JNI_ERR;

gGetName = env->GetMethodID(gUserClass, "getName", "()Ljava/lang/String;");
if (g GetName == nullptr) return JNI_ERR;

NewGlobalRef用于创建对同一类的第二个引用,这个引用在JNI_OnLoad返回后依然有效。原来的本地引用不再需要了,因此代码会立即将其删除。在每次调用时都会检查nullptr,因为当类或方法不存在时(例如,在经过混淆工具修改名称之后),FindClass和GetMethodID会返回NULL,并会留下一个未处理的异常。

jmethodID则不需要进行同样的处理。方法ID和字段ID并不是对象引用,因此它们不会被记录在任何引用表中。只要它们所属的类被加载出来,这些ID就仍然有效。保留对类的全局引用可以确保该类不会被卸载,这也是为什么需要将类及其ID一起进行全局缓存的另一个原因。

全局引用

全局引用是通过NewGlobalRef显式创建的,它的有效性会一直持续到本地代码调用DeleteGlobalRef为止。任何线程都可以使用这种全局引用,而且它会使被引用的对象在整个生命周期内都保持存活状态。因此,对于本地代码需要在多次调用之间保留的对象来说,全局引用是一种非常合适的解决方案——无论是需要缓存的类、回调对象,还是那些持有本地资源的Java对象。

不过,这样的做法也会带来一定的代价:JVM永远不会自动释放全局引用。垃圾收集器会将全局引用视为“根对象”,因此只要存在这个引用,与之相关联的所有对象都会一直留在内存中,直到该引用被删除为止。

在Android系统中,全局引用的数量是有限制的。这一限制为51,200个,如果超过了这个数值,系统会因为“全局引用表溢出”而终止进程。

陷阱3:全局引用泄漏

全局引用泄漏通常发生在这样的代码中:每当有新的对象被注册时,就会创建一个新的全局引用,而之前的引用却不会被释放。

static jobject gListener;

extern "C" JNIEXPORT void JNICALL
Java_com_example_Sensor_setListener(JNIEnv* env, jobject thiz, jobject listener) {
  gListener = env->NewGlobalRef(listener);
}

每次调用setListener方法时,都会创建一个新的全局引用,并覆盖之前存储在gListener中的引用。虽然旧的引用仍然存在于引用表中,但由于再也没有其他地方指向它了,因此它永远不会被删除。每个泄漏出来的全局引用都会使相应的监听对象保持存活状态,而监听对象往往是一个内部类,它会持有对整个Activity或Fragment的引用。这样一来,每次屏幕旋转时,内存泄漏的情况就会加剧,最终当引用表满时,系统就会发生崩溃。

修正后的代码会先释放之前的全局引用,同时为Java程序提供清除这些监听对象的机制。

static std::mutex gListenerMutex;
static jobject gListener = nullptr;

extern "C" JNIEXPORT void JNICALL
Java_com_example_Sensor_setListener(JNIEnv* env, jobject thiz, jobject listener) {
  std::lock_guard lock(gListenerMutex);
  if (gListener != nullptr) {
    env->DeleteGlobalRef(gListener);
    gListener = nullptr;
  }
  if (listener != nullptr) {
    gListener = env->NewGlobalRef(listener);
  }
}

这个版本在创建新的全局引用之前,会删除任何现有的全局引用,因此任何时候都最多只存在一个监听器引用。从Java端传递null可以彻底清除该监听器引用,这样Java方就可以在onDestroy或close()方法中干净地释放它。互斥锁的作用是保护gListener,因为当Java在主线程中替换该监听器引用时,本地工作线程可能仍在读取这个引用。如果没有这种锁定机制,工作线程可能会读取到刚刚被删除的引用。

一个好的习惯是:每当使用NewGlobalRef创建一个全局引用时,都要确保有明确的位置来执行对应的DeleteGlobalRef操作。如果无法确定这样的位置,那么这个引用很可能会造成内存泄漏。

弱全局引用

使用NewWeakGlobalRef》创建的弱全局引用,其作用与普通的全局引用相同——可以在不同的调用之间保持有效性,并且任何线程都可以使用它。但不同的是,这种引用不会使对象持续存在于内存中。如果Java代码中没有其他地方引用这个对象,垃圾回收器就会回收它,此时弱引用就会指向null。

当本地代码需要回调到Java对象,但又不想控制该对象的生命周期时,弱引用就非常有用。例如,一个本地音频引擎在用户离开屏幕后不应该继续维持UI组件的存活状态,但只要UI组件还存在,音频引擎就应该能够与之进行交互。

误区4:直接使用弱全局引用

使用弱引用的错误在于将它们当作强引用来使用。

static jweak gCallback;

void NotifyProgress(JNIEnv* env, jint percent) {
  if (!env->IsSameObject(gCallback, nullptr)) {
    env->CallVoidMethod(gCallback, gOnProgress, percent);  // 这个代码存在隐患:可能会与垃圾回收器发生竞争条件
  }
}

这段代码会检查gCallback所引用的对象是否已经被垃圾回收器回收,如果没有被回收,就会调用该对象的方法。这个检查步骤和调用步骤是分开进行的,而垃圾回收器可能在它们之间运行。如果垃圾回收器在调用之前就回收了对象,那么CallVoidMethod就会尝试访问一个已经不存在的对象,这就是典型的“检查时存在,使用时已消失”的竞争条件问题,这种问题通常只在内存压力较大的情况下才会出现。

安全的做法是先将该弱引用转换为强引用,然后再进行后续操作。

void NotifyProgress(JNIEnv* env, jint percent) {
  jobject callback = env->NewLocalRef(gCallback);
  if (callback == nullptr) return;
  env->CallVoidMethod(callback, gOnProgress, percent);
  env->DeleteLocalRef(callback);
}

NewLocalRef会返回null(表示对象已经被回收),或者返回一个强类型的本地引用。一旦代码获得了这个本地引用,垃圾回收器就无法再回收该对象了,因此这样的调用是安全的。

NewLocalRef这一功能是在JNI 1.2版本中添加的,目前所有现代版本的JVM以及所有Android系统中都支持它。当不再需要某个弱引用时,应该使用DeleteWeakGlobalRef来释放它,而不是DeleteGlobalRef。

误区5:在多线程环境中共享JNIEnv

每个本地方法中传递的JNIEnv*指针都与当前线程相关联。该指针指向与特定线程相关的状态信息,例如本地引用表和未处理的异常信息。如果将这样的指针存储在全局变量中,并在另一个线程中使用它,就会导致这些状态数据被破坏。

static JNIEnv* gEnv;  // 注意:JNIEnv是指针是针对每个线程而言的

extern "C" JNIEXPORT void JNICALL
Java_com_example_Downloader_start(JNIEnv* env, jobject thiz) {
  gEnv = env;
  std::thread([] {
    DownloadFile();
    gEnv->CallStaticVoidMethod(gDownloaderClass, gOnComplete);
  }).detach();
}

这个本地方法会保存自己的JNIEnv*指针,然后启动一个工作线程,在文件下载完成后使用该指针。但此时,被保存的指针实际上属于另一个线程(也就是调用start方法的那个线程),而那个线程可能同时还在执行其他JNI代码。在使用了CheckJNI功能的ART环境中,这种情况会立即导致程序失败,并会出现“JNIEnv被错误地用于了其他线程”这样的错误信息;如果没有使用CheckJNI,就会导致不可预测的数据损坏。

正确的处理方式是:将JavaVM*指针缓存起来,让所有线程都能共享这个全局变量,然后每个线程在需要时再获取自己专属的JNIEnv*指针。

static JavaVM* gVm;

JNI jint JNI_OnLoad(JavaVM* vm, void* reserved) {
  gVm = vm;
  // 在这里缓存类和方法ID等信息。
  return JNI_VERSION_1_6;
}

void WorkerThread() {
  JNIEnv* env = nullptr;
  if (gVm->AttachCurrentThread(&env, nullptr) != JNI_OK) return;

  DownloadFile();
  env->CallStaticVoidMethod(gDownloaderClass, gOnComplete);
  if (env->ExceptionCheck()) env->ExceptionClear();

  gVm->DetachCurrentThread();
}

JNI_OnLoad方法会保存JavaVM*指针,这个指针在整个进程的生命周期内都是有效的。工作线程会通过调用AttachCurrentThread来向JVM注册自己,并获取属于自己的JENV* 指针;在退出之前,它会再次调用DetachCurrentThread来释放这个指针。需要注意的是,Android NDK中定义的AttachCurrentThread方法接受的是JNV* 类型的参数,而桌面JDK的头文件中定义的方法则接受void* 类型的参数,因此桌面代码需要使用reinterpret_cast(&env)来进行类型转换。

调用DetachCurrentThread是必须的。在通过AttachCurrentThread连接到JVM的线程中创建的本地引用,永远不会被自动释放,因为没有相应的本地方法返回来触发清理操作;只有当线程被分离时,这些引用才会被释放。

在Android系统中,如果一个仍在与JVM连接的本地线程退出了,那么ART系统会立即终止整个进程。因此,如果某个线程在整个运行过程中都保持与JVM的连接状态,那么一种常见的做法是注册一个pthread_key_create类型的销毁函数,在该线程退出时调用这个函数来执行DetachCurrentThread操作,这样就可以确保没有任何代码路径会忘记执行这个清理步骤。

请注意,gDownloaderClass必须是一个全局引用,原因详见“陷阱2”中的说明。与JNIEnv*一样,局部引用也无法在不同线程之间进行传递。

陷阱6:从本地线程调用FindClass

当一个新的本地线程尝试调用应用程序自身类的FindClass方法时,也会出现类似的问题。

void WorkerThread() {
  JNIEnv* env = nullptr;
  gVm->AttachCurrentThread(&env, nullptr);
  jclass cls = env->FindClass("com/example/Downloader");  // 返回nullptr
  // ...
  gVm->DetachCurrentThread();
}

当在本地方法内部调用FindClass时,JVM会使用声明该方法的Java类的类加载器,因此应用程序中的类能够被正确地查找到。

通过AttachCurrentThread附加到的本地线程其栈中并不包含任何Java相关的信息。在这种情况下,JVM会转而使用系统的类加载器,但该加载器仅能处理框架类,因此查询会失败,FindClass会返回nullptr,进而引发ClassNotFoundException异常。如果代码没有检查返回值,那么在下次使用cls时程序就会崩溃。

标准的解决方法是在JNI_OnLoad中查找所有需要的类,并将它们存储为全局引用;绝对不要从本地线程调用FindClass。如果某个类需要动态加载,那么可以在JNI_OnLoad期间将其对应的ClassLoader对象的引用缓存起来,然后由工作线程来调用其loadClass方法。

陷阱7:忽略未处理的异常

当JNI调用失败或从本地代码调用的Java代码抛出异常时,该异常并不会导致本地线程栈中的执行流程中断。相反,这个异常会被标记为“待处理”,而本地函数会继续执行。在异常仍未被处理的情况下继续调用大多数JNI函数属于未定义的行为。

jstring name = static_cast(env->CallObjectMethod(user, gGetName));
const char* utf = env->GetStringUTFChars(name, nullptr);  // 如果getName()抛出异常,这里就会出错

如果getName()方法抛出了异常,那么CallObjectMethod会返回nullptr,并且让这个异常处于“待处理”状态。接下来的一行代码会将这个nullptr值传递给GetStringUTFChars方法,而此时异常仍然存在,这就违反了编程规范。使用CheckJNI工具时,ART会抛出类似“JNI GetStringUTFChars在异常未处理的情况下被调用”的错误信息;如果没有使用该工具,程序通常会因为尝试访问空指针而崩溃。

jstring name = static_cast(env->CallObjectMethod(user, gGetName));
if (env->ExceptionCheck()) {
  return nullptr;
}
const char* utf = env->GetStringUTFChars(name, nullptr);
if (utf == nullptr) {
  return nullptr;
}

在每次可能引发异常的调用之后,固定代码会检查ExceptionCheck(),如果存在未处理的异常,就会立即返回结果。从原生方法中返回时携带未处理的异常是允许且有益的:一旦控制权回到Java代码部分,该异常会被重新抛出,因此Java调用者会将其视为来自native方法的普通异常。

如果原生代码希望自行处理这些错误,它可以在进行任何进一步的JNI调用之前先调用ExceptionClear()。在存在未处理的异常时,有一小部分函数是被明确允许使用的,例如DeleteLocalRef、DeleteGlobalRef以及Release函数,这样就可以在返回之前完成必要的清理工作。

陷阱8:使用相等运算符比较引用

由于引用实际上是某种标识符而非对象地址,因此两个不同的标识符可以指向同一个Java对象。

if (listener == gListener) {  // 错误:这里比较的是标识符,而不是对象
  RemoveListener();
}

在这种比较中,listener是当前调用中传递进来的局部引用,而gListener则是之前创建的全局引用。即使它们都指向同一个Java对象,但由于在不同的数据结构中占据不同的位置,因此这些标识符代表的是不同的值,比较结果自然是错误的。这样一来,RemoveListener()方法也永远不会被执行。

if (env->IsSameObject(listener, gListener)) {
  RemoveListener();
}

IsSameObject方法会询问JVM这两个标识符是否都指向同一个对象,这正是你想要进行的比较操作。将null作为第二个参数传递也是检查弱全局引用是否已被清除的标准方法;不过正如陷阱4中所提到的,如果你之后还需要使用该对象,那么使用NewLocalRef会更为安全。

如何利用CheckJNI捕获引用相关错误

本文中提到的大多数错误要么是偶然发生的,要么会导致与实际问题无关的异常。CheckJNI是一种扩展验证机制,它会让这些错误立即以明显的形式显现出来,并且会提供有关出错位置的具体信息。

在Android系统中,对于那些使用了android:debuggable="true"属性构建的应用程序,以及模拟器环境,CheckJNI都是默认启用的。如果你想在真实设备上为所有应用程序启用这一功能,可以运行以下命令,然后重新启动应用程序:

adb shell setprop debug.checkjni 1

这个命令会设置一个系统属性,让ART在之后启动的每个应用程序进程中都启用CheckJNI。不过这对已经正在运行的进程没有影响,因此需要重新启动应用程序才能使设置生效。

当CheckJNI处于启用状态时,ART会对每一个JNI调用进行验证:它会拒绝无效的局部引用、在错误线程上使用的引用、在存在未处理异常的情况下进行的调用、从错误线程使用的JNIEnv*指针、不匹配的Release调用以及非法的方法ID。一旦检测到错误,ART会在logcat中输出“JNI DETECTED ERROR IN APPLICATION”这样的提示信息,同时还会附带问题的详细描述和原生代码的堆栈跟踪信息,之后程序就会终止运行。

在桌面版的JVM上,应使用-Xcheck:jni参数来启用这一功能。

java -Xcheck:jni -Djava.library.path=build/libs -jar app.jar

这样运行应用程序时,HotSpot的JNI检查功能就会被激活,并且会从build/libs目录中加载本地库。对于许多类似的问题,比如在异常尚未被处理的情况下进行调用或使用了无效的引用,HotSpot都会发出警告。不过它的检查机制通常比ART的宽松一些,因此那些在桌面环境下使用-Xcheck:jni能够正常运行的应用程序,在Android平台上使用CheckJNI时仍有可能出现问题。如果在两个平台上都启用CheckJNI来进行测试,包括那些涉及处理大型数组或在本地线程中执行操作的测试,那么大多数引用相关的问题都能在程序发布之前被及时发现。

如何使用RAII机制安全地管理引用

手动调用DeleteLocalRef和DeleteGlobalRef很容易被遗忘,尤其是在函数返回路径较早的地方。C++允许将引用的生命周期与某个作用域绑定在一起,就像std::unique_ptr管理堆内存一样。

template 
class ScopedLocalRef {
 public:
 ScopedLocalRef(JNIEnv* env, T ref) : env_(env), ref_(ref) {}
  ~scopedLocalRef() {
    if (ref_ != nullptr) env_->DeleteLocalRef(ref_);
  }
  scopedLocalRef(const scopedLocalRef&) = delete;
  scopedLocalRef& operator=(const scopedLocalRef&) = delete;

  T get() const { return ref_; }

 private:
  JNIEnv* env_;
  T ref_;
};

ScopedLocalRef会拥有对某个局部引用的所有权,并会在其析构函数中删除该引用,因此无论外部作用域是如何结束的(无论是正常执行完毕、提前返回,还是发生了C++异常),这个引用都会被正确释放。由于两个持有相同引用的对象尝试同时删除同一个引用可能会导致问题,因此ScopedLocalRef没有提供拷贝构造函数和拷贝赋值运算符。而get()方法则用于返回原始的引用句柄,以便将其传递给JNI函数。

使用这种封装机制后,示例中的循环代码会变得更简洁,而且也不会出现内存泄漏的问题。

for (jsize i = 0; i < count; i++) {
  ScopedLocalRef name(
      env, static_cast(env->GetObjectArrayElement(names, i)));
  const char* utf = env->>GetStringUTFChars(name.get(), nullptr);
  if (utf == nullptr) return;
  LogName(utf);
  env->>ReleaseStringUTFChars(name.get(), utf);
}

在每次迭代中,都会为数组中的每个元素创建一个ScopedLocalRef》对象,而它的析构函数会在迭代结束时或函数提前返回时被调用。整个代码中并没有显式地调用DeleteLocalRef,后来添加新的返回路径也不会导致内存泄漏。

全局引用封装机制的基本思想与scopedLocalRef相同,但有一个重要的区别:它的析构函数可能会在创建引用的线程之外的其他线程上执行,因此不应该存储JNIEnv*指针。相反,它应该存储JavaVM*指针,并在需要删除引用时获取当前线程的环境信息。

class ScopedGlobalRef {
 public:
  ScopedGlobalRef(JNIEnv* env, jobject obj)
      : ref_(obj ? env->NewGlobalRef(obj) : nullptr) {
    env->GetJavaVM(&vm_);
  }
  ~ScopedGlobalRef() {
    JNIEnv* env = nullptr;
    if (ref_ != nullptr && vm->GetEnv(reinterpret_castDeleteGlobalRef(ref_);
    }
  }
  ScopedGlobalRef(const ScopedGlobalRef&) = delete;
  ScopedGlobalRef& operator=(constScopedGlobalRef&) = delete;

  jobject get() const { return ref_; }

 private:
  JavaVM* vm_ = nullptr;
  jobject ref_;
};

构造函数会利用接收到的任何局部引用或全局引用来创建一个全局引用,并记录相应的JavaVM*指针。析构函数会调用GetEnv方法来获取当前线程所使用的JNIEnv*指针,只有当该线程与JVM连接在一起时,才会删除这个全局引用。

如果析构函数在某个未与JVM连接的线程上被执行,那么这个简单的实现会导致引用泄漏,而不会引发程序崩溃。在实际应用中,应该要么临时将相关线程连接到JVM上,要么记录这种泄漏现象以便及时发现问题。Android平台代码在其ScopedLocalRef辅助类中也采用了相同的机制;而对于那些不愿意自己编写这类代码的开发人员来说,fbjni等库也提供了经过测试的完整实现版本。

引用类型的比较

。
属性 局部引用 全局引用弱全局引用
创建方式 大多数JNI函数 NewGlobalRef NewWeakGlobalRef
有效期限 直到本地方法返回为止 DeleteGlobalRef被调用时 DeleteWeakGlobalRef被调用时
能否被其他线程使用 不能 可以 可以
是否能使对象保持存活状态 是 是 否
是否会自动被释放 是的,会在方法返回或线程与JVM断开连接时被释放 不会 不会
典型用途 在某次函数调用中临时使用对象 用于缓存类或作为回调对象 用于那些本地代码不应持有的对象所对应的回调功能

该表格总结了三种引用类型的区别。“有效期限”一栏说明了这些引用在什么情况下会不再可用,“是否会自动被释放”一栏则说明了JVM是否会在适当的时候清理这些引用。

最重要的结论是:只有局部引用是JVM会自动帮我们清理的,而正是这种便利性使得局部引用不能被存储或共享。全局引用可以被存储和共享,但只要没有被删除,它们就会一直作为垃圾回收的根对象存在;弱全局引用也可以被存储和共享,而且不会使相关对象被“固定”在内存中,但在每次使用之前,都需要先将这些弱全局引用转换为局部引用。

总结

每一个JNI引用实际上都是对JVM所管理的表格中某个槽位的引用;而每种引用类型的定义,其实都与该槽位何时会被释放有关。当本地方法执行完毕返回时,这些本地引用就会被销毁,并且它们仅属于当前线程。而全局引用则会在你明确删除它们之前一直存在,因此可以在任何地方被使用。至于弱类型的全局引用,虽然也会在你删除它们之后才被销毁,但它们并不会使所引用的对象继续保持存活状态。

大多数与JNI相关的崩溃都是由于违反了这些规则而直接导致的。在循环中创建本地引用却不及时释放它们,会导致本地引用表被溢出,而使用`DeleteLocalRef`、`PushLocalFrame`和`PopLocalFrame`这些方法可以避免这种情况。将某个本地引用保留下来以供后续使用,可能会导致该引用变得无效;而通过`NewGlobalRef`将其提升为全局引用,则可以解决这个问题。如果不及时删除创建的全局引用,就会导致内存泄漏,最终也会使全局引用表被溢出。在使用弱引用之前,如果没有先通过`NewLocalRef`将其提升为强引用,那么就可能会与垃圾回收器发生冲突。

线程相关的规则也同样重要。一个`JNI*`对象属于某个特定的线程,因此在使用JNI之前,应该先将相应的`JavaVM*`对象缓存起来,并将这个对象绑定到对应的本地线程上;在线程退出之前,再解除这种绑定关系。需要在`JNI_OnLoad`中解析应用程序中的类,因为本地线程之后可能无法找到这些类。在任何可能会抛出异常的调用之后,都应该检查是否还有未处理的异常;在比较引用对象时,应该使用`IsSameObject`方法,而不是简单的`==`运算符。

最后,可以让工具来帮助你遵守这些规则。在Android平台上运行测试时,可以使用CheckJNI工具;在桌面环境中运行测试时,可以添加`-Xcheck:jni`参数,这样一旦出现引用相关的错误,程序就会在引发错误的那个调用点立即停止执行。此外,还可以使用RAII封装机制,这样一来,释放引用的操作就会自动完成,而不需要依赖代码中的每一处逻辑来确保这一点被执行。

相关文章

技术实践

如何防止你的安卓应用程序因“唤醒锁定”功能而过度消耗电池电量

“唤醒锁”是Android系统中最简单的API之一,同时也是最容易被误用的功能之一。获取一个唤醒锁只需要一行代码;但如果忘记释放它,手机的CPU可能会持续工作数小时,从而导致电池在夜间被耗尽,同时你的应用程序也可能会在Google Play上收到警告标签。 大多数与唤醒锁相关的错误并不是由于对API本身的误解造成的,而是源于一些常见的控制流程问题:比如在调用 acquire() 和 release() 方法之间抛出了异常、程序过早退出、回调函数从未被执行,或者引用计数出现了失衡。 这些错误在代码审查过程中很难被发现,在常规测试中也无法显现出来,因为通常情况下设备都是处于连接电源且处于唤醒状态的

阅读全文
技术实践

《设计模式手册:通过C#代码示例学习常见的设计模式》

设计模式是针对软件设计中常见问题的、可复用的解决方案。可以把它们看作是蓝图:并非完整的代码,而是经过验证的模板,你可以根据自己的需求将其调整过来,用于解决自己代码库中的特定问题。 这本手册旨在帮助大家切实理解软件设计模式。我编写这本书是为了所有开发者,无论你使用哪种编程语言。书中的示例是用C#编写的,但这里提到的每一个概念同样适用于Python、Java、TypeScript、Go等语言。 源代码可以在这里找到: github.com/Clifftech123/design-patterns-handbook 。 需要注意的事项: 设计模式本身并不是代码。 它们是一种思考代码结构的方式,是解决

阅读全文
技术实践

在Linux系统中,系统调用究竟是如何工作的

这是一个简单的C程序。它调用了三次`clock_gettime()`函数,然后向标准输出输出了5个字节。 #include #include #include int main(void) { struct timespec ts; for (int i = 0; i 这两者看起来都属于系统调用。它们都是向内核请求程序本身无法获取的信息:当前时间,以及文件描述符的访问权限。 gcc -O0 -o mystery mystery.c strace ./mystery 2>&1 | grep -c clock_gettime 答案是`0`。 write() 函数被立即执行了,而那三次`clock_

阅读全文
技术实践

构建者设计模式:构建复杂对象的一种更有效的方法

有些对象非常简单,比如字符串、数字或布尔值。你可以用一行代码创建它们,然后继续进行其他操作。 而另一些对象则完全不简单。例如,一个轮播组件就需要包含项目数量、项目生成函数、控制器、高度、视图窗口比例、自动播放设置、页面切换回调函数以及无限滚动配置等信息。再比如,一个HTTP请求需要URL地址、请求头信息、认证令牌、请求体内容、超时时间以及重试逻辑。又或者,一条通知消息需要标题、正文、图标、发送渠道、优先级、声音效果、振动功能以及操作按钮等等。 当你需要构建这类对象时,一种常见的方法是使用带有大量参数的构造函数。这种方法虽然可行,但随着对象结构变得越来越复杂,就会带来一系列问题:这些参数很难区分

阅读全文