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).
GetPrimitiveArrayCriticalraises 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,
GetByteArrayRegionandSetByteArrayRegion, 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.
GetByteArrayElementsreturns a pointer that is either a fresh copy or the pinned real array. TheisCopyflag tells you which, and you must call the matching release either way (Oracle, JNI Functions). - Alias.
GetPrimitiveArrayCriticaland 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.
That last word, alias, is the one to sit up for. 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");
}
Notice what’s absent: no release call, no unsafe block, 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.
Here’s the part I genuinely like about the jni crate: it 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, not a runtime crash:
#[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), and now you can see why: 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.
Frequently Asked Questions
What is the fastest way to pass a byte array from Kotlin to Rust?
For large, frequently reused buffers, a direct ByteBuffer: Rust works on the memory in place with no copies and no critical restrictions. For everything under a profiled threshold, GetByteArrayRegion copies are fast enough and far simpler. Measure before promoting anything past the copy.
Is GetPrimitiveArrayCritical safe to use on Android?
Yes, within its contract: no other JNI calls, no blocking, release quickly, because the runtime may pause garbage collection while you hold the pointer (Oracle, JNI Functions). The jni crate’s get_array_elements_critical enforces the no-JNI-calls rule at compile time by mutably borrowing the environment, which removes the scariest failure mode.
Why does my Rust code fail to decode a Java string as UTF-8?
Because JNI strings are modified UTF-8, not UTF-8: NUL becomes 0xC0 0x80 and emoji become six-byte surrogate pairs, both of which strict decoders reject (Oracle, JNI Types). Use the jni crate’s get_string conversion, which decodes through cesu8, instead of treating the raw pointer as &str.
Should I use a ByteArray or a direct ByteBuffer in my JNI API?
Default to ByteArray. It’s what Kotlin callers expect, and it works with all three paths, so you can start on the region copy and change your mind later without touching the API. Promote a specific buffer to allocateDirect once it’s large, long-lived and crossing often, which is the workload the platform documentation says direct buffers are for (Android, ByteBuffer reference).
Where this leaves you
The boundary is a toll road with posted prices, and the copy lane stays the right default until a measurement on your own hardware argues otherwise. That measurement costs one afternoon with the Part 5 harness, pointed at the crate from this post.
What we glossed over is lifetime. 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
- Android Developers, “JNI tips”, retrieved 2026-08-30, https://developer.android.com/training/articles/perf-jni
- Oracle, “JNI Functions” (Java SE 17 specification), retrieved 2026-08-30, https://docs.oracle.com/en/java/javase/17/docs/specs/jni/functions.html
- Oracle, “JNI Types and Data Structures: Modified UTF-8 Strings”, retrieved 2026-08-30, https://docs.oracle.com/en/java/javase/17/docs/specs/jni/types.html#modified-utf-8-strings
- Android Developers, “ByteBuffer” reference, retrieved 2026-08-30, https://developer.android.com/reference/java/nio/ByteBuffer
- docs.rs, jni crate 0.21.1 documentation, retrieved 2026-08-30, https://docs.rs/jni/0.21.1/jni/

Loading comments…