freeCodeCamp.org

How to Avoid JNI Crashes by Managing Local and Global References Correctly

8.5内容质量

TL;DR · AI 摘要

正确管理JNI中的本地和全局引用可避免崩溃,文章详细解析了8种常见错误及解决方案。

核心要点

  • 循环中未释放本地引用会导致内存泄漏,应使用NewLocalRef/DeleteLocalRef。
  • 全局引用需显式调用DeleteGlobalRef释放,否则可能引发OOM。
  • 跨线程传递JNIEnv指针会导致未定义行为,应使用AttachCurrentThread。

结构提纲

按章节快速跳转。

  1. JNI崩溃主要源于引用管理错误,而非复杂逻辑。

  2. Java对象通过opaque handle访问,GC可能移动对象位置。

  3. 循环中累积本地引用会导致后续GC时崩溃。

  4. 静态变量缓存本地引用会导致内存泄漏。

  5. RAII模式可自动管理引用生命周期,CheckJNI可检测错误。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • JNI引用管理
    • 引用类型
      • 本地引用
      • 全局引用
      • 弱全局引用
    • 常见错误
      • 引用泄漏
      • 跨线程使用
      • 未处理异常
    • 解决方案
      • CheckJNI
      • RAII模式

金句 / Highlights

值得收藏与分享的关键句。

#JNI#Android#C++#内存管理
打开原文

如何通过正确管理本地和全局引用避免JNI崩溃

2026年10月4日

/

#Android

Nikheel Vishwas Savant

大多数JNI崩溃并非源于复杂的逻辑,而是源于围绕对象引用的一小部分错误:在引用失效后仍然持有它、创建引用的速度快于释放速度,或向从未被允许使用的线程传递引用。

这些错误难以追踪,因为崩溃通常发生在导致问题的代码行很远的地方,有时甚至在垃圾回收器内部,几分钟后才会发生。

Java Native Interface 为C和C++代码提供了一种调用JVM并操作Java对象的方式。本地代码从不会获得Java对象的原始指针,而是获得一个引用,每种引用都有其自身的规则,规定其有效时间、哪些线程可以使用它,以及谁负责释放它。一旦理解这些规则,大多数JNI崩溃将变得容易预防和诊断。

本文将解释本地引用、全局引用和弱全局引用的工作原理,通过示例展示最常见的错误(包括破损代码和修复后的代码),最后介绍能够提前发现这些问题的工具和模式(CheckJNI和RAII包装器)。

示例使用C++和Android惯例,但引用规则源自JNI规范,适用于任何JVM。

目录

  • 前提条件
  • JNI引用的工作原理
  • 本地引用
  • 坑点1:循环中泄露本地引用
  • 坑点2:在静态变量中缓存本地引用
  • 全局引用
  • 坑点3:泄露全局引用
  • 弱全局引用
  • 坑点4:直接使用弱全局引用
  • 坑点5:跨线程共享JNIEnv
  • 坑点6:从本地线程调用FindClass
  • 坑点7:忽略待处理异常
  • 坑点8:使用等值运算符比较引用
  • 使用CheckJNI捕获引用错误
  • 使用RAII安全管理引用
  • 引用类型对比
  • 总结

前提条件

您应能够阅读C++和Java代码,并且至少编写或阅读过一个基本的JNI函数(用extern "C" JNIEXPORT声明,并从Java本地方法调用)。

熟悉Android NDK有助于理解Android特定部分,但并非理解核心概念的必要条件。

JNI引用的工作原理

Java对象位于托管堆上,垃圾收集器可以在任何时候进行内存压缩时移动它。

如果本地代码持有该对象的原始指针,那么在下一次压缩后,指针会变得无效。JNI通过向本地代码提供不透明句柄而非原始指针来避免这种情况。jobject、jclass、jstring和jobjectArray等类型都是这种句柄。

code
Native code                 JVM reference tables             Managed heap
 +-------------+            +----------------------+         +------------+
 | jobject h   + -------->  | slot 17 : ptr        + ------> |  User obj  |
 +-------------+            +----------------------+         +------------+
                              (GC updates the ptr
                               when the object moves)

