Lessons from Mixing Rust and Java: Fast, Safe, and Practical

May 18, 2025 - 09:45
 0  0
Lessons from Mixing Rust and Java: Fast, Safe, and Practical

How to Supercharge Your Java Project with Rust — A Practical Guide to JNI Integration with a Complete Example

5 min read·4 days ago

--

Introduction

Rust and Java are both widely used languages, each excelling in different domains. In real-world scenarios, it’s often beneficial to combine them for more effective system-level and application-level programming:

  • In a Java application, you may want to bypass the Garbage Collector (GC) and manually manage memory in performance-critical regions.
  • You might port a performance-sensitive algorithm to Rust for speed and implementation hiding.
  • Or in a Rust application, you may wish to expose functionality to the Java ecosystem by packaging it as a JAR.

In this post, we’ll walk through how to organize and integrate Rust and Java in the same project. The focus is practical, with concrete code examples and step-by-step explanations. By the end, you’ll be able to create a cross-language application where Rust and Java interact smoothly.

Background: Understanding JNI and Java Memory Management

The Java Native Interface (JNI) is the bridge between Java and native code written in C/C++ or Rust. While its syntax is relatively simple, JNI is notoriously tricky in practice due to its implicit memory and thread management rules.

Memory Segments in Java Runtime

Java applications operate across several memory segments:

  • Java Heap: Where Java objects live, automatically managed by the Garbage Collector.
  • Native Memory: Memory allocated by native code (e.g., Rust), not directly managed by the GC — requiring explicit care to avoid memory leaks.
  • Others: Miscellaneous segments, such as code caches and metadata for compiled classes. Understanding these boundaries is key to writing performant, memory-safe cross-language code.

A Practical Integration: The rust-java-demo Project

Let’s walk through a real-world example: our open-source rust-java-demo repository demonstrates how to integrate Rust into Java applications seamlessly.

Packaging Platform-Specific Rust Libraries into a Single JAR

Java bytecode is platform-independent, but Rust binaries are not. Embedding a Rust dynamic library inside a JAR introduces platform dependency. While building separate JARs for each architecture is possible, it complicates distribution and deployment.

A better solution is to package platform-specific Rust libraries into different folders within a single JAR, and dynamically load the correct one at runtime.

After extracting our multi-platform JAR (jar xf rust-java-demo-2c59460-multi-platform.jar), you’ll find a structure like this:

We use a simple utility to load the correct library based on the host platform:

static {    JarJniLoader.loadLib(        RustJavaDemo.class,        "/io/greptime/demo/rust/libs",        "demo"    );}

This approach ensures platform flexibility without sacrificing developer or operational convenience.

Unifying Logs Across Rust and Java

Cross-language projects can quickly become debugging nightmares without unified logging. We tackled this by funneling all logs — Rust and Java — through the same SLF4J backend.

On the Java side, we define a simple Logger wrapper:

public class Logger {    private final org.slf4j.Logger inner;    public Logger(org.slf4j.Logger inner) {        this.inner = inner;    }    public void error(String msg) { inner.error(msg); }    public void info(String msg) { inner.info(msg); }    public void debug(String msg) { inner.debug(msg); }    // ...}

Rust then calls this logger using JNI. Here’s a simplified Rust implementation:

impl log::Log for Logger {    fn log(&self, record: &log::Record) {        let env = ...; // Obtain the JNI environment        let java_logger = find_java_side_logger();        let logger_method = java_logger.methods.find_method(record.level());        unsafe {            env.call_method_unchecked(                java_logger,                logger_method,                ReturnType::Primitive(Primitive::Void),                &[JValue::from(format_msg(record)).as_jni()]            );        }    }}

And we register it as the global logger:

log::set_logger(&LOGGER).expect("Failed to set global logger");

Now, logs from both languages are visible in the same output stream, simplifying diagnostics and monitoring.

Calling Rust Async Functions from Java

One of Rust’s standout features is its powerful async runtime. Unfortunately, JNI methods cannot be declared async, so calling async Rust code directly from Java is not straightforward:

#[no_mangle]pub extern "system" fn Java_io_greptime_demo_RustJavaDemo_hello(...) {    // ❌ This won't compile    foo().await;}async fn foo() { ... }

To bridge this, we must manually create a runtime (e.g., using Tokio) and manage async execution ourselves:

async fn async_add_one(x: i32) -> i32 {    x + 1}fn sync_add_one(x: i32) -> i32 {    let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();    let handle = rt.spawn(async_add_one(x));    rt.block_on(handle).unwrap()}

But block_on() blocks the current thread—including Java’s. Instead, we leverage a more idiomatic approach: asynchronous task spawning combined with CompletableFuture on the Java side, allowing non-blocking integration.

On the Java side:

public class AsyncRegistry {    private static final AtomicLong FUTURE_ID = new AtomicLong();    private static final Map> FUTURE_REGISTRY = new ConcurrentHashMap<>();}public CompletableFuture add_one(int x) {    long futureId = native_add_one(x); // Call Rust    return AsyncRegistry.take(futureId); // Get CompletableFuture}

This pattern — used in Apache OpenDAL — lets Java developers decide when and whether to block, making integration more flexible.

Mapping Rust Errors to Java Exceptions

To unify exception handling across languages, we convert Rust Result::Err into Java RuntimeExceptions:

fn throw_runtime_exception(env: &mut JNIEnv, msg: String) {    let msg = if let Some(ex) = env.exception_occurred() {        env.exception_clear();        let exception_info = ...; // Extract exception class + message        format!("{}. Java exception occurred: {}", msg, exception_info)    } else {        msg    };    env.throw_new("java/lang/RuntimeException", &msg);}

This ensures Java code can uniformly handle all exceptions, regardless of whether they originate from Rust or Java.

Conclusion

In this article, we explored key aspects of Rust-Java interoperability:

  • Packaging platform-specific native libraries into a single JAR.
  • Unifying logs across Rust and Java.
  • Bridging async Rust functions with Java’s CompletableFuture.
  • Mapping Rust errors into Java exceptions.

For more details and a full working demo, check out our open-source repo.

We welcome feedback, issues, and PRs from the community!

What's Your Reaction?

Like Like 0
Dislike Dislike 0
Love Love 0
Funny Funny 0
Angry Angry 0
Sad Sad 0
Wow Wow 0