Skip to content
Rust & NativeRust y nativo

Memory Management Across JNI: Ownership, Box::into_raw, Global Refs, and LeaksGestión de memoria a través de JNI: propiedad, Box::into_raw, referencias globales y fugas

ByPor David Cruz Anaya, Senior Mobile & Applied AI Engineer (Android, Kotlin Multiplatform, product AI)Ingeniero sénior de móvil e IA aplicada (Android, Kotlin Multiplatform, IA de producto)

Updated 4 September 2026Actualizado el 4 de septiembre de 20269 min read9 min de lectura
AndroidRustNDKJNIMemory ManagementKotlin
Memory Management Across JNI: Ownership, Box::into_raw, Global Refs, and Leaks
Share:Compartir:

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. 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)) };
}

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")
        }
    }
}

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

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.

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.

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.

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.

El recolector de basura de Kotlin no va a liberar jamás un byte de memoria de Rust. En cuanto un valor cruza la frontera como puntero en crudo, solo una cosa puede liberarlo: tu propio código, llamando de vuelta a Rust para soltarlo. La forma fiable de conservar estado de Rust entre llamadas JNI es el patrón del handle: Box::into_raw convierte un struct con dueño en un puntero que Kotlin guarda como jlong, y una función destroy explícita lo vuelve a convertir en un Box para que Rust lo libere. Un wrapper AutoCloseable de Kotlin se encarga de que destroy llegue a ejecutarse de verdad.

Esta parte construye ese patrón de principio a fin en el crate rust/handles de la serie y, después, pasa a las referencias que te entrega la JVM: las locales, que se fugan en bucles y en hilos nativos, y las globales, que anclan objetos Java hasta que las borras. Cierra con cómo encontrar las fugas que, aun así, se cuelan.

Esto da por sentados la configuración de la cadena de herramientas de la parte 1 y las reglas de nombres JNI de la parte 2. La parte 6, sobre cómo pasar datos entre Kotlin y Rust, cubrió lo que cruza la frontera por valor; esta parte trata del estado que se queda en el lado de Rust entre una llamada y la siguiente.

Ideas clave

  • El GC libera objetos de Kotlin, nunca reservas de Rust. El estado de Rust que sobrevive a una sola llamada JNI necesita un dueño explícito: el patrón del handle, Box::into_raw devolviendo un jlong, más una función destroy que le corresponda.
  • Los finalizadores no son una estrategia de limpieza. El GC no nota la presión de la memoria nativa, y la finalización está en desuso, prevista para su eliminación (JEP 421, 2021). Ata el handle a AutoCloseable y a use.
  • Las referencias JNI también se fugan: la especificación solo garantiza 16 huecos locales libres por llamada, y las locales de un hilo nativo adjuntado viven hasta que lo desacoplas (consejos de JNI en Android).
  • LeakCanary no ve el montón de Rust. Vigila la fila Native Heap de dumpsys meminfo y tira de heapprofd cuando empiece a subir.

¿Qué hace distinta la gestión de memoria JNI con Rust?

Estás gestionando dos montones con reglas opuestas. El recolector de basura de ART recorre los objetos de Kotlin alcanzables y libera el resto cuando él decide. Rust no tiene recolector: cada reserva tiene un único dueño, y la memoria se libera en el instante en que ese dueño sale de ámbito. La frontera JNI es el punto en el que cada modelo se queda ciego. El GC no puede seguir un jlong hasta el montón de Rust, así que un struct de Rust fugado no genera presión sobre el GC por mucho que crezca, y el comprobador de préstamos no ve qué hace Kotlin con un puntero exportado. En la frontera, la propiedad pasa a ser un contrato que mantienes a mano. Eso es lo que marcan los bloques unsafe de más abajo, y cada uno lleva un comentario que enuncia su contrato.

Si las reglas de propiedad de Rust solo te suenan de oídas, así se traducen al cruzar JNI:

Regla de Rust En qué se convierte en la frontera JNI
Cada valor tiene un dueño, y el dueño lo libera El handle jlong: Kotlin tiene el único vale, y Rust ha olvidado que la reserva existe
Un préstamo &T es temporal y está acotado a un ámbito Una referencia local, válida solo hasta que vuelve la llamada JNI en curso
Arc<T> comparte la propiedad entre hilos Un GlobalRef, que mantiene vivo un objeto Java hasta que lo sueltas
mem::forget fuga memoria a propósito Box::into_raw sin un Box::from_raw que le corresponda, para siempre