上图展示了使JNI引用安全的间接机制。本地代码持有一个句柄(h),该句柄指向JVM拥有的引用表中的一个槽位。该槽位保存了托管堆上对象的真实地址。

当垃圾收集器移动对象时,它会更新槽位中存储的指针,而本地代码中的句柄仍然有效。槽位还充当GC根,这意味着只要槽位存在,它指向的对象就无法被回收。

所有关于引用的规则最终都归结为一个问题:这个槽位何时会被释放?

JNI有三种引用类型,它们在这一点上存在差异。本地引用的槽位在本地方法返回时会自动被释放。全局引用的槽位会一直存在,直到本地代码显式删除它。弱全局引用的槽位同样会一直存在直到被删除,但它不会保持对象存活,因此它指向的对象随时可能消失。

本地引用

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

本地引用仅在其创建它的本地方法调用内有效,并且仅在创建它的线程上有效。当本地方法返回到Java时,JVM会一次性释放该调用期间创建的所有本地引用。这对于短函数来说很方便,这也是为什么大多数JNI代码根本不需要调用DeleteLocalRef的原因。

这种便利性有其限制。每个调用帧的本地引用表都有固定的容量。JNI规范只保证有16个本地引用的空间,如果你需要更多,必须调用EnsureLocalCapacity。

实际上,JVM通常会提供更多的空间。旧版Android系统将表容量限制为512个条目,超出限制时会以"本地引用表溢出(max=512)"为由终止进程。新版ART显著提高了这个限制,但任何在无限循环中创建本地引用的代码最终仍会耗尽资源。

陷阱1:循环中泄漏本地引用

最常见的本地引用错误是循环中每次迭代都创建新引用却从不释放。

code
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<jstring>(env->GetObjectArrayElement(names, i));
    const char* utf = env->GetStringUTFChars(name, nullptr);
    if (utf == nullptr) return;
    LogName(utf);
    env->ReleaseStringUTFChars(name, utf);
  }
}

该函数遍历Java String[]数组并记录每个条目。每次调用GetObjectArrayElement都会创建新本地引用并存储到表中。

代码正确地使用ReleaseStringUTFChars释放了UTF-8缓冲区,但这仅释放了字符数据。它对jstring引用本身没有任何作用。

处理10个名称时这没有问题。当处理100,000个名称时,表会被填满并导致进程终止。这个错误特别容易被忽略,因为单元测试通常使用小数组。

直接的修复方法是:在不再需要时立即删除每个引用。

code
for (jsize i = 0; i < count; i++) {
  jstring name = static_cast<jstring>(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 可以处理这种情况。

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

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

如果 PushLocalFrame 返回非零值,说明 JVM 无法分配该作用域并已挂起一个 OutOfMemoryError 异常,因此函数会立即返回。

如果需要让某个引用在作用域结束后仍然有效,可以将该引用传递给 PopLocalFrame 而不是 nullptr,此时函数会返回一个指向该对象的新的本地引用,该引用属于外层作用域。

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

查找类和方法 ID 的过程相对较慢,因此本地库通常会缓存它们。错误的做法是将类缓存为本地引用。

code
static jclass gUserClass;
static jmethodID gGetName;

JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
  JNIEnv* env = nullptr;
  if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
    return JNI_ERR;
  }
  gUserClass = env->FindClass("com/example/User");  // Bug: local reference
  gGetName = env->GetMethodID(gUserClass, "getName", "()Ljava/lang/String;");
  return JNI_VERSION_1_6;
}

JNI_OnLoad 在库加载时只执行一次,此版本会将 FindClass 的结果存储在静态变量中以供后续使用。问题是 FindClass 返回的是一个本地引用,而该引用会在 JNI_OnLoad 返回后立即被释放。之后每次使用 gUserClass 时,访问的都是已经被释放的内存槽位,该槽位可能已被其他对象重新使用。

