Skip to content
Memory Management Across JNI: Ownership, Box::into_raw, Global Refs, and Leaks
rust-native

Memory Management Across JNI: Ownership, Box::into_raw, Global Refs, and Leaks

By David Cruz Anaya, Senior Mobile & Applied AI Engineer (Android, Kotlin Multiplatform, product AI)

Updated 4 September 202610 min read
AndroidRustNDKJNIMemory ManagementKotlin
Share:

Rust + NDK on Android, Part 7 of 15.This series teaches Android developers how to ship Rust in production apps, no prior Rust required. Hub: Rust on Android: How to Use It, How to Debug It, and Why It's Worth It. Previous: Passing Data Between Kotlin and Rust: Strings, Arrays, ByteBuffers, and Zero-Copy. Next: Error Handling and Panics: Keeping Rust Crashes Out of Your Android App.

The Kotlin garbage collector will never free a byte of Rust memory. That is the core problem of JNI memory management with Rust. Once a value crosses the boundary as a raw pointer, exactly one thing can release it: your own code, calling back into Rust to drop it. The reliable way to hold Rust state across JNI calls is the handle pattern: Box::into_raw turns an owned struct into a pointer Kotlin stores as a jlong, and an explicit destroy function turns it back into a Box so Rust frees it. A Kotlin AutoCloseable wrapper makes sure destroy actually runs.

This part builds that pattern end to end in the series’ rust/handles crate, then moves to the references the JVM hands you: locals that leak in loops and on native threads, and globals that pin Java objects until you delete them. It closes with finding the leaks that slip through anyway.

This assumes the toolchain setup from Part 1 and the JNI naming rules from Part 2. Part 6 on passing data between Kotlin and Rust covered what crosses the boundary by value; this part is about state that stays on the Rust side between calls.

Key Takeaways

  • The GC frees Kotlin objects, never Rust allocations. Rust state that outlives one JNI call needs an explicit owner: the handle pattern, Box::into_raw returning a jlong plus a matching destroy function.
  • Finalizers are not a cleanup strategy. The GC cannot feel native memory pressure and finalization is deprecated for removal (JEP 421, 2021). Tie the handle to AutoCloseable and use instead.
  • JNI references leak too: the spec only guarantees 16 free local slots per call, and locals on an attached native thread live until you detach (Android JNI tips).
  • LeakCanary cannot see the Rust heap. Watch the Native Heap row of dumpsys meminfo, and reach for heapprofd when it climbs.

What makes JNI memory management different with Rust?

You are managing two heaps with opposite rules. ART’s garbage collector traces reachable Kotlin objects and frees the rest when it chooses. Rust has no collector: every allocation has one owner, and memory is freed the moment that owner goes out of scope. The JNI boundary is where each model goes blind. The GC cannot trace through a jlong into the Rust heap, so a leaked Rust struct creates no GC pressure however large it grows, and the borrow checker cannot see what Kotlin does with an exported pointer. At the border, ownership becomes a contract you keep by hand. That is what the unsafe blocks below mark, and each carries a comment stating its contract.

If you know Rust’s ownership rules only by reputation, here is how they translate across JNI:

Rust rule What it becomes at the JNI border
Every value has one owner, and the owner frees it The jlong handle: Kotlin holds the only claim ticket, Rust has forgotten the allocation exists
A borrow &T is temporary and scoped A local reference, valid only until the current JNI call returns
Arc<T> shares ownership across threads A GlobalRef, keeping a Java object alive until you drop it
mem::forget leaks memory on purpose Box::into_raw without a matching Box::from_raw, forever

The table also lists the three ways a Rust + JNI app leaks: allocations never destroyed, locals overflowing their table, and globals pinning the Java heap. The post takes them in order.

The handle pattern: Box::into_raw and an explicit destroy

The scenario: Kotlin needs a stateful Rust engine that accumulates samples across many calls. The manifest at rust/handles/Cargo.toml is the usual Part 1 shape, a cdylib crate named handles depending on jni = "0.21". The full lifecycle lives in rust/handles/src/lib.rs:

use jni::objects::{JObject, JObjectArray};
use jni::sys::{jdouble, jlong, jobjectArray};
use jni::JNIEnv;

/// The state Kotlin cannot see: an owned Rust struct on the native heap.
pub struct Engine {
    samples: Vec<f64>,
}

impl Engine {
    fn new() -> Self {
        Engine { samples: Vec::new() }
    }

    fn push(&mut self, value: f64) {
        self.samples.push(value);
    }

    fn mean(&self) -> f64 {
        if self.samples.is_empty() {
            return 0.0;
        }
        self.samples.iter().sum::<f64>() / self.samples.len() as f64
    }
}

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_StatsEngine_nativeCreate(
    _env: JNIEnv,
    _this: JObject,
) -> jlong {
    let engine = Box::new(Engine::new());
    // Box::into_raw transfers ownership out of Rust: nothing here will
    // free this Engine now. The jlong is the only claim ticket to it.
    Box::into_raw(engine) as jlong
}

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_StatsEngine_nativePush(
    _env: JNIEnv,
    _this: JObject,
    handle: jlong,
    value: jdouble,
) {
    // Safety: handle is a live pointer from nativeCreate, not yet
    // destroyed; the Kotlin wrapper guarantees both. The mutable
    // borrow lasts this call only.
    let engine = unsafe { &mut *(handle as *mut Engine) };
    engine.push(value);
}

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_StatsEngine_nativeMean(
    _env: JNIEnv,
    _this: JObject,
    handle: jlong,
) -> jdouble {
    // Safety: same contract as nativePush; a shared borrow is enough.
    let engine = unsafe { &*(handle as *const Engine) };
    engine.mean()
}

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_StatsEngine_nativeDestroy(
    _env: JNIEnv,
    _this: JObject,
    handle: jlong,
) {
    if handle == 0 {
        return;
    }
    // Safety: handle came from Box::into_raw and was not destroyed
    // before. Box::from_raw takes ownership back; the drop frees the
    // Engine and everything it owns.
    unsafe { drop(Box::from_raw(handle as *mut Engine)) };
}

Two details matter. First, the standard library is explicit about Box::into_raw: after the call, the caller is responsible for the memory the box used to manage. nativeDestroy is where Box::from_raw hands that responsibility back. Second, the handle must be a jlong: 64 bits on every Android ABI, so a pointer fits on 32-bit and 64-bit devices alike, where a jint would truncate it on 64-bit hardware.

What Rust cannot do is defend against a stale handle. nativePush after destroy is a use-after-free; nativeDestroy twice is a double-free. Both are undefined behavior, and no check in Rust catches them, because the jlong still holds the old pointer value. The defense lives on the side that owns the handle, which is why the Kotlin wrapper is not optional.

Tie the lifetime to AutoCloseable, not a finalizer

The wrapper’s job is to make misuse throw instead of corrupt: one create, exactly one destroy, no calls after close. Here is app/src/main/java/dev/davthecoder/rustseries/StatsEngine.kt in full:

package dev.davthecoder.rustseries

class StatsEngine : AutoCloseable {

    private var handle: Long = nativeCreate()

    fun push(value: Double) {
        check(handle != 0L) { "StatsEngine used after close()" }
        nativePush(handle, value)
    }

    fun mean(): Double {
        check(handle != 0L) { "StatsEngine used after close()" }
        return nativeMean(handle)
    }

    fun formatSamples(): Array<String> {
        check(handle != 0L) { "StatsEngine used after close()" }
        return nativeFormatSamples(handle)
    }

    override fun close() {
        if (handle != 0L) {
            nativeDestroy(handle)
            handle = 0L
        }
    }

    private external fun nativeCreate(): Long
    private external fun nativePush(handle: Long, value: Double)
    private external fun nativeMean(handle: Long): Double
    private external fun nativeFormatSamples(handle: Long): Array<String>
    private external fun nativeDestroy(handle: Long)