La tabla también enumera las tres formas en las que se fuga una app de Rust y JNI: reservas que nunca se destruyen, locales que desbordan su tabla y globales que anclan el montón de Java. El artículo las recorre en ese orden.

El patrón del handle: Box::into_raw y un destroy explícito

El escenario: Kotlin necesita un motor de Rust con estado que vaya acumulando muestras a lo largo de muchas llamadas. El manifiesto en rust/handles/Cargo.toml tiene la forma habitual de la parte 1: un crate cdylib llamado handles que depende de jni = "0.21". El ciclo de vida completo vive en 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)) };
}

Primero, la biblioteca estándar es explícita acerca de Box::into_raw: después de la llamada, quien llama es responsable de la memoria que el box gestionaba. nativeDestroy es el sitio donde Box::from_raw devuelve esa responsabilidad. Segundo, el handle tiene que ser un jlong: 64 bits en cada ABI de Android, de modo que un puntero cabe igual en dispositivos de 32 y de 64 bits, mientras que un jint lo truncaría en hardware de 64 bits.

Lo que Rust no puede hacer es defenderse de un handle caducado. Un nativePush después de destroy es un use-after-free; llamar a nativeDestroy dos veces es un double-free. Las dos cosas son comportamiento indefinido, y ninguna comprobación de Rust las caza, porque el jlong sigue guardando el valor antiguo del puntero. La defensa vive en el lado que posee el handle, y por eso el wrapper de Kotlin no es opcional.

Ata el ciclo de vida a AutoCloseable, no a un finalizador

El trabajo del wrapper es que un mal uso lance, en lugar de corromper: un create, exactamente un destroy, y ninguna llamada después de close. Aquí está app/src/main/java/dev/davthecoder/rustseries/StatsEngine.kt entero:

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")
        }
    }
}

Un segundo close() se convierte en un no-op, y una llamada después de close hace fallar el check con una IllegalStateException, en lugar de escribir encima de memoria nativa ya liberada. Para un trabajo acotado a un bloque, la extensión use de Kotlin garantiza la limpieza aunque el bloque lance:

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

Para objetos que viven con una pantalla, llama a close() desde onCleared() en un ViewModel, o allí donde tu arquitectura retire al dueño.

La alternativa tentadora es sobreescribir finalize(). Falla de tres maneras. El GC programa la recolección a partir de la presión del montón de Java, y tu wrapper ocupa unas pocas docenas de bytes; los megabytes del lado de Rust son invisibles, así que una app puede agotar la memoria nativa mientras el GC no ve motivo para ponerse en marcha. La finalización está en desuso y prevista para su eliminación de Java (JEP 421, 2021). Y, en Android, los finalizadores comparten un único hilo demonio vigilado por FinalizerWatchdogDaemon, que mata el proceso cuando uno se pasa de su tiempo límite.

Como red de seguridad para el día en que alguien olvide close(), java.lang.ref.Cleaner, disponible desde la API 33, puede destruir el handle y dejar un aviso bien ruidoso en el registro. Trátalo como un detector de humo, no como el plan. El plan es AutoCloseable.

Una salvedad: nada de lo anterior es seguro entre hilos. Dos hilos que llamen a push sobre el mismo handle crean dos préstamos &mut Engine a la vez, comportamiento indefinido con independencia de JNI. Mete un Mutex dentro de Engine, o confina cada handle a un solo hilo. La parte 9, Hilos y asincronía: Tokio, corrutinas de Kotlin y cómo volver a llamar a Kotlin desde Rust, cubre cómo cruzar de hilo como es debido.

Referencias locales y globales: las fugas que no son reservas

Las reservas de Rust son solo la mitad de la gestión de memoria JNI. Cada objeto Java que una función nativa recibe o crea es una referencia local: una entrada en una tabla por llamada que mantiene el objeto vivo de cara al GC. Las locales se liberan cuando la llamada JNI vuelve, y por eso las funciones sencillas no llegan a notarlas. Pero la especificación solo garantiza 16 huecos libres por llamada (consejos de JNI en Android), y ART aborta con local reference table overflow cuando una llamada reserva más allá de la capacidad de la tabla.