令人沮丧的是,这段代码通常看起来是能正常工作的。在某些 JVM 上,过期的句柄可能仍会暂时指向正确的类。在启用了 CheckJNI 的 ART 上,进程会因 "accessed stale local reference" 类似的错误而中止。未启用 CheckJNI 时,会导致随机崩溃或调用到错误的对象。

修复方法是在存储前将类提升为全局引用。

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

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

NewGlobalRef 会为同一个类创建第二个引用,这个引用会在 JNI_OnLoad 返回后仍然有效。原始的局部引用不再需要,因此代码会立即删除它。每次调用都需要检查 nullptr,因为当类或方法不存在时(例如混淆工具重命名后),FindClass 和 GetMethodID 会返回 nullptr 并留下待处理异常。

jmethodID 不需要相同的处理方式。方法 ID 和字段 ID 不是对象引用,因此不会在任何引用表中被跟踪。只要所属的类已加载,它们就会保持有效。持有类的全局引用可以保证类不会被卸载,这也是为什么需要将类和其 ID 一起全局缓存的另一个原因。

全局引用

全局引用通过 NewGlobalRef 显式创建,直到本地代码调用 DeleteGlobalRef 之前都保持有效。它可以被任何线程使用,并且在整个生命周期内保持引用对象存活。这使其成为本地代码需要跨调用保持任何内容的正确工具:缓存的类、回调对象,或拥有本地资源的 Java 对象。

代价是 JVM 不会自行释放全局引用。垃圾回收器将其视为根对象,因此对象及其所有可达对象会一直保留在内存中,直到引用被删除。

全局引用还存储在固定大小的表中。在 Android 上,该限制为 51,200 个条目,超出限制会导致进程因全局引用表溢出而终止。

陷阱 3:全局引用泄漏

全局引用泄漏通常来自每次注册时创建新全局引用的代码,而没有释放之前的引用。

code
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 提供清除监听器的方法。

code
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<std::mutex> lock(gListenerMutex);
  if (gListener != nullptr) {
    env->DeleteGlobalRef(gListener);
    gListener = nullptr;
  }
  if (listener != nullptr) {
    gListener = env->NewGlobalRef(listener);
  }
}
code

此版本在创建新引用前会先删除任何现有的全局引用,因此任何时候最多只能存在一个监听器引用。从Java传递null会完全清除监听器,这为Java侧在onDestroy或close()中释放资源提供了清晰的方式。互斥锁保护了gListener,因为当Java主线程替换监听器时,本地工作线程可能正在读取该监听器。如果没有锁,工作线程可能读取到刚刚被删除的对象句柄。

一个良好的习惯是为每个NewGlobalRef都配对一个明确标识对应DeleteGlobalRef发生位置的代码。如果你无法指向那个位置,引用很可能会发生泄漏。

## 弱全局引用

通过NewWeakGlobalRef创建的弱全局引用在跨调用生存能力和多线程可用性方面与全局引用类似。区别在于它不会保持对象存活。如果Java中没有任何其他引用指向该对象,垃圾回收器可以回收它,此时弱引用将指向null。

当本地代码需要回调Java对象但不控制该对象生命周期时,弱引用非常有用。本地音频引擎通知UI组件就是一个典型例子。引擎不应该在用户离开屏幕后保持UI存活,但只要UI存在,引擎就应该能够访问到它。

## 坑点4:直接使用弱全局引用

弱引用的错误用法是像使用强引用一样使用它们。

static jweak gCallback;

void NotifyProgress(JNIEnv* env, jint percent) { if (!env->IsSameObject(gCallback, nullptr)) { env->CallVoidMethod(gCallback, gOnProgress, percent); // Bug: race with GC } }

code

代码检查了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); }

code

NewLocalRef会返回nullptr(对象已被回收)或返回一个强局部引用。一旦代码持有该局部引用,垃圾回收器在引用被删除前无法回收该对象,因此调用是安全的。

NewLocalRef在JNI 1.2中新增,所有现代JVM和所有Android版本都支持。当弱引用本身不再需要时,应使用DeleteWeakGlobalRef释放,而不是DeleteGlobalRef。

