Inside ART & JNI

Connect class loading, JIT/AOT, garbage collection, native loading, and reference lifetime.

9 min readChapter 22 of 50Intermediate
In this chapter

“ART runs DEX” compresses several mechanisms into three words. Before a method can run, its class has to be located and linked; while it runs, ART may execute interpreted or compiled code; while objects are allocated, garbage collection must preserve reachable state. Native code crosses into that world under an explicit reference and thread protocol.

Before you start#

Read Java and native interfaces, Zygote and system_server, Memory and system calls, and C/C++ source reading. Source references use AOSP android-14.0.0_r1; compiler policy, collectors, artifacts, and runtime configuration vary by release/product. Required practice is analysis, not attaching a debugger to system_server.

Learning goals#

Separate loading, linking, initialization, interpretation, JIT, AOT, and collection. Explain class identity, profile-guided compilation, JNI reference lifetime, and native-library loading. Diagnose four similar-looking app failures without treating all of them as “ART is broken.”

From an APK to a method invocation#

An APK is a package/archive, not a running process. It can contain DEX bytecode, resources, manifest information, and ABI-specific native libraries. The package-management/build paths decide installation and code locations; a process's class loader locates its managed code.

Loading finds a class definition. Linking includes verification and preparation/resolution work needed to use it. Initialization runs class initialization according to language/runtime rules. An initializer can execute application logic and fail or block; successfully locating a class does not establish successful initialization. Some work is deferred until needed, so this is a conceptual decomposition rather than a claim that every class completes every step at app startup. See the ART runtime overview and pinned runtime/class_linker.cc.

Class identity includes the defining loader, not only the text name. Two loaders can define classes with the same binary name that are not assignment-compatible. A plugin/library error that prints matching names can therefore still be a real type-identity problem. Resolution also depends on the loader's dependency context; “the DEX file exists” does not prove the requested class is reachable through the correct loader.

Why have loaders? They organize code discovery and dependencies, support framework/app separation and other loading arrangements, and allow runtime resolution without merging all classes into one global namespace. They are not substitutes for Android's process/permission sandbox.

One method, several execution forms#

Execution forms can coexist
Interpretation
Execute bytecode through interpreter machinery without compiling everything first.
JIT
Compile selected code during execution using runtime observations.
AOT
Compile ahead of relevant execution, subject to dependency/runtime validity.
Profile guidance
Observed use informs where compilation work is valuable; warming can change measurements.
Conceptual model. Not a fixed per-method state machine. Selection, compilation policy, profiles, and artifact validity vary by release/product and workload.
MechanismWhat happensTradeoff
InterpreterRuntime executes bytecode behavior through interpreter machineryAvoids compiling everything up front, but repeated work can be slower
JITRuntime compiles selected code during executionAdapts to actual use, at a CPU/memory/startup cost
AOTCode is compiled ahead of the relevant execution periodCan reduce runtime compilation work, but needs build/storage and validity management
Profile guidanceObservations inform which code is worth compilingDepends on which workload produced the profile

These can coexist. Android does not have to choose “all interpreted” or “all compiled.” The JIT documentation explains the mixed strategy. Pinned runtime/jit/jit.cc contains Jit::CompileMethodInternal and profile-driven compile paths. A method's presence in that source does not mean it was compiled on your device at a particular instant.

Why not compile every possible method at installation? Work that is never executed still costs compilation time and storage. Why not interpret everything forever? Repeated hot paths lose optimization opportunities. Profiles let the system spend compilation effort where a real workload benefits. A cold-start benchmark with a fresh profile and a warmed benchmark can therefore measure different states, not necessarily a regression.

Compiled artifacts also depend on code, instruction set, runtime/compiler assumptions, and dependency context. An old artifact on disk is not proof it remains valid or selected. The runtime can reject, regenerate, or use another execution route according to the version's rules. Avoid naming .oat, .odex, .vdex, and .art as interchangeable “compiled app files”: they serve distinct code, verification, and image roles, with evolving formats. See ART configuration.

GC follows reachability, not your intention#

Managed objects are live when reachable through the runtime's relevant roots and references. Roots include appropriate thread/runtime/static/JNI references; simply no longer wanting an object does not remove those paths. A long-lived listener or static collection can retain an entire graph. Conversely, an unreachable object is not guaranteed to be reclaimed immediately on the next source line.

A collector traces and reclaims objects while coordinating with application threads. Some collectors move objects. Runtime safepoints/read barriers and other collector-specific mechanisms make that safe; raw native pointers to movable managed objects are not a general stable ownership scheme. Exact collector and pause behavior must be established for the product/release. See GC debugging guidance.

