About this course
<p>Most JNI crashes don't come from complicated logic. They come from a small set of mistakes around object references: holding on to a reference after it has become invalid, creating references faster than they're released, or passing a reference to a thread that was never allowed to use it.</p>
<p>These bugs are hard to track down because the crash often happens far away from the line that caused it, sometimes minutes later, inside the garbage collector.</p>
<p>The Java Native Interface gives C and C++ code a way to call into the JVM and work with Java objects. Native code never gets a raw pointer to a Java object. It gets a reference, and each kind of reference comes with its own rules about how long it stays valid, which threads can use it, and who's responsible for freeing it. Once those rules are clear, most JNI crashes become easy to prevent and easy to diagnose.</p>
<p>This article explains how local, global, and weak global references work, walks through the most common mistakes with broken and fixed code for each, and finishes with tools and patterns (CheckJNI and RAII wrappers) that catch these problems before they reach production.</p>
<p>The examples use C++ and Android conventions, but the reference rules come from the JNI specification and apply to any JVM.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-jni-references-work">How JNI References Work</a></p>
</li>
<li><p><a href="#heading-local-references">Local References</a></p>
</li>
<li><p><a href="#heading-pitfall-1-leaking-local-references-in-a-loop">Pitfall 1: Leaking Local References in a Loop</a></p>
</li>
<li><p><a href="#heading-pitfall-2-caching-a-local-reference-in-a-static-variable">Pitfall 2: Caching a Local Reference in a Static Variable</a></p>
</li>
<li><p><a href="#heading-global-references">Global References</a></p>
</li>
<li><p><a href="#heading-pitfall-3-leaking-global-references">Pitfall 3: Leaking Global References</a></p>
</li>
<li><p><a href="#heading-weak-global-references">Weak Global References</a></p>
</li>
<li><p><a href="#heading-pitfall-4-using-a-weak-global-reference-directly">Pitfall 4: Using a Weak Global Reference Directly</a></p>
</li>
<li><p><a href="#heading-pitfall-5-sharing-jnienv-across-threads">Pitfall 5: Sharing JNIEnv Across Threads</a></p>
</li>
<li><p><a href="#heading-pitfall-6-calling-findclass-from-a-native-thread">Pitfall 6: Calling FindClass from a Native Thread</a></p>
</li>
<li><p><a href="#heading-pitfall-7-ignoring-pending-exceptions">Pitfall 7: Ignoring Pending Exceptions</a></p>
</li>
<li><p><a href="#heading-pitfall-8-comparing-references-with-the-equality-operator">Pitfall 8: Comparing References with the Equality Operator</a></p>
</li>
<li><p><a href="#heading-how-to-catch-reference-bugs-with-checkjni">How to Catch Reference Bugs with CheckJNI</a></p>
</li>
<li><p><a href="#heading-how-to-manage-references-safely-with-raii">How to Manage References Safely with RAII</a></p>
</li>
<li><p><a href="#heading-reference-types-compared">Reference Types Compared</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You should be comfortable reading C++ and Java, and you should have written or at least read a basic JNI function (one declared with <code>extern "C" JNIEXPORT</code> and called from a Java <code>native</code> method).</p>
<p>Familiarity with the Android NDK helps for the Android specific parts, but it's not required to follow the core ideas.</p>
<h2 id="heading-how-jni-references-work">How JNI References Work</h2>
<p>A Java object lives on the managed heap, and the garbage collector is free to move it whenever it compacts memory.</p>
<p>If native code held a raw pointer to that object, the pointer would silently go stale after the next compaction. JNI avoids this by handing native code an opaque handle instead. Types like <code>jobject</code>, <code>jclass</code>, <code>jstring</code>, and <code>jobjectArray</code> are all handles of this kind.</p>
<pre><code class="language-plaintext"> Native code JVM reference tables Managed heap
+-------------+ +----------------------+ +------------+
| jobject h + --------> | slot 17 : ptr + ------> | User obj |
+-------------+ +----------------------+ +------------+
(GC updates the ptr
when the object moves)
</code></pre>
<p>The diagram above shows the indirection that makes JNI references safe. Native code holds a handle (<code>h</code>), which points at a slot in a reference table owned by the JVM. That slot holds the real address of the object on the managed heap.</p>
<p>When the garbage collector moves the object, it updates the pointer stored in the slot, and the handle in native code stays valid. The slot also acts as a GC root, which means that as long as the slot exists, the object it points to can't be collected.</p>
<p>Every rule about references comes down to one question: when does that slot get freed?</p>
<p>JNI has three kinds of references, and they differ in exactly that respect. A local reference's slot is freed automatically when the native method returns. A global reference's slot lives until native code explicitly deletes it. A weak global reference also lives until deleted, but it doesn't keep the object alive, so the object behind it can disappear at any time.</p>
<h2 id="heading-local-references">Local References</h2>
<p>Almost every JNI function that returns a Java object returns a local reference. <code>FindClass</code>, <code>NewStringUTF</code>, <code>GetObjectArrayElement</code>, <code>CallObjectMethod</code>, and <code>GetObjectClass</code> all do this. Arguments passed into a native method (including <code>thiz</code> or the <code>jclass</code> for static methods) are local references too.</p>
<p>A local reference is valid only within the native method call that created it, and only on the thread that created it. When the native method returns to Java, the JVM frees every local reference created during that call in one step. This is convenient for short functions, and it's the reason most JNI code never calls <code>DeleteLocalRef</code> at all.</p>
<p>The convenience has limits. The local reference table has a fixed capacity per call frame. The JNI specification only guarantees room for 16 local references, and you must call <code>EnsureLocalCapacity</code> if you need more.</p>
<p>In practice, JVMs give you much more than that. Older Android versions capped the table at 512 entries, and exceeding it aborted the process with <code>local reference table overflow (max=512)</code>. Newer versions of ART raised the limit considerably, but any code that creates local references in an unbounded loop will still eventually run out.</p>
<h2 id="heading-pitfall-1-leaking-local-references-in-a-loop">Pitfall 1: Leaking Local References in a Loop</h2>
<p>The most common local reference bug is a loop that creates a new reference on every iteration and never releases it.</p>
<pre><code class="language-cpp">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);
}
}
</code></pre>
<p>This function iterates over a Java <code>String[]</code> and logs each entry. Each call to <code>GetObjectArrayElement</code> creates a new local reference and stores it in the table.</p>
<p>The code correctly releases the UTF-8 buffer with <code>ReleaseStringUTFChars</code>, but that only frees the character data. It does nothing for the <code>jstring</code> reference itself.</p>
<p>With 10 names this works fine. With 100,000 names, the table fills up and the process aborts. The bug is especially easy to miss because unit tests usually pass small arrays.</p>
<p>The direct fix is to delete each reference once it's no longer needed.</p>
<pre><code class="language-cpp">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);
}
</code></pre>
<p>The loop now calls <code>DeleteLocalRef</code> at the end of every iteration and on the early return path, so the table never holds more than one reference from this loop at a time.</p>
<p>The early return matters: <code>GetStringUTFChars</code> returns <code>nullptr</code> when it fails to allocate memory, and in that case it also leaves an <code>OutOfMemoryError</code> pending, so returning to Java right away is the correct response.</p>
<p>When an iteration creates several references (a class, a method result, a string), deleting each one by hand becomes tedious and error prone. <code>PushLocalFrame</code> and <code>PopLocalFrame</code> handle that case.</p>
<pre><code class="language-cpp">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);
}
</code></pre>
<p><code>PushLocalFrame(8)</code> opens a new scope in the local reference table with room for at least 8 references. Every local reference created after that point belongs to the new frame. <code>PopLocalFrame(nullptr)</code> frees all of them at once, so <code>user</code>, <code>name</code>, and <code>email</code> are released together at the end of each iteration.</p>
<p>If <code>PushLocalFrame</code> returns a nonzero value, the JVM couldn't allocate the frame and an <code>OutOfMemoryError</code> is pending, so the function returns immediately.</p>
<p>If you need one reference to survive the frame, pass it to <code>PopLocalFrame</code> instead of <code>nullptr</code>, and the function returns a new local reference to that object in the outer frame.</p>
<h2 id="heading-pitfall-2-caching-a-local-reference-in-a-static-variable">Pitfall 2: Caching a Local Reference in a Static Variable</h2>
<p>Looking up classes and method IDs is relatively slow, so native libraries often cache them. The mistake is caching the class as a local reference.</p>
<pre><code class="language-cpp">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;
}
</code></pre>
<p><code>JNI_OnLoad</code> runs once when the library is loaded, and this version stores the result of <code>FindClass</code> in a static variable for later use. The problem is that <code>FindClass</code> returns a local reference, and that reference is freed as soon as <code>JNI_OnLoad</code> returns. Every later use of <code>gUserClass</code> goes through a slot that has already been released and may have been reused for a completely different object.</p>
<p>The frustrating part is that this code often appears to work. On some JVMs a stale handle still happens to point at the right class for a while. On ART with CheckJNI enabled, the process aborts with an error along the lines of <code>accessed stale local reference</code>. Without CheckJNI, you get random crashes or calls that land on the wrong object.</p>
<p>The fix is to promote the class to a global reference before storing it.</p>
<pre><code class="language-cpp">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;
</code></pre>
<p><code>NewGlobalRef</code> creates a second reference to the same class, one that survives after <code>JNI_OnLoad</code> returns. The original local reference is no longer needed, so the code deletes it right away. Every call is checked for <code>nullptr</code>, because <code>FindClass</code> and <code>GetMethodID</code> return <code>nullptr</code> and leave a pending exception when the class or method doesn't exist (for example, after an obfuscation tool renames it).</p>
<p>The <code>jmethodID</code> doesn't need the same treatment. Method IDs and field IDs aren't object references, so they're not tracked in any reference table. They stay valid as long as the class they belong to is loaded. Holding a global reference to the class guarantees it won't be unloaded, which is one more reason to cache the class globally alongside its IDs.</p>
<h2 id="heading-global-references">Global References</h2>
<p>A global reference is created explicitly with <code>NewGlobalRef</code> and stays valid until native code calls <code>DeleteGlobalRef</code>. It can be used from any thread, and it keeps the referenced object alive for its entire lifetime. That makes it the right tool for anything native code needs to keep across calls: cached classes, callback objects, or a Java object that owns a native resource.</p>
<p>The cost is that the JVM will never free a global reference on its own. The garbage collector treats it as a root, so the object and everything reachable from it stays in memory until the reference is deleted.</p>
<p>Global references also live in a table with a fixed size. On Android, that limit is 51,200 entries, and exceeding it aborts the process with <code>global reference table overflow</code>.</p>
<h2 id="heading-pitfall-3-leaking-global-references">Pitfall 3: Leaking Global References</h2>
<p>Global reference leaks usually come from code that creates a new global reference each time something is registered, without releasing the previous one.</p>
<pre><code class="language-cpp">static jobject gListener;
extern "C" JNIEXPORT void JNICALL
Java_com_example_Sensor_setListener(JNIEnv* env, jobject thiz, jobject listener) {
gListener = env->NewGlobalRef(listener);
}
</code></pre>
<p>Every call to <code>setListener</code> creates a new global reference and overwrites the old handle stored in <code>gListener</code>. The old reference is still in the table, but nothing points to it anymore, so it can never be deleted. Each leaked reference also pins a listener object, and a listener is often an inner class that holds a reference to an entire Activity or Fragment. The result is a memory leak that grows with every screen rotation, and eventually a crash when the table overflows.</p>
<p>A corrected version releases the previous reference and gives Java a way to clear the listener.</p>
<pre><code class="language-cpp">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></pre>
<p>This version deletes any existing global reference before creating a new one, so at most one listener reference exists at any time. Passing <code>null</code> from Java clears the listener entirely, which gives the Java side a clean way to release it in <code>onDestroy</code> or <code>close()</code>. The mutex protects <code>gListener</code> because the listener might be read from a native worker thread while Java is replacing it on the main thread. Without the lock, the worker could read a handle that was deleted a moment earlier.</p>
<p>A good habit is to pair every <code>NewGlobalRef</code> with a clearly identified place where the matching <code>DeleteGlobalRef</code> happens. If you can't point to that place, the reference will probably leak.</p>
<h2 id="heading-weak-global-references">Weak Global References</h2>
<p>A weak global reference, created with <code>NewWeakGlobalRef</code>, works like a global reference in that it survives across calls and can be used from any thread. The difference is that it doesn't keep the object alive. If nothing else in Java refers to the object, the garbage collector can collect it, and the weak reference then refers to <code>null</code>.</p>
<p>Weak references are useful when native code wants to call back into a Java object without controlling that object's lifetime. A native audio engine that notifies a UI component is a typical example. The engine shouldn't keep the UI alive after the user has left the screen, but while the UI exists, the engine should be able to reach it.</p>
<h2 id="heading-pitfall-4-using-a-weak-global-reference-directly">Pitfall 4: Using a Weak Global Reference Directly</h2>
<p>The mistake with weak references is using them as if they were strong ones.</p>
<pre><code class="language-cpp">static jweak gCallback;
void NotifyProgress(JNIEnv* env, jint percent) {
if (!env->IsSameObject(gCallback, nullptr)) {
env->CallVoidMethod(gCallback, gOnProgress, percent); // Bug: race with GC
}
}
</code></pre>
<p>The code checks whether the object behind <code>gCallback</code> has been collected, and if not, calls a method on it. The check and the call are two separate steps, and the garbage collector can run between them. If it does, <code>CallVoidMethod</code> receives a reference to an object that no longer exists. This is a textbook time-of-check to time-of-use race, and it will only show up under memory pressure.</p>
<p>The safe approach is to promote the weak reference to a local reference first.</p>
<pre><code class="language-cpp">void NotifyProgress(JNIEnv* env, jint percent) {
jobject callback = env->NewLocalRef(gCallback);
if (callback == nullptr) return;
env->CallVoidMethod(callback, gOnProgress, percent);
env->DeleteLocalRef(callback);
}
</code></pre>
<p><code>NewLocalRef</code> either returns <code>nullptr</code> (the object has been collected) or returns a strong local reference. Once the code holds that local reference, the garbage collector can't collect the object until the reference is deleted, so the call is safe.</p>
<p><code>NewLocalRef</code> was added in JNI 1.2 and is available on every modern JVM and on all versions of Android. When the weak reference itself is no longer needed, free it with <code>DeleteWeakGlobalRef</code>, not <code>DeleteGlobalRef</code>.</p>
<h2 id="heading-pitfall-5-sharing-jnienv-across-threads">Pitfall 5: Sharing JNIEnv Across Threads</h2>
<p>The <code>JNIEnv*</code> pointer passed into every native method is tied to the current thread. It points to per-thread state such as the local reference table and the pending exception. Storing it in a global and using it from another thread corrupts that state.</p>
<pre><code class="language-cpp">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></pre>
<p>The native method saves its <code>JNIEnv*</code> and then starts a worker thread that uses it after the download completes. By that point, the saved pointer belongs to a different thread (the one that called <code>start</code>), and that thread may be running other JNI code at the same moment. On ART with CheckJNI, this fails immediately with a message saying the <code>JNIEnv</code> was used on the wrong thread. Without CheckJNI, it causes unpredictable corruption.</p>
<p>The correct pattern is to cache the <code>JavaVM*</code>, which is shared across all threads, and have each thread attach itself to obtain its own <code>JNIEnv*</code>.</p>
<pre><code class="language-cpp">static JavaVM* gVm;
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
gVm = vm;
// Cache classes and method IDs here.
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></pre>
<p><code>JNI_OnLoad</code> stores the <code>JavaVM*</code>, which stays valid for the lifetime of the process. The worker thread calls <code>AttachCurrentThread</code> to register itself with the JVM and receive a <code>JNIEnv*</code> of its own, then calls <code>DetachCurrentThread</code> before it exits. The Android NDK declares <code>AttachCurrentThread</code> as taking a <code>JNIEnv**</code>, while the desktop JDK header takes a <code>void**</code>, so desktop code needs a <code>reinterpret_cast<void**>(&env)</code>.</p>
<p>Detaching isn't optional. Local references created on an attached native thread are never freed automatically, because there's no native method return to trigger the cleanup. They're only freed on detach.</p>
<p>On Android, a native thread that exits while still attached causes ART to abort the process. If a thread is attached for its entire life, a common approach is to register a <code>pthread_key_create</code> destructor that calls <code>DetachCurrentThread</code> when the thread exits, so no code path can forget it.</p>
<p>Also note that <code>gDownloaderClass</code> must be a global reference, for the reasons covered in Pitfall 2. Local references can't cross threads any more than <code>JNIEnv*</code> can.</p>
<h2 id="heading-pitfall-6-calling-findclass-from-a-native-thread">Pitfall 6: Calling FindClass from a Native Thread</h2>
<p>A related surprise shows up when a newly attached native thread calls <code>FindClass</code> for one of the app's own classes.</p>
<pre><code class="language-cpp">void WorkerThread() {
JNIEnv* env = nullptr;
gVm->AttachCurrentThread(&env, nullptr);
jclass cls = env->FindClass("com/example/Downloader"); // Returns nullptr
// ...
gVm->DetachCurrentThread();
}
</code></pre>
<p>When <code>FindClass</code> is called from inside a native method, the JVM uses the class loader of the Java class that declared that method, so application classes resolve correctly.</p>
<p>A thread created in native code and attached with <code>AttachCurrentThread</code> has no Java frames on its stack. In that case, the JVM falls back to the system class loader, which only knows about framework classes. The lookup fails, <code>FindClass</code> returns <code>nullptr</code>, and a <code>ClassNotFoundException</code> is left pending. Code that doesn't check the return value then crashes on the next call that uses <code>cls</code>.</p>
<p>The standard solution is to look up every class you need in <code>JNI_OnLoad</code> (which runs with the application's class loader), store each one as a global reference, and never call <code>FindClass</code> from native threads. If a class has to be resolved dynamically, cache a global reference to the application's <code>ClassLoader</code> object during <code>JNI_OnLoad</code> and call its <code>loadClass</code> method from the worker thread instead.</p>
<h2 id="heading-pitfall-7-ignoring-pending-exceptions">Pitfall 7: Ignoring Pending Exceptions</h2>
<p>When a JNI call fails or when Java code invoked from native code throws, the exception doesn't unwind the native stack. It's recorded as pending, and the native function keeps running. Continuing to call most JNI functions while an exception is pending is undefined behavior.</p>
<pre><code class="language-cpp">jstring name = static_cast<jstring>(env->CallObjectMethod(user, gGetName));
const char* utf = env->GetStringUTFChars(name, nullptr); // Bug if getName threw
</code></pre>
<p>If <code>getName()</code> throws, <code>CallObjectMethod</code> returns <code>nullptr</code> and leaves the exception pending. The next line passes that <code>nullptr</code> into <code>GetStringUTFChars</code> while an exception is still pending, which breaks two rules at once. With CheckJNI, ART aborts with a message such as <code>JNI GetStringUTFChars called with pending exception</code>. Without it, the process usually crashes with a null dereference.</p>
<pre><code class="language-cpp">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></pre>
<p>After every call that can throw, the fixed code checks <code>ExceptionCheck()</code> and returns immediately if an exception is pending. Returning from a native method with a pending exception is allowed and useful: the exception is rethrown in Java as soon as control returns there, so the Java caller sees it as a normal exception from the <code>native</code> method.</p>
<p>If native code wants to handle the failure itself instead, it calls <code>ExceptionClear()</code> before making any further JNI calls. A small set of functions, such as <code>DeleteLocalRef</code>, <code>DeleteGlobalRef</code>, and the <code>Release</code> functions, are explicitly allowed while an exception is pending, so cleanup can still run before returning.</p>
<h2 id="heading-pitfall-8-comparing-references-with-the-equality-operator">Pitfall 8: Comparing References with the Equality Operator</h2>
<p>Because references are handles and not object addresses, two different handles can refer to the same Java object.</p>
<pre><code class="language-cpp">if (listener == gListener) { // Bug: compares handles, not objects
RemoveListener();
}
</code></pre>
<p>In this comparison, <code>listener</code> is a local reference passed into the current call and <code>gListener</code> is a global reference created earlier. Even if both refer to the same Java object, they occupy different slots in different tables, so the handles are different values and the comparison is false. The listener is never removed.</p>
<pre><code class="language-cpp">if (env->IsSameObject(listener, gListener)) {
RemoveListener();
}
</code></pre>
<p><code>IsSameObject</code> asks the JVM whether both handles resolve to the same object, which is the Java <code>==</code> semantics you actually want. Passing <code>nullptr</code> as the second argument is also the standard way to check whether a weak global reference has been cleared, although as Pitfall 4 showed, <code>NewLocalRef</code> is safer when you intend to use the object afterward.</p>
<h2 id="heading-how-to-catch-reference-bugs-with-checkjni">How to Catch Reference Bugs with CheckJNI</h2>
<p>Most of the bugs in this article either work by accident or crash somewhere unrelated to the real mistake. CheckJNI is an extended validation mode that makes them fail loudly and immediately, with a message pointing at the bad call.</p>
<p>On Android, CheckJNI is enabled automatically for apps built with <code>android:debuggable="true"</code> and on emulators. To force it on for all apps on a physical device with a user build, run the following command and then restart the app.</p>
<pre><code class="language-bash">adb shell setprop debug.checkjni 1
</code></pre>
<p>This sets a system property that tells ART to enable CheckJNI for every app process started afterward. It doesn't affect processes that are already running, which is why the app needs a restart.</p>
<p>With it enabled, ART validates every JNI call: it rejects stale local references, references used on the wrong thread, calls made with a pending exception, <code>JNIEnv*</code> pointers used from the wrong thread, mismatched <code>Release</code> calls, and invalid method IDs. When a check fails, ART prints <code>JNI DETECTED ERROR IN APPLICATION</code> in logcat along with a description of the problem and a native stack trace, then aborts.</p>
<p>On a desktop JVM, pass the <code>-Xcheck:jni</code> flag instead.</p>
<pre><code class="language-bash">java -Xcheck:jni -Djava.library.path=build/libs -jar app.jar
</code></pre>
<p>This runs the application with HotSpot's JNI checking enabled and loads native libraries from <code>build/libs</code>. HotSpot prints warnings for many of the same problems, such as calls made with a pending exception or invalid references. Its checks are generally less strict than ART's, so an app that runs cleanly under <code>-Xcheck:jni</code> on the desktop can still fail CheckJNI on Android. Running tests with CheckJNI enabled on both platforms, including tests that pass large arrays and run work on native threads, catches most reference bugs well before release.</p>
<h2 id="heading-how-to-manage-references-safely-with-raii">How to Manage References Safely with RAII</h2>
<p>Manual <code>DeleteLocalRef</code> and <code>DeleteGlobalRef</code> calls are easy to forget, especially on early return paths. C++ lets you tie the lifetime of a reference to a scope, the same way <code>std::unique_ptr</code> manages heap memory.</p>
<pre><code class="language-cpp">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></pre>
<p><code>ScopedLocalRef</code> takes ownership of a local reference and deletes it in its destructor, so the reference is released no matter how the enclosing scope exits (normal completion, an early <code>return</code>, or a C++ exception). The copy constructor and copy assignment are deleted because two wrappers holding the same handle would both try to delete it. The <code>get()</code> method returns the raw handle for passing into JNI functions.</p>
<p>With this wrapper, the loop from Pitfall 1 becomes shorter and can't leak.</p>
<pre><code class="language-cpp">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></pre>
<p>Each iteration constructs a <code>ScopedLocalRef</code> for the array element, and the destructor runs at the end of the iteration or on the early <code>return</code>. There's no explicit <code>DeleteLocalRef</code> call anywhere, and adding another return path later can't introduce a leak.</p>
<p>A global reference wrapper follows the same idea, with one important difference: the destructor might run on a thread other than the one that created the reference, so it shouldn't store a <code>JNIEnv*</code>. Instead, it stores the <code>JavaVM*</code> and obtains the current thread's environment when it needs to delete the reference.</p>
<pre><code class="language-cpp">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></pre>
<p>The constructor creates a global reference from any local or global reference it receives and records the <code>JavaVM*</code>. The destructor calls <code>GetEnv</code> to get the <code>JNIEnv*</code> for whatever thread it happens to run on, and deletes the reference only if that thread is attached to the JVM.</p>
<p>If the destructor runs on an unattached thread, this simple version leaks the reference rather than crashing. A production version would either attach temporarily or log the leak so it gets noticed. Android's platform code uses the same pattern in its own <code>ScopedLocalRef</code> helper, and libraries such as fbjni provide complete, tested versions of both wrappers if you prefer not to write your own.</p>
<h2 id="heading-reference-types-compared">Reference Types Compared</h2>
<table>
<thead>
<tr>
<th>Property</th>
<th>Local</th>
<th>Global</th>
<th>Weak Global</th>
</tr>
</thead>
<tbody><tr>
<td>Created by</td>
<td>Most JNI functions</td>
<td><code>NewGlobalRef</code></td>
<td><code>NewWeakGlobalRef</code></td>
</tr>
<tr>
<td>Valid until</td>
<td>Native method returns</td>
<td><code>DeleteGlobalRef</code></td>
<td><code>DeleteWeakGlobalRef</code></td>
</tr>
<tr>
<td>Usable from other threads</td>
<td>No</td>
<td>Yes</td>
<td>Yes</td>
</tr>
<tr>
<td>Keeps object alive</td>
<td>Yes</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td>Freed automatically</td>
<td>Yes, on return or detach</td>
<td>No</td>
<td>No</td>
</tr>
<tr>
<td>Typical use</td>
<td>Temporary values in one call</td>
<td>Cached classes, owned callbacks</td>
<td>Callbacks to objects native code shouldn't own</td>
</tr>
</tbody></table>
<p>The table summarizes the three reference types. The "Valid until" row describes when the handle stops being usable, and "Freed automatically" describes whether you can rely on the JVM to clean it up.</p>
<p>The main takeaway is that local references are the only kind the JVM cleans up for you, and that convenience is exactly why they can't be stored or shared. Global references can be stored and shared, but each one is a permanent GC root until you delete it. Weak global references can also be stored and shared without pinning the object, but you must promote them to a local reference before every use.</p>
<h2 id="heading-summary">Summary</h2>
<p>Every JNI reference is a handle to a slot in a JVM-managed table, and each reference type is defined by when that slot is freed. Local references disappear when the native method returns and belong to a single thread. Global references survive until you delete them and can be used anywhere. Weak global references survive until you delete them but don't keep the object alive.</p>
<p>Most JNI crashes follow directly from breaking one of those rules. Creating local references in a loop without releasing them overflows the local table, which <code>DeleteLocalRef</code> or <code>PushLocalFrame</code> and <code>PopLocalFrame</code> prevent. Storing a local reference for later use leaves you with a stale handle, which promoting it with <code>NewGlobalRef</code> fixes. Creating global references without deleting them leaks memory and eventually overflows the global table. Using a weak reference without first promoting it with <code>NewLocalRef</code> races with the garbage collector.</p>
<p>The threading rules matter just as much. A <code>JNIEnv*</code> belongs to one thread, so cache the <code>JavaVM*</code> and attach each native thread before using JNI, then detach it before the thread exits. Resolve application classes in <code>JNI_OnLoad</code>, because native threads can't find them later. Check for pending exceptions after every call that can throw, and compare references with <code>IsSameObject</code> rather than <code>==</code>.</p>
<p>Finally, let tools enforce these rules for you. Run your tests with CheckJNI on Android and <code>-Xcheck:jni</code> on the desktop so that reference mistakes fail at the call that caused them, and use RAII wrappers so that releasing a reference happens automatically instead of depending on every code path remembering to do it.</p>