## 坑点5:跨线程共享JNIEnv

传递给每个本地方法的JNIEnv*指针与当前线程绑定。它指向线程专属状态,如局部引用表和待处理异常。将其存储在全局变量中并从其他线程使用会破坏这些状态。

static JNIEnv* gEnv; // Bug: JNIEnv is per thread

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

code

本地方法保存其 JNIEnv*,然后启动一个工作线程,在下载完成后该线程会使用它。到那时,保存的指针属于另一个线程(调用 start 的那个线程),而该线程可能在同一时刻正在运行其他 JNI 代码。在启用 CheckJNI 的 ART 环境中,这会立即失败并显示 JNIEnv 在错误线程上使用的提示信息。未启用 CheckJNI 时,会导致不可预测的内存损坏。

正确的做法是缓存 JavaVM*,它在所有线程间共享,每个线程都应自行附加以获取自己的 JNIEnv*。

static JavaVM* gVm;

JNIEXPORT 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(); }

code

JNI_OnLoad 保存 JavaVM*,其在整个进程生命周期内都有效。工作线程调用 AttachCurrentThread 向 JVM 注册自身并获取自己的 JNIEnv*,在退出前调用 DetachCurrentThread。Android NDK 声明 AttachCurrentThread 接收 JNIEnv**,而桌面 JDK 头文件使用 void**,因此桌面代码需要 reinterpret_cast<void**>(&env)。

分离线程不是可选操作。在附加的本地线程上创建的局部引用永远不会自动释放,因为没有本地方法返回来触发清理。它们仅在分离时释放。

在 Android 上,如果线程在仍附加状态下退出,会导致 ART 终止进程。如果线程在其整个生命周期都保持附加状态,常见做法是注册一个 pthread_key_create 析构函数,在线程退出时调用 DetachCurrentThread,确保任何代码路径都不会遗漏。

还需注意 gDownloaderClass 必须是全局引用,原因如 Pitfall 2 所述。局部引用无法跨线程传递,与 JNIEnv* 一样存在限制。

## Pitfall 6:从本地线程调用 FindClass

当新附加的本地线程为应用自身的类调用 FindClass 时,会出现相关意外情况。

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

code

当 FindClass 在 Java 方法内部调用时,JVM 使用声明该方法的 Java 类的类加载器,因此应用类能正确解析。

在本地代码中创建并用 AttachCurrentThread 附加的线程,其栈中没有 Java 帧。此时 JVM 会回退到系统类加载器,该加载器仅了解框架类。查找失败,FindClass 返回 nullptr,导致 ClassNotFoundException 挂起。未检查返回值的代码在后续使用 cls 的调用时会崩溃。

标准解决方案是在 JNI_OnLoad(使用应用类加载器运行)中预先查找所有需要的类,将每个类存储为全局引用,并且绝不从本地线程调用 FindClass。如果必须动态解析类,应在 JNI_OnLoad 期间缓存应用 ClassLoader 的全局引用,并改由工作线程调用其 loadClass 方法。

## Pitfall 7:忽略待处理异常

当JNI调用失败或从本地代码调用的Java代码抛出异常时,异常不会导致本地堆栈展开。异常会被记录为待处理状态,本地函数会继续执行。在异常待处理状态下继续调用大多数JNI函数属于未定义行为。

jstring name = static_cast<jstring>(env->CallObjectMethod(user, gGetName)); const char* utf = env->GetStringUTFChars(name, nullptr); // 如果getName抛出异常则存在缺陷

code

如果getName()抛出异常,CallObjectMethod会返回nullptr并留下待处理的异常。下一行代码在异常仍待处理时将nullptr传递给GetStringUTFChars,这同时违反了两个规则。启用CheckJNI时,ART会以类似"JNI GetStringUTFChars called with pending exception"的提示信息终止进程。未启用CheckJNI时,进程通常会因空指针解引用而崩溃。

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

code

