Skip to content
Rust & NativeRust y nativo

Passing Data Between Kotlin and Rust: Strings, Arrays, ByteBuffers, and Zero-CopyPasar datos entre Kotlin y Rust: cadenas, arrays, ByteBuffers y copia cero

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 28 August 2026Actualizado el 28 de agosto de 202610 min read10 min de lectura
AndroidRustNDKJNIKotlinPerformance
Passing Data Between Kotlin and Rust: Strings, Arrays, ByteBuffers, and Zero-Copy
Share:Compartir:

Rust + NDK on Android, Part 6 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: Fast Image Processing on Android with Rust: Bitmaps, Filters, and the JNI Fast Path. Next: Memory Management Across JNI: Ownership, Box::into_raw, Global Refs, and Leaks.

There are exactly three ways to pass a byte array from Kotlin to Rust over JNI: copy it with a region call, borrow it with a critical section, or share it through a direct ByteBuffer. Each trades safety rules for speed in a different place (Oracle, JNI Functions). The short answer for most apps: use the region copy until profiling complains, use a direct ByteBuffer for large buffers you reuse, and treat the critical section as a specialist tool with handcuffs attached.

In Part 5 we skipped this whole question by locking Bitmap pixels directly. This post is about every other payload: the byte arrays, strings and buffers that ordinary APIs pass around. It assumes the toolchain from Part 1 and the JNI plumbing from Part 2.

Key Takeaways

  • JNI moves bytes three ways: region calls always copy, element access may copy or pin, and direct ByteBuffers alias the same memory with no copy at all (Android JNI tips).
  • GetPrimitiveArrayCritical raises the odds of a real pointer, but between get and release you may not call JNI or block. The jni crate turns the no-JNI-calls half of that contract into a compile error; not blocking is still on you.
  • Java strings are not UTF-8. They cross JNI as modified UTF-8, and the jni crate decodes them through the cesu8 crate so your emoji survive.
  • Per-call overhead is fixed and payload cost scales with size: one chunky call beats a thousand chatty ones, so design APIs that cross rarely.

What actually crosses the JNI boundary

Primitives are cheap: an Int or Long argument arrives by value, no marshalling involved. Objects cross as references, not data, and every field read is a call back into the runtime. The expensive traffic is bulk data, arrays and strings, where JNI gives the runtime three behaviours (Android JNI tips):

  • Copy. The region calls, GetByteArrayRegion and SetByteArrayRegion, copy a range of the array into or out of a buffer you provide. Fixed overhead, no release call to forget, and Android’s JNI tips push you toward them whenever a copy is all you need.
  • Copy or pin. GetByteArrayElements returns a pointer that is either a fresh copy or the pinned real array. The isCopy flag tells you which, and you must call the matching release either way (Oracle, JNI Functions).
  • Alias. GetPrimitiveArrayCritical and direct ByteBuffers give you a pointer into memory Kotlin can also see. No copy, and no referee: while Rust holds that pointer, nothing on the JVM side may touch the data.

Rust’s safety model is built on knowing who can read and write memory, and a pointer Kotlin handed you is invisible to the borrow checker. The moment you turn it into a &mut [u8], you are personally guaranteeing that nothing else, Kotlin or Rust, touches those bytes until you’re done. Part 7, Memory Management Across JNI: Ownership, Box::into_raw, Global Refs, and Leaks, makes those ownership contracts explicit.

The three paths, side by side:

Path Copies Rules while held When to use
Region copy (GetByteArrayRegion / SetByteArrayRegion) Two, in and out None The default until profiling says otherwise
Critical access (GetPrimitiveArrayCritical) Usually none, the runtime may still copy No JNI calls, no blocking, release quickly Short hot sections, once profiling blames the copies
Direct ByteBuffer None Keep the buffer alive; nothing else touches the memory during the call Large, long-lived buffers you reuse

How do you pass a byte array from Kotlin to Rust?