Three quantities differ:

  • Managed live heap: managed objects still reachable.
  • Allocated/committed heap capacity: memory retained for allocation, not all live objects.
  • Process resident memory: includes code, native allocations, graphics/shared buffers, stacks, and other mappings as well as managed pages.

Consequently, “GC ran but RSS stayed high” does not alone prove a GC bug. Native allocations are not automatically freed because an unrelated Java reference became unreachable. Shared pages and allocator retention further complicate accounting. First identify which memory category is growing.

JNI: references and thread ownership are part of the ABI#

JNI lets native code interact with managed objects. It does not turn an app into a different Linux identity or make Java object addresses permanent.

  • A local reference is valid within its specified JNI local-reference lifetime and thread context. It is not a durable handle for arbitrary later worker use.
  • A global reference deliberately keeps an object reachable across calls until explicitly deleted. Forgetting deletion can create a managed retention leak from native code.
  • A weak global reference does not keep the referent strongly alive; use requires checking/promoting it safely under JNI rules.
  • JNIEnv* is thread-specific. A native-created thread needs the appropriate attach/detach lifecycle; storing another thread's environment pointer is incorrect.
  • A pending managed exception changes what JNI calls are valid. Ignoring it and continuing native work can compound the original error.

The Android JNI tips explain these contracts and CheckJNI. This is why “save the object pointer in a C++ member” is not an adequate cross-call design.

Worked lifetime failure

Original pseudocode; deliberately wrong, not runnable JNI code:

text
nativeStart(env, localCallback):
    store env and localCallback in worker state
    return to Java
worker, later on another thread:
    use saved env to call saved callback

There are two independent defects: the environment belongs to another thread, and the callback was retained beyond its local-reference lifetime. A correct design would establish the worker's own JNI environment, retain an appropriate global reference if ownership demands it, handle pending exceptions, release that reference, and synchronize cancellation/teardown. Making only the pointer volatile fixes neither lifetime nor thread ownership.

Native loading is not managed class loading#

System.loadLibrary ultimately enters Android's native loading/linker machinery. The linker has to select a compatible ELF binary, map segments, resolve required symbols/dependencies, and apply namespace rules. A visible .so file can fail because it has the wrong ABI, a missing dependency/symbol, or a namespace-inaccessible dependency. An UnsatisfiedLinkError is not proof that the Java class itself was absent.

Pinned bionic/linker/linker.cpp, do_dlopen, is a native loading entry point; it is not the ART class loader. A linker namespace controls native-library visibility/dependencies. A mount namespace controls filesystem lookup topology. Neither changes the process UID merely because loading succeeds. See Android linker namespaces.

Practice#

Classify this synthetic packet and write the next discriminating check:

SymptomCandidate mechanismUseful next evidence
Class name exists in one archive but load failsLoader search/dependency or initialization failureFull exception cause and defining-loader context
Native library exists but load reports missing symbolELF ABI/dependency/linker issueExact error, ABI, dependency and symbol identity
Callback fails only from a background native workerJNI thread/reference lifetimeAttach state, reference ownership, CheckJNI in your own debuggable app
Managed live bytes stable, process memory risesNative/mapping/buffer growth or retentionCategorized memory and allocation/lifetime evidence

Then explain a fifth case: a second launch is faster after exercising one feature. JIT/profile warming, filesystem/page cache, resource cache, and other startup work are competing explanations. Preserve a controlled workload and profile/cache state; do not identify JIT from timing alone. No real Android measurements are supplied here.

Knowledge check#

  1. Are identically named classes from different defining loaders necessarily the same type?
  2. Does AOT eliminate every need for runtime execution/GC machinery?
  3. Why is a JNI global reference both useful and dangerous?
  4. Can a library load fail even though the file is visible?

Answers#

  1. No. Defining-loader identity matters.
  2. No. Runtime services, object management, and mixed execution remain relevant.
  3. It extends reachability across calls, but leaks/over-retains objects if not released correctly.
  4. Yes. ABI, symbols, dependencies, namespace restrictions, and other loading errors can still fail.

Checkpoint#

Ready when you can place each failure in the correct mechanism and repair both defects in the JNI pseudocode with an explicit ownership/teardown plan. Revise if GC is equated with freeing all process memory, or every load failure is called a missing file. Return to the execution table and separate managed-loader, native-linker, object-lifetime, and process-memory evidence. Runtime compiler/collector behavior remains not yet evidenced without an identified target and observation.

Sources#

A little further beneath the surface.

Check your understanding with the checkpoint. Marking a chapter as read saves a bookmark, not a proficiency assessment.