Skip to content

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

This article provides a hands-on guide to integrating Rust and Java through the Java Native Interface (JNI), covering everything from cross-language invocation and unified logging to asynchronous execution and error handling. It’s accompanied by a fully working open-source demo project, rust-java-demo, to help you quickly get started building cross-language applications.
How to Supercharge Your Java Project with Rust - A Practical Guide to JNI Integration with a Complete Example
On this page

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:

(Figure 1: The Example)
(Figure 1: The Example)

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

java
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:

java
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:

rust
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:

rust
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:

rust
#[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:

rust
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:

java
public class AsyncRegistry {
    private static final AtomicLong FUTURE_ID = new AtomicLong();
    private static final Map<Long, CompletableFuture<?>> FUTURE_REGISTRY = new ConcurrentHashMap<>();
}
public CompletableFuture<Integer> 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:

rust
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!

Stay in the loop

Join our community