Three implementations of one API. The crate lives at rust/data-passing, and its manifest at rust/data-passing/Cargo.toml is the same shape as every crate in this series:

[package]
name = "data-passing"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
jni = "0.21"

The actual work is a plain Rust function at the top of rust/data-passing/src/lib.rs, plus one helper for a papercut you’ll hit immediately: JNI’s jbyte is i8, while Rust bytes are u8. Same bits, different type, so we reinterpret the slice once:

use jni::objects::{JByteArray, JByteBuffer, JObject, JString, ReleaseMode};
use jni::sys::{jbyte, jstring};
use jni::JNIEnv;

/// The real work. Pure Rust, no JNI types, testable on your laptop
/// with `cargo test` before an emulator ever gets involved.
fn xor_in_place(data: &mut [u8], key: u8) {
    for byte in data.iter_mut() {
        *byte ^= key;
    }
}

/// jbyte is i8, Rust bytes are u8. Same bits, so reinterpret the slice.
fn as_bytes_mut(buf: &mut [i8]) -> &mut [u8] {
    // SAFETY: i8 and u8 have identical size and alignment.
    unsafe { std::slice::from_raw_parts_mut(buf.as_mut_ptr() as *mut u8, buf.len()) }
}

Path 1: the region copy, your boring default

Copy the array into Rust-owned memory, work on it, copy the result back. Two full copies of the payload, zero rules to remember. This function goes in the same rust/data-passing/src/lib.rs:

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_DataBridge_xorCopy<'local>(
    env: JNIEnv<'local>,
    _this: JObject<'local>,
    array: JByteArray<'local>,
    key: jbyte,
) {
    let len = env.get_array_length(&array).expect("array length") as usize;
    let mut buf = vec![0i8; len];
    env.get_byte_array_region(&array, 0, &mut buf)
        .expect("copy from Kotlin");

    xor_in_place(as_bytes_mut(&mut buf), key as u8);

    env.set_byte_array_region(&array, 0, &buf)
        .expect("copy back to Kotlin");
}

There is no release call, no unsafe block, and no restrictions between the two copies. You can log, allocate, call back into Kotlin, even block, because the buffer is ordinary Rust memory the garbage collector never sees. The expect calls are placeholders until a later post brings real error handling.

Path 2: the critical section, speed with handcuffs

GetPrimitiveArrayCritical asks the runtime for the actual array storage. The spec’s contract is blunt: the runtime may disable garbage collection while you hold the pointer, you must not call any other JNI function, you must not block, and you should release it quickly (Oracle, JNI Functions). Break those rules and you get the bug that deadlocks one device model in production.

The jni crate turns that contract into types. get_array_elements_critical borrows the JNIEnv mutably while the returned guard lives, so calling any other JNI method inside the critical section is a compile error:

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_DataBridge_xorCritical<'local>(
    mut env: JNIEnv<'local>,
    _this: JObject<'local>,
    array: JByteArray<'local>,
    key: jbyte,
) {
    // SAFETY: we touch no other JNI functions and do not block while
    // `elements` is alive, and no one else mutates the array concurrently.
    let mut elements = unsafe {
        env.get_array_elements_critical(&array, ReleaseMode::CopyBack)
            .expect("critical array access")
    };

    xor_in_place(as_bytes_mut(&mut elements), key as u8);

    // Dropping `elements` releases the critical section and, if the
    // runtime handed us a copy, writes it back because of CopyBack.
}

ReleaseMode::CopyBack covers the case where the runtime gave you a copy instead of the real pointer: changes are written back on release. If you only read, NoCopyBack skips that. Note the spec’s honest wording: critical access makes an uncopied pointer more likely, not guaranteed, which is exactly why the measurement section below exists.

Path 3: the direct ByteBuffer, actual zero-copy