El disparador clásico es un bucle que crea una referencia por iteración dentro de una sola llamada. Esta función de rust/handles/src/lib.rs, el lado nativo de formatSamples en el wrapper, convierte cada muestra en una cadena de Java, y sería justo ese fallo si no hubiera un borrado dentro del bucle:

#[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()
}

Las llamadas a expect abortan si algo falla: honestas, pero bruscas. La parte 8 las sustituye por un tratamiento de errores de verdad. Cuando el cuerpo de un bucle necesita varias referencias vivas a la vez, JNIEnv::with_local_frame envuelve una clausura en un marco de referencias nuevo y libera todo lo que hay dentro al terminar (documentación del crate jni).

Las locales se liberan al volver a Java, y en un hilo nativo que has adjuntado tú no hay vuelta a Java. Un worker de Rust de larga duración que toque objetos Java acumula locales hasta que se desacopla, cosa que, en un grupo de hilos, puede no ocurrir nunca. La regla: en cualquier hilo que sobreviva a una llamada, borra cada local que crees. La parte 9 trata a fondo los hilos adjuntados.

Cuando un objeto Java tiene que sobrevivir de verdad a la llamada, promuévelo. env.new_global_ref(&obj) devuelve un GlobalRef que mantiene el objeto vivo entre llamadas y entre hilos, y el crate jni borra la referencia subyacente cuando se suelta el último clon (documentación del crate jni): un Arc sobre un objeto Java. Guárdalo dentro de Engine y el patrón del handle encaja solo: destroy suelta el Engine, que suelta el GlobalRef, que libera el objeto. Si te olvidas, es Rust quien está anclando el montón de Java. Una referencia global a una View ancla la Activity que hay detrás, y así es como el código nativo fuga una Activity que el lado Java nunca trató mal.

¿Cómo encuentras la fuga una vez que ya la has publicado?

Vigilando el montón nativo, porque nada de lo que vigila el montón de Java te lo va a contar. Aquí hay una fuga deliberada, app/src/main/java/dev/davthecoder/rustseries/LeakDemo.kt, conectada a un botón de la app de demostración:

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

Cada Engine abandonado guarda 100.000 doubles, así que leak(50) deja varados al menos 40 MB en el montón de Rust. Pulsa el botón unas cuantas veces y saca el resumen del proceso:

adb shell dumpsys meminfo dev.davthecoder.rustseries

La firma de una fuga de handle es inconfundible en esa salida. La fila Java Heap se queda más o menos plana: los wrappers son minúsculos y se recolectan cuando les toca. La fila Native Heap sube un escalón, más o menos del tamaño fugado, en cada pulsación, y no vuelve a bajar: ni después de un GC, ni después de mandar la app a segundo plano. Las fugas de Java derivan; las fugas de handle suben en escalera. No pego una tabla capturada, porque las cifras varían según el dispositivo y la versión de Android, y esta serie no imprime números que no haya medido; cuando el repositorio compañero publique el módulo handles, el código de arriba reproduce la escalera. Dentro del proceso, Debug.getNativeHeapAllocatedSize() da la misma curva desde un test.

LeakCanary analiza volcados del montón de Java, así que la fuga de arriba le resulta invisible: los wrappers ya se recolectaron, y nada en el volcado apunta a la memoria de Rust que quedó varada. Sigue ganándose el sitio en la fuga simétrica, una Activity anclada por una referencia global olvidada, cuya traza termina en una raíz global nativa. Para atribuir de verdad, heapprofd, el perfilador de montón nativo de Perfetto, muestrea malloc con trazas de pila y señala, dentro de tu .so, el punto de la reserva. El Address Sanitizer del NDK tiene su sitio en la caja de herramientas para los fallos de corrupción —use-after-free y double-free a través de handles caducados—, no para salir de caza de fugas.

Cada expect de aquí sigue abortando el proceso cuando JNI se porta mal, y esa es la siguiente corrección. La parte 8, Tratamiento de errores y panics: cómo mantener los fallos de Rust fuera de tu app de Android, construye la frontera que convierte los panics y los errores de Result en excepciones de Kotlin. El índice de la serie está en el artículo central.

Share:Compartir:

CommentsComentarios

Loading comments…Cargando comentarios…