在所有可能抛出异常的调用之后,修复后的代码会检查ExceptionCheck()。如果发现有待处理的异常,会立即返回。从本地方法返回时携带待处理异常是允许且有用的:当控制权回到Java代码时,异常会立即被重新抛出,Java调用者会将其视为本地方法抛出的正常异常。

如果本地代码希望自行处理失败情况,应在进行任何后续JNI调用前调用ExceptionClear()。DeleteLocalRef、DeleteGlobalRef和各类Release函数等少数函数在异常待处理时被明确允许使用,这样可以在返回前继续执行清理操作。

## 坑点8:使用等号运算符比较引用

由于引用是句柄而非对象地址,两个不同的句柄可能指向同一个Java对象。

if (listener == gListener) { // 缺陷:比较的是句柄而非对象 RemoveListener(); }

code

在这个比较中,listener是当前调用传入的局部引用,gListener是之前创建的全局引用。即使两者指向同一个Java对象,它们在不同表中的不同槽位中占据着不同的句柄值,因此比较结果为false,listener永远不会被移除。

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

code

IsSameObject会询问JVM这两个句柄是否指向同一个对象,这正是你实际需要的Java等号语义。将nullptr作为第二个参数传递也是检查弱全局引用是否被清除的标准方式,尽管如坑点4所示,如果后续要使用对象,NewLocalRef会更安全。

## 如何使用CheckJNI捕获引用错误

本文中大部分错误要么偶然工作,要么在与实际错误无关的地方崩溃。CheckJNI是Android系统提供的一个扩展验证模式,它能让这些错误立即明显地失败,并显示指向错误调用的提示信息。

在Android上,对于使用android:debuggable="true"构建的应用和模拟器,CheckJNI会自动启用。要强制在物理设备的用户构建版本上为所有应用启用CheckJNI,请运行以下命令并重启应用:

adb shell setprop debug.checkjni 1

code

这会设置一个系统属性,指示ART为之后启动的每个应用进程启用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

code

这会以启用HotSpot JNI检查的方式运行应用,并从build/libs加载本地库。HotSpot会对许多相同问题(如存在未处理异常时的调用或无效引用)打印警告。其检查通常比ART的检查更宽松,因此在桌面端能干净运行于-Xcheck:jni的应用仍可能在Android上触发CheckJNI检查失败。在两个平台都启用CheckJNI运行测试(包括传递大数组和在本地线程执行操作的测试),能在发布前很好地捕获大部分引用错误。

## 如何通过RAII安全管理引用

手动的DeleteLocalRef和DeleteGlobalRef调用很容易被遗忘,特别是在早期返回路径中。C++允许将引用的生命周期绑定到作用域,就像std::unique_ptr管理堆内存一样。

template <typename T> 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_; };

code

ScopedLocalRef接管本地引用的所有权,并在其析构函数中删除该引用,因此无论包含作用域如何退出(正常完成、早期返回或C++异常),引用都会被释放。拷贝构造函数和拷贝赋值运算符被删除,因为两个持有相同句柄的包装器都会尝试删除该句柄。get()方法返回原始句柄用于传递给JNI函数。

使用这个包装器后,第一个陷阱中的循环变得更简洁且不会发生泄漏。

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

code

每次迭代都会为数组元素构造一个ScopedLocalRef实例,析构函数会在迭代结束或早期返回时执行。代码中没有任何显式的DeleteLocalRef调用,后续添加其他返回路径也不会引入泄漏。

全局引用包装器遵循相同思路,但有一个重要区别:析构函数可能在创建引用的线程之外运行,因此不应存储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_cast<void**>(&env), JNI_VERSION_1_6) == JNI_OK) { env->DeleteGlobalRef(ref_); } } ScopedGlobalRef(const ScopedGlobalRef&) = delete; ScopedGlobalRef& operator=(const ScopedGlobalRef&) = delete;

jobject get() const { return ref_; }

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

code

构造函数会从接收到的任何本地或全局引用创建一个全局引用,并记录 JavaVM*。析构函数会调用 GetEnv 获取当前线程的 JNIEnv*,并在该线程附加到 JVM 时删除引用。