A direct ByteBuffer is allocated outside the garbage-collected heap, which is what makes it safe to share: its address never moves for the lifetime of the buffer object (Android, ByteBuffer reference). Rust asks for the address and length once, then works on the memory in place. Nothing gets copied in either direction, and the critical section rules don’t apply:

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_DataBridge_xorDirect<'local>(
    env: JNIEnv<'local>,
    _this: JObject<'local>,
    buffer: JByteBuffer<'local>,
    key: jbyte,
) {
    let ptr = env
        .get_direct_buffer_address(&buffer)
        .expect("needs a direct ByteBuffer, not a heap one");
    let len = env
        .get_direct_buffer_capacity(&buffer)
        .expect("buffer capacity");

    // SAFETY: the buffer is direct, so this address is stable, Kotlin
    // keeps the buffer alive for the whole call, and nothing else
    // reads or writes it while we hold this slice.
    let bytes = unsafe { std::slice::from_raw_parts_mut(ptr, len) };
    xor_in_place(bytes, key as u8);
}

Two traps. First, this only works for buffers from ByteBuffer.allocateDirect; a plain ByteBuffer.allocate lives on the managed heap, GetDirectBufferAddress returns null, and that first expect fires. Second, direct buffers are not free: allocation costs more than a heap array and reclaim waits for the buffer object’s collection, which is why the platform docs recommend them for large, long-lived buffers you reuse (Android, ByteBuffer reference). Kotlin also reads them back through get calls rather than array indexing, so zero-copy on the Rust side can quietly move cost to the Kotlin side.

The Kotlin side

All three entry points hang off one object in app/src/main/java/dev/davthecoder/rustseries/DataBridge.kt. Cargo turns the data-passing package name into libdata_passing.so, so that’s the name we load:

package dev.davthecoder.rustseries

import java.nio.ByteBuffer

object DataBridge {
    init {
        System.loadLibrary("data_passing")
    }

    external fun xorCopy(data: ByteArray, key: Byte)
    external fun xorCritical(data: ByteArray, key: Byte)
    external fun xorDirect(buffer: ByteBuffer, key: Byte)
    external fun shout(input: String): String
}

And the call sites, with the direct buffer allocated once and reused:

val payload = ByteArray(16 * 1024 * 1024) { it.toByte() }
DataBridge.xorCopy(payload, 0x5A.toByte())
DataBridge.xorCritical(payload, 0x5A.toByte())

val frame: ByteBuffer = ByteBuffer.allocateDirect(16 * 1024 * 1024)
DataBridge.xorDirect(frame, 0x5A.toByte())

Strings and the modified UTF-8 trap

Java strings are UTF-16 inside the runtime, and they cross JNI as modified UTF-8, not real UTF-8: NUL becomes the two bytes 0xC0 0x80, and anything outside the basic plane, every emoji for a start, becomes a six-byte surrogate pair instead of the standard four bytes (Oracle, JNI Types). Strict decoders reject both forms. The classic bug is Rust code that handles “hello” fine and breaks the first time a user types an emoji. The reverse direction is worse: real UTF-8 with four-byte sequences is invalid input to NewStringUTF, and CheckJNI flags it loudly (Android JNI tips).

The jni crate absorbs this. get_string hands you a JavaStr in modified UTF-8, and converting it into a Rust String runs a proper decode through the cesu8 crate; new_string re-encodes on the way out. This function completes rust/data-passing/src/lib.rs:

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_DataBridge_shout<'local>(
    mut env: JNIEnv<'local>,
    _this: JObject<'local>,
    input: JString<'local>,
) -> jstring {
    // The Into<String> conversion decodes modified UTF-8 correctly,
    // so `DataBridge.shout("hola 🦀")` keeps its crab.
    let text: String = env
        .get_string(&input)
        .map(Into::into)
        .unwrap_or_default();
    env.new_string(text.to_uppercase())
        .expect("re-encode reply")
        .into_raw()
}

