← 文章 / 编程开发
freeCodeCamp 2小时前 · 2026-10-05 04:29:24 · 7 阅读

正确管理局部与全局引用,避免 JNI 崩溃

大多数 JNI 崩溃并非源于复杂的逻辑,而是由对象引用管理中的少量常见错误导致:在引用失效后仍继续持有、引用创建速度快于释放速度,或者将引用传递给不允许使用该引用的线程。

这类 bug 极难追踪,因为崩溃发生的位置往往远离引发问题的代码行,有时甚至滞后数分钟,直至垃圾回收器内部才显现。

Java 原生接口(Java Native Interface,JNI)允许 C 和 C++ 代码调用 JVM 并操作 Java 对象。原生代码无法直接获取 Java 对象的原始指针,而是获取一个引用。每种引用类型都有各自的有效性时长规则、线程访问限制以及释放责任归属。一旦理清这些规则,大多数 JNI 崩溃就能轻松预防和诊断。

本文将解析局部引用(Local Reference)、全局引用(Global Reference)和弱全局引用(Weak Global Reference)的工作机制,针对常见错误提供修复前后的代码对比,并介绍能在这些问题流入生产环境之前捕捉它们的工具和模式(如 CheckJNI 和 RAII 封装器)。

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

目录

前置要求

你需要能读懂 C++ 和 Java 代码,并且写过或至少读过基本的 JNI 函数(即用 extern "C" JNIEXPORT 声明、由 Java 的 native 方法调用的那种)。

了解 Android NDK 对理解 Android 相关的部分有帮助,但掌握核心内容并不需要它。

JNI 引用是如何工作的

Java 对象位于托管堆上,垃圾回收器在压缩内存时可以随意移动它。

如果原生代码持有指向该对象的裸指针,那么在下一次内存压缩后,这个指针就会悄无声息地失效。JNI 的做法是给原生代码一个不透明的句柄,从而避免这个问题。jobject、jclass、jstring、jobjectArray 等类型都属于这类句柄。

 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 个条目,超出会导致进程因 local reference table overflow (max=512) 而终止。较新的 ART 版本大幅提高了上限,但在无界循环中创建局部引用的代码最终仍会耗尽容量。

陷阱 1:循环中泄漏局部引用

最常见的局部引用 bug 是循环中每次迭代创建新引用却从不释放。

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 个名称时,引用表会迅速填满导致进程中断。这个 bug 尤其容易被忽视,因为单元测试通常只使用小数组。

直接的修复方法是,当引用不再需要时立即删除。

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 正是为了处理这种情况而设计的。

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 个引用。此后创建的所有局部引用都归属于这个新 frame。调用 PopLocalFrame(nullptr) 会一次性释放它们,因此每轮循环结束时,user、name 和 email 会一起被释放。

