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
--
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 CompletableFutureadd_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 RuntimeException
s:
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?






