← All problems

Mobile Core Concepts

NDK and JNI

When reaching for C/C++ on Android earns its complexity, and the JNI and ABI constraints that decide whether a native dependency ships at all.

Free~22 min

Most Android apps never ship a line of C, and that is the right answer for most Android apps. The NDK earns its place in three narrow cases: reusing an existing C / C++ codebase (a codec, an ML inference engine, a crypto primitive), perf-critical hot paths that can use SIMD or hand-tuned memory layouts (audio DSP, image pipelines), and - the case that comes up in interviews more than any other - catching native crashes from a sigaction handler. This page covers the toolchain and ABIs, the JNI round-trip, the rules that govern what a native signal handler may call, and the libc / STL gotchas that bite teams the first time they ship native code. Memory and GC introduces the native heap; this page extends into signal-handler constraints. The Crash Reporter SDK problem leans on this material heavily.

When teams reach for NDK

Three patterns dominate. Reusing an existing C / C++ codebase: a codec (FFmpeg, libwebp), a cross-platform ML runtime (TFLite, ONNX), or a crypto library audited elsewhere - rewriting in Kotlin is rarely cheaper than wrapping. Perf-critical hot paths where the JIT cannot match hand-tuned C: SIMD image processing, audio resampling, DSP. The bar is high; a careless JNI boundary erases the win. Native crash handlers: catching SIGSEGV from C / C++ code or transitively-loaded system libraries requires a sigaction handler written under extreme constraints.

What NDK is not for: writing the app. UI, persistence, networking, and the data layer run faster, safer, and smaller in Kotlin. "We need NDK for performance" often turns into "we needed a baseline profile" once profiled.

The toolchain and ABIs

The NDK ships clang, a frozen libc++, a sysroot per minSdk target, and a small set of build tools. Each NDK version pins clang and libc++ to specific upstream revisions, so the same C++ code compiled with NDK r26 and r27 can produce different binaries; pin the NDK version in build.gradle for reproducibility.

Four ABIs exist on paper; two matter in practice:

ABIWhere it runsShip it?
arm64-v8aEvery modern Android phoneAlways
armeabi-v7a32-bit ARM, mostly old hardwareYes if minSdk is low, otherwise optional
x86_64Emulators, Chrome OSUseful for debug; not required for Play
x86Long-deprecated; Play removed it years agoNo

Build integration goes through AGP's externalNativeBuild. CMake is the modern shape; ndk-build (the older Android.mk system) is legacy:

android {
    defaultConfig {
        externalNativeBuild {
            cmake {
                cppFlags += listOf("-fvisibility=hidden", "-fno-exceptions")
                arguments += "-DANDROID_STL=c++_shared"
            }
        }
    }
    externalNativeBuild { cmake { path = file("CMakeLists.txt") } }
}

Gradle runs CMake once per ABI, links a separate .so, and packages each as lib/<abi>/libfoo.so in the APK or AAB. Unstripped binaries (needed to symbolicate native crashes) live under intermediates/.../obj/<abi>/; Crash Reporter SDK design hinges on this directory.

JNI essentials

JNI is the function-call boundary between ART and native code. The two layers share no ABI, calling convention, or object model - JNI is the glue. A round-trip starts in Kotlin:

object Native {
    init { System.loadLibrary("native-lib") }   // looks up lib/<abi>/libnative-lib.so
    external fun hello(): String
    external fun resize(srcPtr: Long, w: Int, h: Int): Long
}

System.loadLibrary("native-lib") resolves to libnative-lib.so - the lib prefix and .so suffix are added by the loader. The library is dlopen'd during the call, which blocks init {}; do it once at process start.

On the C side, every external function corresponds to a precisely-encoded symbol name:

extern "C" JNIEXPORT jstring JNICALL
Java_com_example_Native_hello(JNIEnv* env, jobject /*thiz*/) {
    return env->NewStringUTF("hello from native");
}

The naming rule is Java_<package_with_underscores>_<Class>_<method>; underscores in the package become _1, inner classes separate with _00024. Mismatches produce UnsatisfiedLinkError: No implementation found for ... at the first call, not at dlopen - the linker is lazy. extern "C" blocks C++ name mangling; without it JNI cannot find the symbol.

JNIEnv* is thread-local. A pointer obtained on one thread is invalid on another. A native thread that needs to call back into Java must attach first:

JNIEnv* env;
JavaVM* vm = /* cached at JNI_OnLoad */;
vm->AttachCurrentThread(&env, nullptr);   // attach; env is now valid on this thread
// ... use env ...
vm->DetachCurrentThread();                // before the thread exits

A pthread that exits without detaching aborts the process.

Local refs, global refs, and the local reference table

