Zygote
Zygote, Dalvik & ARTnerd

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.

11 minAPI 1–37 · 17interactivequizverified 2026-10-27

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.
  • JNIEnv is per-thread. You cannot share it; you share the JavaVM and 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#

Loading diagram…
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_64 and x86 — 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 exits

Android 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 DeleteLocalRef or PushLocalFrame/PopLocalFrame.
  • Global reference — created with NewGlobalRef, valid until DeleteGlobalRef. 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. Use IsSameObject.
  • Never use a jobject as 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 dlsym finds it. Less code, but errors are discovered at call time.
  • RegisterNatives — explicit registration in JNI_OnLoad. Mistakes are caught at load time, only JNI_OnLoad needs 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 receives JNIEnv and jobject.
  • @CriticalNative — static only, primitives only, no JNIEnv and no jclass.

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.
3

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#

Versions10 releases
APIVersionReleasedWhat changed
11.02008-09-23—
92.3 Gingerbread2010-12-06—
144.0 Ice Cream Sandwich2011-10-18—
215.0 Lollipop2014-11-03—
247.0 Nougat2016-08-22—
29102019-09-03—
31122021-10-04—
3313 Tiramisu2022-08-15—
3515 Vanilla Ice Cream2024-10-15—
3717 Cinnamon Bun2026-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#

  • JNIEnv is not shareable, but JavaVM is. 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. @CriticalNative exists because at scale, nanoseconds per call matter.

Try it#

See which ABIs an app ships
adb shell pm path com.example.app

The 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.

Check the ABI of the running process
adb shell getprop ro.product.cpu.abi

Example output for a modern 64-bit device; other devices report a different ABI.

Check yourself#

Check yourself

1 / 12

How is a new app process created from Zygote?

0 answered · 0 correct

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.

  1. docsJNI tipsdeveloper.android.comaccessed 2026-10-27↩
  2. docsART improvementssource.android.comaccessed 2026-10-27↩
  3. docsMemory safetysource.android.comaccessed 2026-10-27↩
  4. docsVendor NDK (VNDK)source.android.comaccessed 2026-10-27↩
  5. docsAndroid Runtime (ART) and Dalviksource.android.comaccessed 2026-10-27↩