    companion object {
        init {
            System.loadLibrary("handles")
        }
    }
}

Zeroing handle in close() does the heavy lifting. A second close() becomes a no-op, and a call after close fails the check with an IllegalStateException instead of scribbling on freed native memory. For scoped work, Kotlin’s use extension guarantees cleanup even when the block throws:

StatsEngine().use { engine ->
    engine.push(3.0)
    engine.push(5.0)
    Log.d("rustseries", "mean = ${engine.mean()}")
}

For objects that live with a screen, call close() from onCleared() in a ViewModel, or wherever your architecture retires the owner.

The tempting alternative is a finalize() override. It fails three ways. The GC schedules collection from Java heap pressure, and your wrapper is a few dozen bytes; the megabytes on the Rust side are invisible, so an app can exhaust native memory while the GC sees no reason to run. Finalization is deprecated for removal from Java (JEP 421, 2021). And on Android, finalizers share one daemon thread policed by the FinalizerWatchdogDaemon, which kills the process when one overruns its timeout.

As a backstop for the day someone forgets close(), java.lang.ref.Cleaner, available since API 33, can destroy the handle and log a loud warning. Treat it as a smoke alarm, not the plan. The plan is AutoCloseable.

One caveat: nothing above is thread safe. Two threads calling push on one handle create two &mut Engine borrows at once, undefined behavior regardless of JNI. Put a Mutex inside Engine, or confine each handle to one thread. Part 9, Threading and Async: Tokio, Kotlin Coroutines, and Calling Back Into Kotlin from Rust, covers crossing threads properly.

Local and global references: the leaks that are not allocations

Rust allocations are only half of JNI memory management. Every Java object a native function receives or creates is a local reference: an entry in a per-call table that keeps the object alive for the GC. Locals are freed when the JNI call returns, which is why simple functions never notice them. But the spec only guarantees 16 free slots per call (Android JNI tips), and ART aborts with local reference table overflow when a call allocates past the table’s capacity.

The classic trigger is a loop creating one reference per iteration in a single call. This function from rust/handles/src/lib.rs, the native side of the wrapper’s formatSamples, turns every sample into a Java string, and would be that crash without the delete inside the loop:

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_StatsEngine_nativeFormatSamples(
    mut env: JNIEnv,
    _this: JObject,
    handle: jlong,
) -> jobjectArray {
    // Safety: same handle contract as nativeMean.
    let engine = unsafe { &*(handle as *const Engine) };
    let array: JObjectArray = env
        .new_object_array(engine.samples.len() as i32, "java/lang/String", JObject::null())
        .expect("array allocation");
    for (i, sample) in engine.samples.iter().enumerate() {
        let text = env.new_string(format!("{sample:.3}")).expect("string");
        env.set_object_array_element(&array, i as i32, &text)
            .expect("store element");
        // Without this, every iteration parks one local reference in
        // the table until the call returns.
        env.delete_local_ref(text).expect("delete local ref");
    }
    array.into_raw()
}

The expect calls abort on failure, honest but blunt; Part 8 replaces them with real error handling. When a loop body needs several references alive at once, JNIEnv::with_local_frame wraps a closure in a fresh reference frame and frees everything in it afterwards (jni crate docs).

The second trigger is subtler. Locals are freed on return to Java, and on a native thread you attached yourself there is no return to Java. A long-lived Rust worker touching Java objects accumulates locals until it detaches, which for a thread pool may be never. The rule: on any thread that outlives a call, delete every local you create. Part 9 gives attached threads the full treatment.

When a Java object genuinely must outlive the call, promote it. env.new_global_ref(&obj) returns a GlobalRef that keeps the object alive across calls and threads, and the jni crate deletes the underlying reference when the last clone drops (jni crate docs): an Arc over a Java object. Store it inside Engine and the handle pattern composes: destroy drops the Engine, which drops the GlobalRef, which releases the object. Forget it and Rust is pinning the Java heap instead. A global reference to a View pins the Activity behind it, which is how native code leaks an Activity the Java side never mishandled.