Every jobject returned by JNI (FindClass, NewStringUTF, CallObjectMethod) is a local reference, scoped to the current native frame and released on return. The JNI spec guarantees only a minimum of 16 local refs per frame; if you need more you must delete as you go or reserve capacity. Pre-Oreo Android enforced a version-specific implementation cap (around 512 on some versions); on Android 8.0+ the table is effectively unbounded - it grows on demand. Either way, leaking local refs in a long loop can still overflow the table: JNI ERROR (app bug): local reference table overflow. A loop that calls NewStringUTF per iteration without DeleteLocalRef is the canonical way to hit it:

for (int i = 0; i < n; i++) {
    jstring s = env->NewStringUTF(data[i]);
    env->CallVoidMethod(receiver, append, s);
    env->DeleteLocalRef(s);     // without this, the table grows every iteration
}

PushLocalFrame(capacity) / PopLocalFrame(nullptr) is the batched alternative - a sub-frame that releases everything at once.

jclass, jmethodID, and jfieldID are different. IDs are stable across calls and threads; cache them once in JNI_OnLoad. jclass is a jobject and the local copy goes away with the frame - wrap with NewGlobalRef. jmethodID and jfieldID are opaque pointers, not objects, and need no wrapping.

The native-call lifecycle

Rendering diagram…

The Kotlin call is synchronous: the JVM thread is the thread that runs the native code, so wrap non-trivial work in withContext(Dispatchers.IO). Callbacks via env->CallVoidMethod run on whatever native thread you are on; UI work must Handler(Looper.getMainLooper()).post {...} itself across.

Signal handling and async-signal-safety

This is the core material for Crash Reporter SDK. A native crash in any loaded .so delivers one of SIGSEGV (bad memory access), SIGABRT (abort(), assert, CHECK failures), SIGBUS (misaligned access, mapped-file truncation), or SIGILL (illegal instruction). The default disposition: kernel writes /data/tombstones/tombstone_NN, ART logs to logcat, the process exits. A crash reporter intercepts before that.

The interception is one sigaction per signal, installed in JNI_OnLoad or early in Application.onCreate:

struct sigaction old_segv;
struct sigaction sa{};
sa.sa_sigaction = on_native_crash;
sa.sa_flags = SA_SIGINFO | SA_ONSTACK;
sigemptyset(&sa.sa_mask);
sigaction(SIGSEGV, &sa, &old_segv);   // capture the previous handler

sigaction is the modern API; signal() has implementation-defined behavior on Android and should never be used. SA_ONSTACK directs the kernel to deliver on a separate stack registered via sigaltstack - essential, because the crash might be stack exhaustion, in which case running the handler on the same stack re-faults immediately.

The signal handler runs with extreme constraints. POSIX defines a set of async-signal-safe functions a handler may call; everything else is forbidden. The traps:

ForbiddenWhy
malloc, free, new, deleteThe allocator may hold a lock the crashing thread was inside; re-entering deadlocks the process
printf, fprintf, sprintfInternally allocates and locks stdout's mutex
pthread_mutex_lock, all locksHolding a lock and crashing while another thread holds the partner lock is the canonical deadlock
Most of libc++Allocates internally even for "trivial" string ops
dlopen, dlsymTakes the loader lock; the crash may be inside a loader callback

The safe set is small: write, read, _exit, sigaction, sigprocmask, clock_gettime, getpid, gettid, the *at syscall family. The practical consequence: a crash handler writes a pre-allocated buffer to a pre-opened file descriptor and then exits. Allocating, formatting, or symbolicating inside the handler is a bug. Symbolication runs at next launch, in a different process, against the captured raw frame pointers.