Two practical notes. The crate’s own docs warn that get_string validates its argument is really a java.lang.String and can be several times slower than get_string_unchecked for very short strings (docs.rs, jni crate); keep the checked version until a profile objects. And for large text, skip strings at the boundary entirely: encode to UTF-8 bytes in Kotlin, pass a ByteArray, decode with std::str::from_utf8 in Rust, and inherit every byte array option above.

Measuring the crossing before believing anything

The honest way to choose between these paths is the harness from Part 5: release builds only, a named physical device, warm-up before timing, and the median of many runs. JNI microbenchmarks mislead easily: JIT warm-up on the Kotlin side and copy behaviour on the runtime side both change between the first call and the thousandth.

Four measurements tell the story: an empty JNI call to isolate the fixed per-call overhead, the same XOR at sizes from a few bytes to tens of megabytes through each path, and a pure Kotlin loop as the baseline. The expected shape follows from the mechanics: the region path pays two payload-sized copies, so its cost grows with the buffer, while the critical and direct paths pay mostly the fixed crossing cost. Below some size that overhead dominates and the paths converge, the regime where you should just use the copy.

I’m not printing a results table here, deliberately. This series only publishes numbers captured from the companion repo’s benchmark module on named hardware, and the data-passing numbers land when that module ships; the chart slots in here then. Until then, treat any JNI throughput figure you read, including mine, as a hypothesis to verify on your own devices.

Design the API so data crosses rarely

The first item in Android’s own JNI guidance is to minimize marshalling across the boundary (Android JNI tips). Every crossing pays a fixed toll and every copied payload pays by the byte. The rule that follows is chunky over chatty. One call processing a whole frame beats a thousand calls processing a pixel each, because the thousand calls pay the toll a thousand times.

In practice that means batching: pass the whole buffer and a description of the work, not a stream of tiny requests, and keep state that only Rust needs on the Rust side of the border. When a domain has a purpose-built escape hatch, use it: locking Bitmap pixels in Part 5 was exactly that, a zero-copy path that never touches a Java array. Audio takes the same idea to its limit, since a real-time callback has no budget for copies; a later part in this series, Low-Latency Audio DSP with Rust: AAudio and Oboe from a Rust Core, covers that path.

Reusable direct ByteBuffers are the general-purpose version of that pattern: allocate once, hand the same buffer across every frame, and the steady-state cost falls to the fixed call overhead.

Where this leaves you

Every example here finished its borrow before returning, which is why none of it leaked. The moment Rust keeps state alive between calls, you need Box::into_raw, global references and an explicit destroy contract, and that’s what Part 7, Memory Management Across JNI: Ownership, Box::into_raw, Global Refs, and Leaks, is about. The series index lives at the hub.

Sources

Rust + NDK on Android, Part 6 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: Fast Image Processing on Android with Rust: Bitmaps, Filters, and the JNI Fast Path. Next: Memory Management Across JNI: Ownership, Box::into_raw, Global Refs, and Leaks.

Hay exactamente tres formas de pasar un array de bytes de Kotlin a Rust por JNI: copiarlo con una llamada de región, tomarlo prestado con una sección crítica o compartirlo a través de un ByteBuffer directo. Cada una cambia reglas de seguridad por velocidad en un sitio distinto (Oracle, funciones JNI). La respuesta corta para la mayoría de las aplicaciones: usa la copia por región hasta que el perfilado se queje, usa un ByteBuffer directo para búferes grandes que reutilices y trata la sección crítica como una herramienta de especialista con las esposas puestas.

En la parte 5 nos saltamos toda esta pregunta bloqueando los píxeles del Bitmap directamente. Este artículo va de cualquier otra carga: los arrays de bytes, las cadenas y los búferes que pasan las API normales. Da por sentada la cadena de herramientas de la parte 1 y el montaje JNI de la parte 2.

