JNI, the NDK and ABIs
JNI is the bridge between managed code and C++. It is powerful, easy to misuse, and the reason an APK contains lib/ directories for four different CPU architectures.
Kotlin is the language you write, but a lot of what your app does ends up in C or C++: a codec, a physics engine, a database, a ML runtime. The boundary between those two worlds is JNI, and it is the most error-prone part of an Android codebase.
TL;DR#
- JNI is a C ABI, not a language feature. It defines how managed code calls native code and how native code reaches back.
JNIEnvis per-thread. You cannot share it; you share theJavaVMand ask it for the current thread's environment.- Every JNI transition has a cost, and every object you pass across it needs a reference with a defined lifetime — local, global or weak global.
- The NDK is the toolchain and headers; an ABI is the machine-code contract. A 64-bit ARM library cannot be loaded by a 32-bit process.
- Memory bugs in native code are not caught by the runtime. Android's answer is hardening — CFI, MTE, scudo — plus the fact that a crash kills only that process.1
Mental model#
Think of an international conference with two languages and strict protocol.
The managed world is one room, the native world is another. There is a translator at the door (the JNI functions) who follows a fixed script. You cannot walk into the other room; you hand the translator a note and they hand back a translation.
Each translator is assigned to one thread. If a second thread needs to talk, it needs its own translator, or it has to be formally introduced to the conference (attached) before it can say anything.
Notes have a lifetime: a note written on a whiteboard lasts for the conversation (a local reference), a note pinned to the wall lasts until someone takes it down (a global reference), and a note that fades when nobody is looking at it is a weak global reference.
Where the analogy breaks#
A translator makes both sides sound the same, but JNI does not hide the seam. Types cross as
explicit jobject handles, strings cross as modified UTF-8 or UTF-16, and arrays cross either by
copy or by pinning. The seam is visible in every function signature, which is precisely why JNI code
is verbose.
The big picture#
Text description of this diagram
Two data paths compared. This diagram is used here as an analogy for the JNI seam: in the managed world, objects live in a heap the runtime moves; in the native world, memory is raw pointers. Crossing the seam means either copying data into a native buffer or pinning managed memory so the collector cannot move it. The two-copy path corresponds to copying an array with GetByteArrayElements, and the one-copy path corresponds to a direct ByteBuffer or a critical section.
The diagram's two paths map onto the two JNI strategies. Copying is safe and simple; pinning or a direct buffer avoids the copy but requires you to understand what the collector is allowed to do while you hold the pointer.
Deep dive#
The ABI, and why there are four#
An ABI is the contract between compiled code and the machine: the instruction set, the calling convention, the size of types, the name mangling. Android supports several:
arm64-v8a— 64-bit ARM, the default for modern devices.armeabi-v7a— 32-bit ARM, still present on older and low-end devices.x86_64andx86— emulators and some tablets.
An APK can ship native libraries for several ABIs under lib/<abi>/. At install, the package
manager picks the ABI that matches the device, and only that directory's libraries are used. Shipping
all of them makes the APK larger; shipping only one makes it fail on devices with a different ABI.
The sizes are not marginal. A single native library compiled for four ABIs can quadruple the APK's native payload. That is why the App Bundle exists: it delivers only the ABI the device needs, and the developer uploads all of them once.
A subtle consequence: a 64-bit app process can still load a 32-bit library only if a 32-bit process is also running, which is why dual-ABI devices sometimes run two Zygotes. The ABI is a property of the process, not of the app.
The JNIEnv and thread attachment#
JNIEnv is a pointer to a table of functions, and it is thread-local. A native function receives
one as its first argument (except for @CriticalNative methods, which have no JNIEnv at all). You
must not cache it and use it from another thread.
For a thread created natively, there is no JNIEnv until you attach:
JavaVM* vm = ...; // one per process, shared
JNIEnv* env = nullptr;
vm->AttachCurrentThread(&env, nullptr);
// ... make JNI calls ...
vm->DetachCurrentThread(); // required before the thread exitsAndroid allows one JavaVM per process. AttachCurrentThread constructs a java.lang.Thread and
adds it to the main ThreadGroup, which is why an attached native thread appears in a Java thread
dump. Calling it on an already-attached thread is a no-op.1
References#
Every object that crosses into native code arrives as a local reference. A local reference is valid for the duration of the current native call on the current thread; it is invalid when the method returns, even if the object itself lives on.
- Local reference — automatic, scoped to the native call. The implementation only has to
guarantee 16 slots, so a loop that creates references needs
DeleteLocalReforPushLocalFrame/PopLocalFrame. - Global reference — created with
NewGlobalRef, valid untilDeleteGlobalRef. Use these for anything cached across calls. - Weak global reference — does not keep the object alive; useful for caches.
Two rules that catch people:
- Never compare references with
==. Two references can point to the same object and have different values. UseIsSameObject. - Never use a
jobjectas a key. References are not stable identifiers.
Strings#
Java strings are UTF-16. JNI also supports modified UTF-8, which encodes \u0000 as 0xc0 0x80
so that C string functions can treat it as zero-terminated. Passing ordinary UTF-8 to
NewStringUTF is undefined behaviour, and CheckJNI will abort on it.1
For short strings, GetStringRegion into a stack buffer avoids an allocation and a copy:
constexpr size_t kStack = 64;
jchar buf[kStack];
jsize len = env->GetStringLength(str);
if (len <= kStack) {
env->GetStringRegion(str, 0, len, buf);
process(buf, len);
}The pattern generalises: region calls (GetStringRegion, GetByteArrayRegion,
SetIntArrayRegion) combine a get and a release into one call and avoid pinning.
Arrays and critical sections#
GetByteArrayElements may return a pointer to the actual array or allocate a copy. Either way you
must call ReleaseByteArrayElements, and the release mode matters:
0— copy back if it was a copy, unpin if it was pinned.JNI_COMMIT— copy back but do not free.JNI_ABORT— discard changes.
The trap is that skipping Release when no copy was made leaves the array pinned, which blocks
the collector from moving it. A long-lived pin is a performance problem that looks like a GC
problem.
RegisterNatives and lookup#
There are two ways to bind a native method to a C function:
- Name-based lookup — the runtime derives a symbol name from the class and method, and
dlsymfinds it. Less code, but errors are discovered at call time. RegisterNatives— explicit registration inJNI_OnLoad. Mistakes are caught at load time, onlyJNI_OnLoadneeds to be exported, and tooling can check signatures.
RegisterNatives is the recommended choice for most apps. It also solves the class-loader problem:
FindClass from a native thread with no Java frame uses the system class loader, which cannot see
app classes. Caching a global jclass from JNI_OnLoad avoids it.
Fast and critical natives#
Android has two annotations that change the transition:
@FastNative— non-static methods, still receivesJNIEnvandjobject.@CriticalNative— static only, primitives only, noJNIEnvand nojclass.
Both disable GC while the native method runs, so they must be short. The AOSP documentation claims
up to 3x and 5x speedups on the transition, and gives measured numbers on a Nexus 6P: regular JNI
115 ns, @FastNative 35 ns, @CriticalNative 25 ns.2
Those are transition costs, not call costs. A method that does real work will not be saved by the
annotation, and a @CriticalNative method that blocks will stall the collector.
Memory safety#
Native code is where the runtime cannot help you. A buffer overflow in C++ is memory corruption, not an exception. Android's mitigations are layered:
- Scudo — the hardened allocator, which detects some heap overflows and use-after-free.
- CFI — control-flow integrity, which rejects indirect calls to unexpected targets.
- MTE — memory tagging on supported ARM hardware, which detects use-after-free at the hardware level.
- SELinux and the sandbox — a native crash kills the process, and the process's permissions bound the damage.
The pattern is that Android assumes native code will have bugs and tries to make each bug fatal to one process rather than to the device.
How it evolved#
| API | Version | Released | What changed |
|---|---|---|---|
| 1 | 1.0 | 2008-09-23 | — |
| 9 | 2.3 Gingerbread | 2010-12-06 | — |
| 14 | 4.0 Ice Cream Sandwich | 2011-10-18 | — |
| 21 | 5.0 Lollipop | 2014-11-03 | — |
| 24 | 7.0 Nougat | 2016-08-22 | — |
| 29 | 10 | 2019-09-03 | — |
| 31 | 12 | 2021-10-04 | — |
| 33 | 13 Tiramisu | 2022-08-15 | — |
| 35 | 15 Vanilla Ice Cream | 2024-10-15 | — |
| 37 | 17 Cinnamon Bun | 2026-06-16 | — |
Two milestones stand out. Android 5.0 added 64-bit ABIs, which doubled the number of native
libraries an app might need to ship. Android 14 made @CriticalNative a CTS-tested public API, so
the fast path stopped being a platform-only trick.
Under the hood#
- the JNI implementation: references, calls, attachment
- helpers for JNI code in the platform
- the C library the NDK links against
- the NDK's headers and build files
Curiosity corner#
JNIEnvis not shareable, butJavaVMis. That one sentence explains most attachment bugs.- A pinned array blocks the collector. The GC cannot move memory a native pointer refers to.
- The transition cost is measured in nanoseconds.
@CriticalNativeexists because at scale, nanoseconds per call matter.
Try it#
adb shell pm path com.example.appThe library names come from the app; the lib/<abi>/ layout is the stable part. `pm path` prints APK paths, so use `unzip -l` on the base APK to list ABIs.
adb shell getprop ro.product.cpu.abiExample output for a modern 64-bit device; other devices report a different ABI.
Check yourself#
Check yourself
How is a new app process created from Zygote?
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsJNI tipsdeveloper.android.comaccessed 2026-10-27↩
- docsART improvementssource.android.comaccessed 2026-10-27↩
- docsMemory safetysource.android.comaccessed 2026-10-27↩
- docsVendor NDK (VNDK)source.android.comaccessed 2026-10-27↩
- docsAndroid Runtime (ART) and Dalviksource.android.comaccessed 2026-10-27↩