如果 PushLocalFrame 返回非零值,说明 JVM 无法分配该 frame,此时已有一个 OutOfMemoryError` 待抛出,函数应立即返回。

如果需要让某个引用在 frame 释放后依然存活,可以把该引用传给 PopLocalFrame 而不是 nullptr,函数会返回一个属于外层 frame 的新局部引用。

陷阱 2:把局部引用缓存到静态变量中

查找类和方法 ID 的开销相对较大,所以 native 库通常会缓存它们。常见的错误是把类以局部引用的形式缓存下来。

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,则会导致随机崩溃,或调用错误地落在不匹配的对象上。

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

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 (gGetName == nullptr) return JNI_ERR;

NewGlobalRef 为同一个类创建了一个新的引用,该引用在 JNI_OnLoad 返回后依然有效。由于不再需要原始的局部引用,代码立即将其删除。每一步都会检查 nullptr,因为当类或方法不存在时(例如经过混淆工具重命名后),FindClass 和 GetMethodID 会返回 nullptr 并保留一个待处理异常。

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

全局引用

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

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

全局引用同样存放在一个大小固定的表中。在 Android 上,该表的上限为 51,200 个条目,一旦超限,进程会因 global reference table overflow 而中止。

陷阱 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 中的旧句柄。旧引用仍存在于表中,但没有任何变量再指向它,因此永远无法被删除。每个泄露的引用都会固定住一个 listener 对象,而 listener 通常是内部类,往往持有一个完整 Activity 或 Fragment 的引用。后果是内存泄露会随着每次屏幕旋转而不断累积,最终导致引用表溢出并引发崩溃。

修正后的版本会释放旧引用,并为 Java 侧提供清除 listener 的机制。

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

这个版本在创建新引用之前,先删除已有的全局引用,从而保证任意时刻最多只存在一个 listener 引用。当 Java 侧传入 null 时,将完全清除 listener,这使得 Java 侧能在 onDestroy 或 close() 中干净地释放资源。这里使用互斥锁保护 gListener,因为当主线程正在替换 listener 时,原生工作线程可能正在读取它。如果没有锁保护,工作线程可能会读取到刚刚被删除的句柄。

一个良好的实践习惯是:为每一个 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
  }
}

这段代码先检查 gCallback 背后的对象是否已被回收,没有的话就调用它的方法。问题在于,检查和调用是两个独立步骤,垃圾回收器可能恰好在这两者之间运行。一旦发生,CallVoidMethod 拿到的就是一个已不存在的对象的引用。这是教科书级的 TOCTOU(检查时机与使用时机不一致)竞态,而且只在内存压力大的时候才会暴露出来。

安全的做法是先把弱引用提升为局部引用。

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

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

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

陷阱 5:跨线程共享 JNIEnv

传入每个 native 方法的 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();
}

该 native 方法保存了 JNIEnv*,随后启动 worker 线程,在下载完成后使用该指针。此时保存的指针已属于另一个线程(即调用 start 的那个线程),而该线程可能同时正在执行其他 JNI 代码。在 ART 且开启 CheckJNI 时,会立即报错,提示 JNIEnv 被用于错误线程;关闭 CheckJNI 时,则会导致不可预测的内存损坏。

正确做法是缓存所有线程共享的 JavaVM*,让每个线程自行 attach 获取专属的 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();
}

JNI_OnLoad 保存了 JavaVM*,其生命周期与进程一致。worker 线程调用 AttachCurrentThread 向 JVM 注册自身并获取专属 JNIEnv*,退出前调用 DetachCurrentThread。Android NDK 将 AttachCurrentThread 声明为接收 JNIEnv**,而桌面 JDK 头文件接收 void**,因此桌面端代码需要使用 reinterpret_cast<void**>(&env)。

Detach 不是可选项。在已 attach 的 native 线程上创建的局部引用不会自动释放,因为没有 native 方法返回来触发清理,只有在 detach 时才会释放。

在 Android 上,如果原生线程在仍保持 attached 状态时退出,会导致 ART 终止整个进程。若线程在整个生命周期内都处于 attached 状态,通常的做法是注册一个 pthread_key_create 析构函数,在线程退出时调用 DetachCurrentThread,从而确保任何代码路径都不会遗漏这一步。

另外请注意,gDownloaderClass 必须是全局引用,原因见陷阱 2 的说明。Local references 无法跨越线程,这一点与 JNIEnv* 的限制相同。

陷阱 6:在原生线程中调用 FindClass

一个相关的意外情况出现在新附着的原生线程尝试使用 FindClass 查找应用自身类的场景中。

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

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

然而,由原生代码创建并通过 AttachCurrentThread 附着的线程,其调用栈上没有 Java 帧。在这种情况下,JVM 会回退到系统类加载器,而它只认识 framework 类。查找失败,FindClass 返回 nullptr,并留下一个 pending 的 ClassNotFoundException。如果代码不检查返回值,下一次使用 cls 的调用将会导致崩溃。

标准解决方案是在 JNI_OnLoad(此时运行在应用类加载器环境下)中查找所有需要的类,将每个类存储为全局引用,并避免在原生线程中调用 FindClass。如果必须动态解析类,请在 JNI_OnLoad 期间缓存应用 ClassLoader 对象的全局引用,然后在 worker 线程中调用其 loadClass 方法。

陷阱 7:忽略 Pending 异常

当 JNI 调用失败,或者从原生代码调用的 Java 代码抛出异常时,异常不会展开(unwind)原生栈。它被记录为 pending,原生函数继续执行。在异常 pending 期间继续调用大多数 JNI 函数属于未定义行为。

jstring name = static_cast<jstring>(env->CallObjectMethod(user, gGetName));
const char* utf = env->GetStringUTFChars(name, nullptr);  // Bug if getName threw

如果 getName() 抛出异常,CallObjectMethod 会返回 nullptr 并保留该异常状态。下一行在异常仍未处理的情况下,将该 nullptr 传入 GetStringUTFChars,同时违反了两条规则。启用 CheckJNI 时,ART 会直接中止程序,并显示类似 JNI GetStringUTFChars called with pending exception 的错误信息。若未启用,进程通常因空指针解引用而崩溃。

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;
}

修复后的代码在所有可能抛出异常的调用之后,都会检查 ExceptionCheck(),若存在待处理异常则立即返回。在有待处理异常的情况下从 native 方法返回是允许且有用的:一旦控制权回到 Java 层,异常会被重新抛出,因此 Java 调用方会将其视为来自 native 方法的普通异常。

如果 native 代码希望自行处理该故障,应在进行其他 JNI 调用前先调用 ExceptionClear()。少数函数(如 DeleteLocalRef、DeleteGlobalRef 及各种 Release 函数)在异常挂起时明确允许执行,因此在返回前仍可执行清理工作。

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

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

if (listener == gListener) {  // Bug: compares handles, not objects
  RemoveListener();
}

在此比较中,listener 是传入当前调用的局部引用,而 gListener 是更早创建的全局引用。即使两者指向同一个 Java 对象,它们也位于不同的表中的不同槽位,因此句柄值不同,比较结果为假。导致监听器始终无法被移除。

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

IsSameObject 让 JVM 判断两个句柄是否指向同一个对象,这正是 Java 中 == 运算符的实际语义。将 nullptr 作为第二个参数,也是检查弱全局引用是否已释放的标准做法。不过,正如陷阱 4 所示,如果之后还要使用该对象,改用 NewLocalRef 会更安全。

如何用 CheckJNI 捕获引用错误

本文中描述的多数 bug 要么偶然能跑通,要么在真实错误不相关的地方崩溃。CheckJNI 是一种增强验证模式,能让这些问题立即、显著地失败,并给出指向错误调用的提示信息。

在 Android 上,CheckJNI 会自动为 android:debuggable="true" 构建的应用程序以及模拟器启用。若要强制在 user build 的物理设备上为所有应用开启,请运行以下命令,然后重启应用。

adb shell setprop debug.checkjni 1

这会设置一个系统属性,指示 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

这会在启用 HotSpot 的 JNI 检查的同时运行应用程序,并从 build/libs 加载原生库。HotSpot 会对许多相同的问题(如存在未处理异常时的调用或无效引用)发出警告。不过,它的检查通常比 ART 宽松,因此在桌面环境下通过 -Xcheck:jni 干净运行的应用,在 Android 上仍可能无法通过 CheckJNI。在两个平台上启用 CheckJNI 运行测试,包括传递大数组和在原生线程上执行工作的测试,能在发布前尽早捕获大多数引用 bug。

如何用 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_;
};

ScopedLocalRef 接管一个局部引用,并在析构函数中将其删除。这样无论外层作用域以何种方式退出(正常结束、提前 return,还是抛出 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);
}

每次迭代为当前数组元素构造一个 ScopedLocalRef,析构函数会在迭代结束或提前 return 时执行。整个循环里没有任何显式的 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_;
};

构造函数会将接收到的任意本地或全局引用转换为全局引用,并记录 JavaVM* 指针。析构函数调用 GetEnv 获取当前线程的 JNIEnv*,仅当该线程已附加到 JVM 时才删除引用。

如果析构函数在未附加到 JVM 的线程上运行,这个简化版本会导致引用泄漏,而不是崩溃。生产环境版本通常会临时附加线程,或记录泄漏日志以便发现。Android 平台代码在其 ScopedLocalRef 辅助类中采用了相同模式;如需现成且经过测试的包装器,也可使用 fbjni 等库提供的完整实现。

引用类型对比

属性 本地引用 全局引用 弱全局引用
创建方式 大多数 JNI 函数 NewGlobalRef NewWeakGlobalRef
有效期至 本地方法返回 DeleteGlobalRef DeleteWeakGlobalRef
能否跨线程使用 否 是 是
是否保持对象存活 是 是 否
是否自动释放 是,方法返回或线程分离时 否 否
典型用途 单次调用中的临时值 缓存类、持有的回调 指向 native 代码不应持有的对象的回调

上表概括了三种引用类型。“有效期至”说明句柄何时停止可用,“是否自动释放”说明能否依赖 JVM 进行清理。

核心要点在于:局部引用(local reference)是唯一由 JVM 自动清理的引用类型,这种便利性也决定了它不能被长期存储或跨范围共享。全局引用(global reference)可以存储和共享,但每一个都是持久的 GC 根对象,直到你主动删除它为止。弱全局引用(weak global reference)同样可以存储和共享,且不会阻止对象被回收,但每次使用前都必须将其提升为局部引用。

总结

每个 JNI 引用本质上都是指向 JVM 托管表槽位的句柄,而引用类型的定义核心在于该槽位何时被释放。局部引用随本地方法返回而消失,且仅属于单个线程;全局引用存活直至被显式删除,可在任何地方使用;弱全局引用存活直至被删除,但不会维持对象的生命周期。

大多数 JNI 崩溃直接源于违反上述规则。在循环中创建局部引用而不释放,会导致局部引用表溢出,这可以通过 DeleteLocalRef 或配合 PushLocalFrame 与 PopLocalFrame 来防止。若将局部引用存储以便日后使用,会遗留一个失效句柄,需用 NewGlobalRef 提升为全局引用来修复。创建全局引用却不删除会导致内存泄漏,最终可能溢出全局引用表。未先通过 NewLocalRef 提升弱引用就直接使用,会与垃圾回收器产生竞态条件。

线程规则同样关键。JNIEnv* 绑定于特定线程,因此需缓存 JavaVM*,并在每个原生线程使用 JNI 前进行 attach(附加),在线程退出前 detach(分离)。应在 JNI_OnLoad 中解析应用类,因为原生线程后续无法自行查找。每次调用可能抛出异常的方法后,都要检查是否存在待处理异常;比较引用时应使用 IsSameObject,而非 ==。

最后,利用工具自动强制执行这些规则。在 Android 上使用 CheckJNI、在桌面端使用 -Xcheck:jni 运行测试,可使引用错误在引发错误的调用点即刻暴露;使用 RAII 封装器可让引用的释放自动发生,无需依赖每条代码路径都记得手动释放。

原始来源: freeCodeCamp

评论 (0)