Embedding JVM in Rust

Java ecosystem is rich and mature. I won’t be surprised if some application may need to call a piece of Java code. There are several options to do this, starting from the basic multi-processing. In this short post, I want to tell about embedding Java virtual machine (JVM) directly into Rust programs with the jni crate.

In this post:

  1. Embedding JVM into a Rust application.
  2. Calling Java code from Rust and Rust code from Java.

The post assumes you’re familiar with Java and Rust. You can find the code from this post in this repository on Github.

A toy example we will use may look very artificial, but this is to cover more features of the Java-Rust interaction. Here’s our Java library code. We have three classes:

package me.ivanyu.java_library;

public class Calculator {
    public static void add(int a, int b, ResultCallback callback) {
        int sum = a + b;
        String message = "Result: " + sum;
        Result result = new Result(message);
        callback.onResult(result);
    }
}

// ...

@FunctionalInterface
public interface ResultCallback {
    void onResult(Result result);
}

// ...

public class Result {
    private final String message;

    public Result(final String message) {
        this.message = message;
    }

    public String getMessage() {
        return message;
    }
}

Calculator.add calculates the sum of two integers, makes a string of it and calls the callback with the result. Now we want to call this function from Rust code. We also want the callback to be a Rust function: Java → Rust → Java. We will use a Java mechanism called Java Native Interface (JNI) to achieve this.

First, we need to create a native Java method to which we can attach our native Rust code.

package me.ivanyu.java_library;

public class NativeCallback implements ResultCallback {
    @Override
    // Mind the `native` keyword.
    public native void onResult(Result result);
}

Now let’s build a JAR of our library:

./gradlew clean jar

Coming to Rust, we need a crate called jni:

[dependencies]
jni = { version = "0.21.1", features = ["invocation"] }

Mind the invocation feature, it’s needed to run a JVM from our Rust code.

Let’s define our callback:

// Prevent symbol mangling
// https://doc.rust-lang.org/rustc/symbol-mangling/index.html
#[unsafe(no_mangle)]
// `extern "system"` ensures the correct calling convention for JNI.
// The name follows the convention from
// https://docs.oracle.com/en/java/javase/21/docs/specs/jni/design.html#resolving-native-method-names
// However, generally this is not needed because we use RegisterNatives function
// (through `jni` crate).
pub extern "system" fn Java_me_ivanyu_java_1library_NativeCallback_onResult(
    mut env: JNIEnv,
    _obj: JObject,
    result_obj: JObject,
) {
    // Call getMessage on the result.
    // The signature means
    // "no arguments, returns `java.lang.String`".
    // More details on signatures:
    // https://docs.oracle.com/en/java/javase/21/docs/specs/jni/types.html
    let message_jstring = env
        .call_method(result_obj, "getMessage", "()Ljava/lang/String;", &[])
        .expect("Failed to call getMessage")
        .l()  // unwrap to Object (implicitly check for null)
        .expect("getMessage returned null");
    // Convert a Java string to Rust string.
    let message_jstring = JString::from(message_jstring);
    let message: String = env
        .get_string(&message_jstring)
        .expect("Couldn't get Java string")
        .into();

    println!("{}", message);
}

Now let’s run the JVM with our library in the class path and call the Calculator.add method. Make sure you’ve built the Java library first (the JAR file must exist at java/lib/build/libs/lib.jar):

fn main() {
    // Add the Java library to the class path.
    let jar_path = Path::new("java/lib/build/libs/lib.jar");
    let classpath_option = format!("-Djava.class.path={}", jar_path.display());
    let jvm_args = jni::InitArgsBuilder::new()
        .version(jni::JNIVersion::V8)
        .option(&classpath_option)
        .build()
        .expect("Failed to create JVM args");
    // Launch the JVM.
    let jvm = JavaVM::new(jvm_args).expect("Failed to create JVM");

    // The JNI interface pointer (JNIEnv) is valid only in the current thread. We need to attach it.
    // See more:
    // https://docs.oracle.com/en/java/javase/21/docs/specs/jni/invocation.html#attaching-to-the-vm
    // Detachment happens automatically when `env` is deleted.
    let mut env = jvm
        .attach_current_thread()
        .expect("Failed to attach current thread");

    // Find the native callback class and register the native method for it.
    let native_callback_class = env
        .find_class("me/ivanyu/java_library/NativeCallback")
        .expect("Failed to find NativeCallback class");
    // The signature means
    // "one argument of the class `me.ivanyu.java_library.Result`, returning `void`".
    // More details on signatures:
    // https://docs.oracle.com/en/java/javase/21/docs/specs/jni/types.html
    let native_methods = [jni::NativeMethod {
        name: jni::strings::JNIString::from("onResult"),
        sig: jni::strings::JNIString::from("(Lme/ivanyu/java_library/Result;)V"),
        fn_ptr: Java_me_ivanyu_java_1library_NativeCallback_onResult as *mut c_void,
    }];
    env.register_native_methods(&native_callback_class, &native_methods)
        .expect("Failed to register native methods");

    // Create an instance of NativeCallback
    let callback_obj = env
        // Our native callback code.
        .alloc_object(&native_callback_class)
        .expect("Failed to allocate NativeCallback object");

    // Find the Calculator class and call the `add` method (static).
    let calculator_class = env
        .find_class("me/ivanyu/java_library/Calculator")
        .expect("Failed to find Calculator class");
    env.call_static_method(
        &calculator_class,
        "add",
        "(IILme/ivanyu/java_library/ResultCallback;)V",
        &[
            jni::objects::JValue::Int(3),
            jni::objects::JValue::Int(5),
            jni::objects::JValue::Object(&callback_obj),
        ],
    )
    .expect("Failed to call add");
}

That’s it, let’s run:

cargo run 
Result: 8

There is another similar crate, Duchess, which is more high-level and focused on ergonomics. It uses marcos to reflect Java classes into Rust types and call methods as if they were native Rust methods. This may be a topic for another post.