The chaining pattern: after the handler does its work, re-raise the signal so the kernel delivers it to whatever was installed before (ART's own handler, which writes the tombstone), or call the saved handler directly:

static void on_native_crash(int sig, siginfo_t* info, void* uc) {
    write(pre_opened_fd, snapshot, snapshot_len);   // async-signal-safe
    sigaction(sig, &old_segv, nullptr);             // restore previous
    syscall(SYS_tgkill, getpid(), gettid(), sig);   // re-raise on this thread
}

tgkill(pid, tid, signum) is the right re-raise API: it re-sends to the same thread that crashed, so the kernel delivers on the right stack context. Plain raise() or kill() may go to a different thread depending on signal masks - subtle and a common source of "the tombstone is wrong" bugs. Most production SDKs instead invoke the saved old.sa_sigaction(sig, info, uc) directly, which preserves the original siginfo_t.

The Crash Reporter SDK problem covers this in depth: what goes in the pre-allocated buffer (last N breadcrumbs, register dump, thread ID), where to write it (a pre-opened FD under cache/), and how the next-launch path symbolicates and uploads.

Bionic vs glibc

Android's libc is Bionic, not glibc; most C++ developers learn this the day a missing symbol crashes their port. Bionic is BSD-licensed and rewritten from scratch, mostly POSIX-compatible with documented holes: no dlmopen, no pthread_cancel, no getopt_long until late versions; <locale.h> mostly stubbed (full ICU is in the framework); no <execinfo.h> (backtrace) - use libunwind or _Unwind_Backtrace.

Symbols also appear and disappear across API levels (setrlimit64 is API 21+, posix_spawn is API 28+). Guard with #if __ANDROID_API__ >= 28. AGP's link-time warnings catch most of these; a library that compiles for Linux desktop may link clean against the NDK and still throw UnsatisfiedLinkError on device because a libc symbol is missing on minSdk.

C++ STL choices

The NDK ships libc++ (LLVM's STL) and offers two link modes:

  • c++_shared (default) - ships as libc++_shared.so in the APK / AAB. Every .so links against the same instance. Exceptions, RTTI, dynamic_cast, and global state (locales, typeinfo) work correctly across .so boundaries.
  • c++_static - statically linked. Smaller if you have exactly one .so; pathological if you have two or more.

Two .sos each statically linking libc++ end up with duplicate copies of every global. A dynamic_cast<Derived*>(base) across the boundary then silently returns nullptr because the typeinfo objects on the two sides are at different addresses. Exceptions thrown in one .so and caught in another bypass the catch or terminate. The symptoms look like memory corruption. Use c++_shared unless you have measured one-.so and need the ~200 KB win.

Packaging native libs and ABI splits

Native libraries live under lib/<abi>/ inside the APK or AAB. By default the APK contains every ABI built; an arm64-v8a device downloads the full set. Two ways to slim down.

The legacy mechanism is splits { abi { ... } }, which produces one APK per ABI at build time:

android {
    splits {
        abi {
            enable true
            reset()
            include 'arm64-v8a', 'armeabi-v7a'
            universalApk false
        }
    }
}

This is the right shape for sideload and direct-APK installs; for Play it has been superseded.

The modern path is the Android App Bundle (AAB). Upload one .aab containing every ABI; Play's server generates per-device split APKs at install time, and the device downloads only the matching ABI. ABI splits in build.gradle become unnecessary - and counterproductive - with AAB. The same step handles density and language splits.

AARs include native libraries in jni/<abi>/; consumers pick them up and they participate in downstream AAB splitting. With AAB the user only ever downloads one ABI, so optimize for smaller per-ABI binary (-fvisibility=hidden, -Wl,--gc-sections, LTO) rather than dropping ABIs.

See also

  • Platform Knowledge - the process model and ART runtime that JNI bridges into; JNI_OnLoad runs inside the loading process and is bound by the same Doze / standby rules as anything else.
  • Memory and GC - the native heap is introduced there and counts against the same per-app RAM budget as Java; Cleaner and PhantomReference are the JVM-side hook for releasing native buffers.
  • Performance and profiling - Perfetto captures both Java and native stacks; a crash inside an .so symbolicates against the unstripped obj/<abi>/ artifacts.
  • Security and Crypto - hand-rolled crypto in C is the wrong call; if NDK crypto is needed, link against vetted libraries (Tink, BoringSSL) instead of writing primitives.
  • WebView and Custom Tabs - the WebView renderer is itself a native process the app talks to via IPC; native crashes inside it surface as renderer kills, not app crashes.

Used in

  • Crash Reporter SDK - the entire native handler chapter: sigaction(SIGSEGV / SIGABRT / SIGBUS / SIGILL), async-signal-safety inside the handler, pre-allocated buffer + pre-opened FD, tgkill re-raise, next-launch symbolication against the unstripped obj/ artifacts.
  • Image Loader - decoders for AVIF / HEIC / animated WebP are wrapped C libraries; the JNI boundary cost matters for the inner decode loop and bitmaps cross from native heap into Java via Bitmap.wrapHardwareBuffer.
  • Doc Editor App - text shaping (HarfBuzz) and PDF rendering paths live in C; the JNI surface is a thin facade and most calls pass Long handles into native-owned objects.
  • Music Player - audio DSP, resampling, and the low-latency output path go through AAudio / OpenSL ES, both native APIs; the playback engine is C++ with a small Kotlin facade.
  • Networking Client - some teams ship Cronet (Chrome's network stack) as a native dependency; the JNI shim is straightforward but the .so adds several MB per ABI, which is the size-budget conversation behind every "should we adopt Cronet" decision.

Done reading? Mark it so it sticks in your dashboard.