Puntos clave

  • JNI mueve bytes de tres maneras: las llamadas de región siempre copian, el acceso a elementos puede copiar o fijar, y los ByteBuffers directos apuntan a la misma memoria sin copia alguna (consejos de JNI de Android).
  • GetPrimitiveArrayCritical sube las probabilidades de un puntero real, pero entre el get y el release no puedes llamar a JNI ni bloquearte. El crate jni convierte en error de compilación la mitad de «nada de llamadas JNI» de ese contrato; no bloquearte sigue siendo cosa tuya.
  • Las cadenas de Java no son UTF-8. Cruzan JNI como UTF-8 modificado, y el crate jni las decodifica a través del crate cesu8 para que tus emojis sobrevivan.
  • El sobrecoste por llamada es fijo y el coste de la carga escala con el tamaño: una llamada gruesa gana a mil parlanchinas, así que diseña API que crucen pocas veces.

Qué cruza de verdad la frontera JNI

Los primitivos son baratos: un argumento Int o Long llega por valor, sin marshalling. Los objetos cruzan como referencias, no como datos, y cada lectura de un campo es una llamada de vuelta al runtime. El tráfico caro es el de datos en bloque, arrays y cadenas, donde JNI da al runtime tres comportamientos (consejos de JNI de Android):

  • Copia. Las llamadas de región, GetByteArrayRegion y SetByteArrayRegion, copian un tramo del array hacia dentro o hacia fuera de un búfer que tú aportas. Sobrecoste fijo, ninguna llamada de release que olvidar, y los consejos de JNI de Android te empujan hacia ellas siempre que una copia sea todo lo que necesitas.
  • Copia o fijación. GetByteArrayElements devuelve un puntero que o es una copia nueva o el array real fijado. La bandera isCopy te dice cuál, y tienes que llamar al release correspondiente en cualquier caso (Oracle, funciones JNI).
  • Alias. GetPrimitiveArrayCritical y los ByteBuffers directos te dan un puntero a memoria que Kotlin también puede ver. Ni copia, ni árbitro: mientras Rust tenga ese puntero, nada en el lado de la JVM puede tocar los datos.

El modelo de seguridad de Rust se construye sabiendo quién puede leer y escribir memoria, y un puntero que te entregó Kotlin es invisible para el comprobador de préstamos. En el momento en que lo conviertes en un &mut [u8], estás garantizando en persona que nadie más, ni Kotlin ni Rust, toca esos bytes hasta que termines. La parte 7, Gestión de memoria a través de JNI: propiedad, Box::into_raw, referencias globales y fugas, deja explícitos esos contratos de propiedad.

Las tres vías, una al lado de la otra:

Vía Copias Reglas mientras la tienes Cuándo usarla
Copia por región (GetByteArrayRegion / SetByteArrayRegion) Dos, de ida y de vuelta Ninguna El valor por defecto hasta que el perfilado diga lo contrario
Acceso crítico (GetPrimitiveArrayCritical) Normalmente ninguna; el runtime aún puede copiar Nada de llamadas JNI, nada de bloqueos, libera pronto Tramos calientes y cortos, cuando el perfilado culpe a las copias
ByteBuffer directo Ninguna Mantén vivo el búfer; nadie más toca la memoria durante la llamada Búferes grandes y de vida larga que reutilices

¿Cómo pasas un array de bytes de Kotlin a Rust?

Tres implementaciones de una sola API. El crate vive en rust/data-passing, y su manifiesto en rust/data-passing/Cargo.toml tiene la misma forma que todos los crates de esta serie:

[package]
name = "data-passing"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
jni = "0.21"

El trabajo de verdad es una función de Rust sencilla al principio de rust/data-passing/src/lib.rs, más un ayudante para un pinchazo con el que vas a toparte enseguida: el jbyte de JNI es i8, mientras que los bytes de Rust son u8. Los mismos bits, distinto tipo, así que reinterpretamos el slice una vez:

use jni::objects::{JByteArray, JByteBuffer, JObject, JString, ReleaseMode};
use jni::sys::{jbyte, jstring};
use jni::JNIEnv;