How do you find the leak once you have shipped it?

By watching the native heap, because nothing watching the Java heap will tell you. Here is a deliberate leak, app/src/main/java/dev/davthecoder/rustseries/LeakDemo.kt, wired to a button in the demo app:

package dev.davthecoder.rustseries

object LeakDemo {
    // Deliberately wrong: every engine is created, filled, and abandoned.
    // The Kotlin wrappers get garbage collected. The Rust Engines do not.
    fun leak(engines: Int) {
        repeat(engines) {
            val engine = StatsEngine()
            repeat(100_000) { i -> engine.push(i.toDouble()) }
            // close() is never called.
        }
    }
}

Each abandoned Engine holds 100,000 doubles, so leak(50) strands at least 40 MB on the Rust heap. Tap the button a few times and pull the process summary:

adb shell dumpsys meminfo dev.davthecoder.rustseries

The signature of a handle leak is distinctive in that output. The Java Heap row stays roughly flat: the wrappers are tiny and collected on schedule. The Native Heap row steps up by roughly the leaked amount on every tap and never comes back down, not after a GC, not after backgrounding. Java leaks drift; handle leaks climb a staircase. I am not pasting a captured table, because the numbers vary by device and Android version, and this series does not print numbers it did not measure; when the companion repo ships the handles module, the code above reproduces the staircase. In-process, Debug.getNativeHeapAllocatedSize() gives the same curve from a test.

Know what your usual tools will say. LeakCanary analyzes Java heap dumps, so the leak above is invisible to it: the wrappers were collected, and nothing in the dump references the stranded Rust memory. It still earns its keep on the mirror-image leak, an Activity pinned by a forgotten global reference, whose leak trace ends at a native global root. For real attribution, heapprofd, Perfetto’s native heap profiler, samples malloc with backtraces and points into your .so at the allocation site. The NDK’s Address Sanitizer belongs in the toolbox for the corruption bugs, use-after-free and double-free through stale handles, not for leak hunting.

Frequently Asked Questions

How do I keep a Rust object alive between JNI calls?

Box it, leak the pointer on purpose with Box::into_raw, and return it to Kotlin as a jlong. Later calls cast the jlong back to a reference, and an explicit destroy function calls Box::from_raw so Rust frees it. Wrap the lifecycle in a Kotlin AutoCloseable so destroy runs even when the code between create and close throws.

Do JNI global references ever get garbage collected?

No. A global reference pins its Java object until you explicitly delete it, and everything that object references stays reachable too. With the jni crate, new_global_ref returns a GlobalRef that deletes the reference when its last clone drops, so storing it in the struct behind your handle releases it on destroy.

Why does my app crash with “local reference table overflow”?

Some native call is creating Java object references in a loop without releasing them, or an attached native thread is accumulating them between calls. Delete references as you go with delete_local_ref, or wrap loop bodies in with_local_frame. The JNI spec only guarantees 16 free slots per call.

Can LeakCanary detect Rust or native memory leaks?

Not directly. LeakCanary analyzes the Java heap, and leaked Rust allocations leave no trace there once their Kotlin wrappers are collected. Watch the Native Heap row of dumpsys meminfo and profile with heapprofd instead. LeakCanary still helps with the reverse case, a Java object pinned by a native global reference root.

Where this leaves you

The rust/handles crate now owns its state the way I ship it in real apps: a jlong claim ticket with one destroy function, wrapped in an AutoCloseable so misuse throws instead of corrupting memory. Of the reference rules in the second half, the one worth pinning above your desk is the thread rule, because attached native threads have no return to Java to flush their locals for them.

Every expect here still aborts the process when JNI misbehaves, and that is the next fix. Part 8, Error Handling and Panics: Keeping Rust Crashes Out of Your Android App, builds the boundary that turns panics and Result errors into Kotlin exceptions. The series index lives in the hub post.

Share:

Comments

Loading comments…