如果析构函数在未附加的线程上运行,这个简化版本会泄露引用而不是崩溃。生产环境版本会临时附加线程或记录泄露信息以便被发现。Android 平台代码在其自身的 ScopedLocalRef 辅助类中使用了相同的模式,而 fbjni 等库提供了完整且经过测试的两种包装器实现,如果不想自己编写的话可以直接使用。

## 引用类型对比

属性

本地

全局

弱全局

创建方式

大多数 JNI 函数

NewGlobalRef

code

NewWeakGlobalRef

code

有效期限

本地方法返回时

DeleteGlobalRef

code

DeleteWeakGlobalRef

code

其他线程可用

否

是

保持对象存活

自动释放

是,返回或分离时

典型用途

单次调用中的临时值

缓存类、拥有回调

不应拥有对象的回调

表格总结了三种引用类型。 "有效期限" 行描述了句柄何时停止可用,"自动释放" 描述了是否可以依赖 JVM 清理它。

主要结论是本地引用是唯一一种 JVM 会自动清理的引用类型,这种便利性也正是它们不能被存储或共享的原因。全局引用可以被存储和共享,但每个引用都是一个永久的 GC 根节点,直到被删除。弱全局引用也可以被存储和共享而不会固定对象,但在每次使用前必须将其提升为本地引用。

## 总结

每个 JNI 引用都是 JVM 管理表中某个槽位的句柄,每种引用类型由该槽位何时被释放定义。本地引用在本地方法返回时消失,属于单个线程。全局引用在被删除前一直存在,可以在任何地方使用。弱全局引用在被删除前一直存在,但不会保持对象存活。

大多数 JNI 崩溃都直接源于违反这些规则。在循环中创建本地引用而不释放会导致本地表溢出,这可以通过 DeleteLocalRef 或 PushLocalFrame/PopLocalFrame 避免。存储本地引用供以后使用会导致得到过期句柄,通过 NewGlobalRef 提升可以解决。创建未删除的全局引用会导致内存泄漏并最终导致全局表溢出。使用弱引用前未通过 NewLocalRef 提升会与垃圾收集器产生竞争。

线程规则同样重要。JNIEnv* 属于特定线程,因此需要缓存 JavaVM*,在使用 JNI 之前将每个本地线程附加到 JVM,线程退出前再分离。在 JNI_OnLoad 中解析应用类,因为后续本地线程无法再找到它们。每次可能抛出异常的调用后都要检查未处理异常,并使用 IsSameObject 而非 == 比较引用。

最后,让工具帮你强制执行这些规则。在 Android 上使用 CheckJNI 运行测试,在桌面端使用 -Xcheck:jni 参数,这样引用错误会在导致问题的调用处直接失败。使用 RAII 包装器确保释放引用自动完成,而不是依赖每条代码路径都记得手动处理。

合作伙伴推荐课程

(在新标签页中打开)

广告

- Udemy 上的课程:C#7 新特性详解,Dmitri Nesteruk,4.6 星(2,180 人评价),2,180 人评分,5小时14分钟,中级,证书,查看课程 (在新标签页中打开)

- Coursera 上的课程:Java 与线程函数式编程,Douglas C. Schmidt 博士和 Jules White 博士,4.2 星(13 人评价),13 人评分,4周,每周5小时,中级,证书,查看课程 (在新标签页中打开)

- Udemy 上的课程:使用 Executor 实现 Java 多线程与并发,Arun Kumar,4.5 星(1,296 人评价),1,296 人评分,5小时33分钟,初级,证书,查看课程 (在新标签页中打开)

- Skillshare 上的课程:使用 Photoshop 创建角色插画,Ramona Kaulitzki,4.4 星(74 人评价),74 人评分,1小时27分钟,初级,查看课程 (在新标签页中打开)

更多 Java 课程请访问 Class Central

→

阅读更多文章。

如果这篇文章对你有帮助,请分享。

免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成为开发者。立即开始