/// The real work. Pure Rust, no JNI types, testable on your laptop
/// with `cargo test` before an emulator ever gets involved.
fn xor_in_place(data: &mut [u8], key: u8) {
    for byte in data.iter_mut() {
        *byte ^= key;
    }
}

/// jbyte is i8, Rust bytes are u8. Same bits, so reinterpret the slice.
fn as_bytes_mut(buf: &mut [i8]) -> &mut [u8] {
    // SAFETY: i8 and u8 have identical size and alignment.
    unsafe { std::slice::from_raw_parts_mut(buf.as_mut_ptr() as *mut u8, buf.len()) }
}

Vía 1: la copia por región, tu opción aburrida por defecto

Copias el array a memoria en propiedad de Rust, trabajas sobre ella y copias el resultado de vuelta. Dos copias completas de la carga, cero reglas que recordar. Esta función va en el mismo rust/data-passing/src/lib.rs:

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_DataBridge_xorCopy<'local>(
    env: JNIEnv<'local>,
    _this: JObject<'local>,
    array: JByteArray<'local>,
    key: jbyte,
) {
    let len = env.get_array_length(&array).expect("array length") as usize;
    let mut buf = vec![0i8; len];
    env.get_byte_array_region(&array, 0, &mut buf)
        .expect("copy from Kotlin");

    xor_in_place(as_bytes_mut(&mut buf), key as u8);

    env.set_byte_array_region(&array, 0, &buf)
        .expect("copy back to Kotlin");
}

No hay llamada de release, ni bloque unsafe, ni restricciones entre las dos copias. Puedes registrar, asignar, volver a llamar a Kotlin e incluso bloquearte, porque el búfer es memoria de Rust normal que el recolector de basura no ve nunca. Las llamadas a expect son un apaño hasta que un artículo posterior traiga el manejo de errores de verdad.

Vía 2: la sección crítica, velocidad con esposas

GetPrimitiveArrayCritical pide al runtime el almacenamiento real del array. El contrato de la especificación es brusco: el runtime puede desactivar la recolección de basura mientras tengas el puntero, no debes llamar a ninguna otra función JNI, no debes bloquearte y deberías liberarlo pronto (Oracle, funciones JNI). Rompe esas reglas y te llevas el fallo que deja en interbloqueo un modelo de dispositivo en producción.

El crate jni convierte ese contrato en tipos. get_array_elements_critical toma prestado el JNIEnv de forma mutable mientras vive el guardia que devuelve, así que llamar a cualquier otro método JNI dentro de la sección crítica es un error de compilación:

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_DataBridge_xorCritical<'local>(
    mut env: JNIEnv<'local>,
    _this: JObject<'local>,
    array: JByteArray<'local>,
    key: jbyte,
) {
    // SAFETY: we touch no other JNI functions and do not block while
    // `elements` is alive, and no one else mutates the array concurrently.
    let mut elements = unsafe {
        env.get_array_elements_critical(&array, ReleaseMode::CopyBack)
            .expect("critical array access")
    };

    xor_in_place(as_bytes_mut(&mut elements), key as u8);

    // Dropping `elements` releases the critical section and, if the
    // runtime handed us a copy, writes it back because of CopyBack.
}

ReleaseMode::CopyBack cubre el caso en el que el runtime te dio una copia en lugar del puntero real: los cambios se escriben de vuelta al liberar. Si solo lees, NoCopyBack se salta eso. Fíjate en la redacción honesta de la especificación: el acceso crítico hace más probable un puntero sin copiar, no lo garantiza, y exactamente por eso existe la sección de medición de más abajo.

Vía 3: el ByteBuffer directo, copia cero de verdad

Un ByteBuffer directo se asigna fuera del montón del recolector de basura, y eso es lo que hace seguro compartirlo: su dirección no se mueve durante la vida del objeto búfer (Android, referencia de ByteBuffer). Rust pide la dirección y la longitud una vez, y luego trabaja sobre la memoria in situ. No se copia nada en ninguna dirección, y las reglas de la sección crítica no se aplican:

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_DataBridge_xorDirect<'local>(
    env: JNIEnv<'local>,
    _this: JObject<'local>,
    buffer: JByteBuffer<'local>,
    key: jbyte,
) {
    let ptr = env
        .get_direct_buffer_address(&buffer)
        .expect("needs a direct ByteBuffer, not a heap one");
    let len = env
        .get_direct_buffer_capacity(&buffer)
        .expect("buffer capacity");

    // SAFETY: the buffer is direct, so this address is stable, Kotlin
    // keeps the buffer alive for the whole call, and nothing else
    // reads or writes it while we hold this slice.
    let bytes = unsafe { std::slice::from_raw_parts_mut(ptr, len) };
    xor_in_place(bytes, key as u8);
}

Dos trampas. Primera, esto solo funciona con búferes de ByteBuffer.allocateDirect; un ByteBuffer.allocate normal vive en el montón gestionado, GetDirectBufferAddress devuelve null y ese primer expect salta. Segunda, los búferes directos no son gratis: asignarlos cuesta más que un array del montón, y la recuperación espera a la recolección del objeto búfer, y por eso la documentación de la plataforma los recomienda para búferes grandes y de vida larga que reutilices (Android, referencia de ByteBuffer). Kotlin también los lee de vuelta con llamadas a get, no con indexación de array, así que la copia cero en el lado de Rust puede trasladar el coste en silencio al lado de Kotlin.

El lado de Kotlin

Los tres puntos de entrada cuelgan de un solo objeto en app/src/main/java/dev/davthecoder/rustseries/DataBridge.kt. Cargo convierte el nombre del paquete data-passing en libdata_passing.so, así que ese es el nombre que cargamos:

package dev.davthecoder.rustseries

import java.nio.ByteBuffer

object DataBridge {
    init {
        System.loadLibrary("data_passing")
    }

    external fun xorCopy(data: ByteArray, key: Byte)
    external fun xorCritical(data: ByteArray, key: Byte)
    external fun xorDirect(buffer: ByteBuffer, key: Byte)
    external fun shout(input: String): String
}

Y los puntos de llamada, con el búfer directo asignado una vez y reutilizado:

val payload = ByteArray(16 * 1024 * 1024) { it.toByte() }
DataBridge.xorCopy(payload, 0x5A.toByte())
DataBridge.xorCritical(payload, 0x5A.toByte())

val frame: ByteBuffer = ByteBuffer.allocateDirect(16 * 1024 * 1024)
DataBridge.xorDirect(frame, 0x5A.toByte())

Las cadenas y la trampa del UTF-8 modificado

Las cadenas de Java son UTF-16 dentro del runtime, y cruzan JNI como UTF-8 modificado, no como UTF-8 de verdad: NUL se convierte en los dos bytes 0xC0 0x80, y cualquier cosa fuera del plano básico, para empezar todos los emojis, se convierte en un par suplente de seis bytes en lugar de los cuatro bytes estándar (Oracle, tipos JNI). Los decodificadores estrictos rechazan las dos formas. El fallo clásico es código Rust que maneja «hello» bien y se rompe la primera vez que alguien escribe un emoji. La dirección inversa es peor: UTF-8 real con secuencias de cuatro bytes es entrada inválida para NewStringUTF, y CheckJNI lo señala bien alto (consejos de JNI de Android).

El crate jni se encarga de esto. get_string te entrega un JavaStr en UTF-8 modificado, y convertirlo en un String de Rust ejecuta una decodificación correcta a través del crate cesu8; new_string recodifica a la salida. Esta función completa rust/data-passing/src/lib.rs:

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_DataBridge_shout<'local>(
    mut env: JNIEnv<'local>,
    _this: JObject<'local>,
    input: JString<'local>,
) -> jstring {
    // The Into<String> conversion decodes modified UTF-8 correctly,
    // so `DataBridge.shout("hola 🦀")` keeps its crab.
    let text: String = env
        .get_string(&input)
        .map(Into::into)
        .unwrap_or_default();
    env.new_string(text.to_uppercase())
        .expect("re-encode reply")
        .into_raw()
}

Dos notas prácticas. La propia documentación del crate avisa de que get_string valida que su argumento sea de verdad un java.lang.String y puede ser varias veces más lento que get_string_unchecked con cadenas muy cortas (docs.rs, crate jni); quédate con la versión comprobada hasta que un perfil se queje. Y para texto grande, sáltate las cadenas en la frontera por completo: codifica a bytes UTF-8 en Kotlin, pasa un ByteArray, decodifica con std::str::from_utf8 en Rust y heredas todas las opciones de arrays de bytes de arriba.

Medir el cruce antes de creer nada

La forma honesta de elegir entre estas vías es el arnés de la parte 5: solo compilaciones de release, un dispositivo físico con nombre, calentamiento antes de cronometrar y la mediana de muchas pasadas. Los microbenchmarks de JNI engañan con facilidad: el calentamiento del JIT en el lado de Kotlin y el comportamiento de copia en el lado del runtime cambian entre la primera llamada y la milésima.

Cuatro medidas cuentan la historia: una llamada JNI vacía para aislar el sobrecoste fijo por llamada, el mismo XOR en tamaños que van de unos pocos bytes a decenas de megabytes por cada vía, y un bucle de Kotlin puro como línea de base. La forma esperada sale de la mecánica: la vía de región paga dos copias del tamaño de la carga, así que su coste crece con el búfer, mientras que las vías crítica y directa pagan sobre todo el coste fijo de cruzar. Por debajo de cierto tamaño ese sobrecoste domina y las vías convergen, el régimen en el que deberías usar la copia y ya está.

No pinto aquí una tabla de resultados, y es a propósito. Esta serie solo publica números capturados del módulo de benchmark del repositorio complementario en hardware con nombre, y los números de data-passing llegan cuando ese módulo se publique; el gráfico encaja aquí entonces. Hasta ese momento, trata cualquier cifra de rendimiento de JNI que leas, incluida la mía, como una hipótesis que verificar en tus propios dispositivos.

Diseña la API para que los datos crucen pocas veces

El primer punto de la propia guía de JNI de Android es minimizar el marshalling a través de la frontera (consejos de JNI de Android). Cada cruce paga un peaje fijo y cada carga copiada paga por byte. La regla que sigue es grueso antes que parlanchín. Una llamada que procesa un frame entero gana a mil llamadas que procesan un píxel cada una, porque las mil llamadas pagan el peaje mil veces.

En la práctica eso significa agrupar: pasa el búfer entero y una descripción del trabajo, no un chorro de peticiones minúsculas, y deja en el lado Rust de la frontera el estado que solo Rust necesita. Cuando un dominio tiene una vía de escape hecha a medida, úsala: bloquear los píxeles del Bitmap en la parte 5 era exactamente eso, una vía de copia cero que no toca un array de Java. El audio lleva la misma idea al límite, porque un callback de tiempo real no tiene presupuesto para copias; una parte posterior de esta serie, DSP de audio de baja latencia con Rust: AAudio y Oboe desde un núcleo en Rust, cubre esa vía.

Los ByteBuffers directos reutilizables son la versión de propósito general de ese patrón: asigna una vez, entrega el mismo búfer en cada frame y el coste en régimen permanente baja al sobrecoste fijo de la llamada.

Cada ejemplo de aquí terminaba su préstamo antes de volver, y por eso nada de esto tenía fugas. En el momento en que Rust mantiene estado vivo entre llamadas, necesitas Box::into_raw, referencias globales y un contrato explícito de destrucción, y de eso va la parte 7, Gestión de memoria a través de JNI: propiedad, Box::into_raw, referencias globales y fugas. El índice de la serie está en el hub.

Fuentes

Share:Compartir:

CommentsComentarios

Loading comments…Cargando comentarios…