近期工作中有Rust和Java互相调用需求,这篇文章主要介绍如何用Rust通过JNI和Java进行交互,还有记录一下开发过程中遇到的一些坑。
JNI简单来说是一套Java与其他语言互相调用的标准,主要是C语言,官方也提供了基于C的C++接口。 既然是C语言接口,那么理论上支持C ABI的语言都可以和Java语言互相调用,Rust就是其中之一。
关于JNI的历史背景以及更详细的介绍可以参考官方文档
在Rust中和Java互相调用,可以使用原始的JNI接口,也就是自己声明JNI的C函数原型,在Rust里按照C的方式去调用,但这样写起来会很繁琐,而且都是unsafe的操作; 不过Rust社区里已经有人基于原始的JNI接口,封装好了一套safe的接口,crate的名字就叫jni,用这个库来开发就方便多了
文中涉及的代码放在了这个github仓库 https://github.com/metaworm/rust-java-demo
Rust JNI 工程配置
如果你熟悉Cargo和Maven,可以跳过这一节,直接看我提供的github源码即可
Rust工程配置
首先,通过cargo new java-rust-demo
创建一个rust工程
然后切换到工程目录cd java-rust-demo
,并编辑Cargo.toml
:修改类型为动态库、加上对 jni crate 的依赖
go
[package]
name = "rust-java-demo"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ['cdylib']
[dependencies]
jni = {version = '0.19'}
重命名src目录下的main.rs
为lib.rs
,Rust库类型的工程编译入口为 lib.rs,然后添加以下代码
go
use jni::objects::*;
use jni::JNIEnv;
#[no_mangle]
pub unsafe extern "C" fn Java_pers_metaworm_RustJNI_init(env: JNIEnv, _class: JClass) {
println!("rust-java-demo inited");
}
然后执行cargo build
构建,生成的动态库默认会位于target/debug
目录下,我这里用的linux系统,动态库文件名为librust_java_demo.so
,如果是Windows系统,文件名为rust_java_demo.dll
这样,我们第一个JNI函数就创建成功了! 通过Java_pers_metaworm_RustJNI_init
这个导出函数,给了Java的pers.metaworm.RustJNI
这个类提供了一个native的静态方法init
; 这里只是简单地打印了一句话,后面会通过这个初始化函数添加更多的功能
Java工程配置
还是在这个工程目录里,把Java部分的代码放在java
这个目录下,在其中创建pers/metaworm/RustJNI.java
文件
go
package pers.metaworm;
public class RustJNI {
static {
System.loadLibrary("rust_java_demo");
}
public static void main(String[] args) {
init();
}
static native void init();
}
我们使用流行的 maven 工具来构建Java工程,在项目根目录下创建 maven 的工程文件 pom.xml
go
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>pers.metaworm</groupId>
<artifactId>RustJNI</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<exec.mainClass>pers.metaworm.RustJNI</exec.mainClass>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<maven.compiler.encoding>UTF-8</maven.compiler.encoding>
</properties>
<dependencies>
</dependencies>
<build>
<sourceDirectory>java</sourceDirectory>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>2.4</version>
<configuration>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
</plugins>
</build>
</project>
运行 DMEO 工程
上面的工程配置弄好之后,就可以使用cargo build
命令构建Rust提供的JNI动态库,mvn compile
命令来编译Java代码
Rust和Java代码都编译好之后,执行java -Djava.library.path=target/debug -classpath target/classes pers.metaworm.RustJNI
来运行
其中-Djava.library.path=target/debug
指定了我们JNI动态库所在的路径,-classpath target/classes
指定了Java代码的编译输出的类路径,pers.metaworm.RustJNI
是Java main方法所在的类
不出意外的话,运行之后会在控制台输出init函数里打印的"rust-java-demo inited"
Java调用Rust
接口声明
前面的Java_pers_metaworm_RustJNI_init
函数已经展示了如何给Java暴露一个native方法,即导出名称为Java_<类完整路径>_<方法名>
的函数,然后在Java对应的类里声明对应的native方法
拓展:除了通过导出函数给Java提供native方法,还可以通过 RegisterNatives 函数动态注册native方法,对应的jni封装的函数为JNIEnv::register_native_methods,一般动态注册会在JNI_Onload
这个导出函数里执行,jvm加载jni动态库时会执行这个函数(如果有的话)
当在Java里首次调用native方法时,JVM就会寻找对应名称的导出的或者动态注册的native函数,并将Java的native方法和Rust的函数关联起来;如果JVM没找到对应的native函数,则会报java.lang.UnsatisfiedLinkError
异常
为了演示,我们再添加一些代码来覆盖更多的交互场景
go
use jni::objects::*;
use jni::sys::{jint, jobject, jstring};
use jni::JNIEnv;
#[no_mangle]
pub unsafe extern "C" fn Java_pers_metaworm_RustJNI_addInt(
env: JNIEnv,
_class: JClass,
a: jint,
b: jint,
) -> jint {
a + b
}
#[no_mangle]
pub unsafe extern "C" fn Java_pers_metaworm_RustJNI_getThisField(
env: JNIEnv,
this: JObject,
name: JString,
sig: JString,
) -> jobject {
let result = env
.get_field(
this,
&env.get_string(name).unwrap().to_string_lossy(),
&env.get_string(sig).unwrap().to_string_lossy(),
)
.unwrap();
result.l().unwrap().into_inner()
}
RustJNI.java
go
package pers.metaworm;
public class RustJNI {
static {
System.loadLibrary("rust_java_demo");
}
public static void main(String[] args) {
init();
System.out.println("test addInt: " + (addInt(1, 2) == 3));
RustJNI jni = new RustJNI();
System.out.println("test getThisField: " + (jni.getThisField("stringField", "Ljava/lang/String;") == jni.stringField));
System.out.println("test success");
}
String stringField = "abc";
static native void init();
static native int addInt(int a, int b);
native Object getThisField(String name, String sig);
}
其中,addInt方法接收两个int参数,并返回相加的结果;getThisField是一个实例native方法,它获取this对象指定的字段并返回
参数传递
从上一节的例子里可以看到,jni函数的第一个参数总是JNIEnv
,很多交互操作都需要通过这个对象来进行; 第二个参数是类对象(静态native方法)或this对象(实例native方法); 从第三个参数开始,每一个参数对应Java的native方法所声明的参数
对于基础的参数类型,可以直接用use jni::sys::*
提供的j开头的系列类型来声明,类型对照表:
Java 类型 | Native 类型 | 类型描述 |
---|---|---|
boolean | jboolean | unsigned 8 bits |
byte | jbyte | signed 8 bits |
char | jchar | unsigned 16 bits |
short | jshort | signed 16 bits |
int | jint | signed 32 bits |
long | jlong | signed 64 bits |
float | jfloat | 32 bits |
double | jdouble | 64 bits |
void | void | not applicable |
对于引用类型(复合类型/对象类型),可以统一用jni::objects::JObject
声明;JObject是对jobject的rust封装,带有生命周期参数;对于String类型,也可以用 JString 来声明,JString是对JObject的一层简单封装
抛异常
前面的Java_pers_metaworm_RustJNI_getThisField
函数里,用了很多unwrap,这在生产环境中是非常危险的,万一传了一个不存在的字段名,就直接crash了;所以我们改进一下这个函数,让他支持抛异常,出错的时候能让Java捕获到
go
#[no_mangle]
pub unsafe extern "C" fn Java_pers_metaworm_RustJNI_getThisFieldSafely(
env: JNIEnv,
this: JObject,
name: JString,
sig: JString,
) -> jobject {
let result = (|| {
env.get_field(
this,
&env.get_string(name)?.to_string_lossy(),
&env.get_string(sig)?.to_string_lossy(),
)?
.l()
})();
match result {
Ok(res) => res.into_inner(),
Err(err) => {
env.exception_clear().expect("clear");
env.throw_new("Ljava/lang/Exception;", format!("{err:?}"))
.expect("throw");
std::ptr::null_mut()
}
}
}
Java层的测试代码为
go
try {
System.out.println("test getThisFieldSafely: " + (jni.getThisFieldSafely("stringField", "Ljava/lang/String;") == jni.stringField));
jni.getThisFieldSafely("fieldNotExists", "Ljava/lang/String;");
} catch (Exception e) {
System.out.println("test getThisFieldSafely: catched exception: " + e.toString());
}
通过env.throw_new("Ljava/lang/Exception;", format!("{err:?}"))
抛出了一个异常,从JNI函数返回后,Java就会捕获到这个异常; 代码里可以看到在抛异常之前,调用了env.exception_clear()
来清除异常,这是因为前面的get_field已经抛出一个异常了,当env里已经有一个异常的时候,后续再调用env的函数都会失败,这个异常也会继续传递到上层的Java调用者,所以其实这里没有这两句,直接返回null的话,Java也可以捕获到异常;但我们通过throw_new可以自定义异常类型及异常消息
这其实不是一个典型的场景,典型的场景应该是Rust里的某个调用返回了Error,然后通过抛异常的形式传递到Java层,比如除0错误
go
#[no_mangle]
pub unsafe extern "C" fn Java_pers_metaworm_RustJNI_divInt(
env: JNIEnv,
_class: JClass,
a: jint,
b: jint,
) -> jint {
if b == 0 {
env.throw_new("Ljava/lang/Exception;", "divide zero")
.expect("throw");
0
} else {
a / b
}
}
Rust调用Java
创建对象、调用方法、访问字段...
下面用一段代码展示如何在Rust中创建Java对象、调用方法、获取字段、处理异常等常见用法
go
#[allow(non_snake_case)]
fn call_java(env: &JNIEnv) {
match (|| {
let File = env.find_class("java/io/File")?;
// 获取静态字段
let separator = env.get_static_field(File, "separator", "Ljava/lang/String;")?;
let separator = env
.get_string(separator.l()?.into())?
.to_string_lossy()
.to_string();
println!("File.separator: {}", separator);
assert_eq!(separator, format!("{}", std::path::MAIN_SEPARATOR));
// env.get_static_field_unchecked(class, field, ty)
// 创建实例对象
let file = env.new_object(
"java/io/File",
"(Ljava/lang/String;)V",
&[JValue::Object(env.new_string("")?.into())],
)?;
// 调用实例方法
let abs = env.call_method(file, "getAbsolutePath", "()Ljava/lang/String;", &[])?;
let abs_path = env
.get_string(abs.l()?.into())?
.to_string_lossy()
.to_string();
println!("abs_path: {}", abs_path);
jni::errors::Result::Ok(())
})() {
Ok(_) => {}
// 捕获异常
Err(jni::errors::Error::JavaException) => {
let except = env.exception_occurred().expect("exception_occurred");
let err = env
.call_method(except, "toString", "()Ljava/lang/String;", &[])
.and_then(|e| Ok(env.get_string(e.l()?.into())?.to_string_lossy().to_string()))
.unwrap_or_default();
env.exception_clear().expect("clear exception");
println!("call java exception occurred: {err}");
}
Err(err) => {
println!("call java error: {err:?}");
}
}
}
#[no_mangle]
pub unsafe extern "C" fn Java_pers_metaworm_RustJNI_callJava(env: JNIEnv) {
println!("call java");
call_java(&env)
}
总结一下常用的函数,具体用法可以参考JNIEnv的文档
-
创建对象
new_object
-
创建字符串对象
new_string
-
调用方法
call_method
call_static_method
-
获取字段
get_field
get_static_field
-
修改字段
set_field
set_static_field
要注意的是调用方法、创建对象等需要传一个方法类型签名,这是因为Java支持方法重载,同一个类里一个名称的函数可能有多个,所以需要通过类型签名来区分,类型签名的规则可以参考官方文档
异常处理
call_java
函数展示了如何在Rust中处理Java的异常: 通过JNIEnv对象动态获取字段或者调用方法,都会返回一个jni::errors::Result
类型,对应的Error类型为jni::errors::Error
;如果Error是jni::errors::Error::JavaException
则表明在JVM执行过程中,某个地方抛出了异常,这种情况下就可以用exception_occurred
函数来获取异常对象进行处理,然后调用exception_clear
来清除异常,如果再返回到Java便可以继续执行
在非Java线程中调用Java
从Java中调用的Rust代码,本身就处于一个Java线程中,第一个参数为JNIEnv对象,Rust代码用这个对象和Java进行交互; 实际应用场景中,可能需要从一个非Java线程或者说我们自己的线程中去调用Java的方法,但我们的线程没有JNIEnv对象,这时就需要调用JavaVM::attach_current_thread
函数将当前线程附加到JVM上,来获得一个JNIEnv
go
#[no_mangle]
pub unsafe extern "C" fn Java_pers_metaworm_RustJNI_callJavaThread(env: JNIEnv) {
let vm = env.get_java_vm().expect("get jvm");
std::thread::spawn(move || {
println!("call java in another thread");
let env = vm.attach_current_thread().expect("attach");
call_java(&env);
});
}
attach_current_thread
函数返回一个AttachGuard
对象,可以解引用为JNIEnv,并且在作用域结束drop的时候自动调用detach_current_thread
函数;原始的AttachCurrentThread
JNI函数,如果当前线程已经attach了,则会抛异常,jni crate里的JavaVM::attach_current_thread
做了一层封装,如果当前已经attach了,则会返回之前attach的对象,保证不会重复attach
JavaVM对象通过JNIEnv::get_java_vm
函数获取,可以在初始化的时候将这个变量存起来,给后续的其他线程使用
局部引用、全局引用与对象缓存
关于局部引用与全局引用的官方文档
Rust提供的native函数,传过来的对象引用都是局部引用,局部引用只在本次调用JNI调用范围内有效,而且不能跨线程使用;如果跨线程,必须使用全局引用
可以通过JNIEnv::new_global_ref
来获取JClass、JObject的全局引用,这个函数返回一个GlobalRef对象,可以通过GlobalRef::as_object
转成JObject或者JClass等对象;GlobalRef对象drop的时候,会调用DeleteGlobalRef将JVM内部的引用删除
前面的代码,从Rust调用Java方法都是通过名称加方法签名调用的,这种方式,写起来很舒服,但运行效率肯定是非常低的,因为每次都要通过名称去查找对应的方法
其实JNI原始的C接口,是通过jobjectID、jclassID、jmethodID、jfieldID来和Java交互的,只不过是jni crate给封装了一层比较友好的接口
如果我们对性能要求比较高,则可以在初始化的时候获取一些JClass、JObject的全局引用,缓存起来,后面再转成JClass、JObject来使用,千万不要对jmethodID、jfieldID获取全局引用,因为这俩都是通过jclassID生成的,其声明周期和jclassID对应的对象相同,不是需要GC的对象,如果对jmethodID获取全局引用然后调用,会导致某些JVM Crash;对于jmethodID、jfieldID,则可以基于JClass、JObject的全局引用获取,后面直接使用即可
获取到这些全局的ID之后,就可以通过JNIEnv::call_method_unchecked
系列函数,来更高效地调用Java
我用Rust强大的宏,实现了这个过程,可以让我们直接在Rust中以声明的方式缓存的所需类及其方法ID
go
#[allow(non_snake_case)]
pub mod cache {
use anyhow::Context;
use jni::errors::Result as JniResult;
use jni::objects::*;
use jni::JNIEnv;
pub fn method_global_ref<'a>(
env: JNIEnv<'a>,
class: JClass,
name: &str,
sig: &str,
) -> JniResult<JMethodID<'a>> {
let method = env.get_method_id(class, name, sig)?.into_inner();
Ok(JMethodID::from(method.cast()))
}
pub fn static_method_global_ref<'a>(
env: JNIEnv<'a>,
class: JClass,
name: &str,
sig: &str,
) -> ::jni::errors::Result<JStaticMethodID<'a>> {
let method = env.get_static_method_id(class, name, sig)?.into_inner();
Ok(JStaticMethodID::from(method.cast()))
}
macro_rules! gen_global_ref {
(@method_type) => { JMethodID<'static> };
(@method_type static) => { JStaticMethodID<'static> };
(@method_ref) => { method_global_ref };
(@method_ref static) => { static_method_global_ref };
(
$(
#[name = $classname:literal]
class $name:ident {
$($method:ident : $($modify:ident)* $sig:literal,)*
}
)*
) => {
$(
#[allow(non_snake_case)]
pub struct $name {
pub class: JClass<'static>,
$(pub $method: gen_global_ref!(@method_type $($modify)*),)*
}
impl $name {
pub fn from_env(env: JNIEnv<'static>) -> anyhow::Result<Self> {
Self::from_class(env, env.find_class($classname)?)
}
pub fn from_class(env: JNIEnv<'static>, class: JClass) -> anyhow::Result<Self> {
let cls = env.new_global_ref(class)?;
let class = JClass::from(*cls.as_obj());
core::mem::forget(cls);
Ok(Self {
class,
$(
$method: gen_global_ref!(@method_ref $($modify)*)(
env, class, stringify!($method), $sig).context(stringify!($method)
)?,
)*
})
}
}
// TODO: impl Drop
)*
pub struct CachedClasses {
$(pub $name: $name,)*
}
impl CachedClasses {
pub fn from_env(env: JNIEnv<'static>) -> anyhow::Result<Self> {
Ok(Self {
$($name: $name::from_env(env).context(stringify!($name))?,)*
})
}
}
unsafe impl Sync for CachedClasses {}
unsafe impl Send for CachedClasses {}
}
}
gen_global_ref! {
#[name = "java/lang/Thread"]
class Thread {
currentThread: static "()Ljava/lang/Thread;",
getStackTrace: "()[Ljava/lang/StackTraceElement;",
}
#[name = "java/lang/StackTraceElement"]
class StackTraceElement {
getLineNumber: "()I",
toString: "()Ljava/lang/String;",
}
#[name = "java/io/File"]
class File {
getAbsolutePath: "()Ljava/lang/String;",
}
}
static mut CLASSES: Option<Box<CachedClasses>> = None;
pub unsafe fn init(env: JNIEnv<'static>) -> anyhow::Result<Option<Box<CachedClasses>>> {
Ok(CLASSES.replace(CachedClasses::from_env(env)?.into()))
}
pub fn get() -> &'static CachedClasses {
unsafe { CLASSES.as_ref().expect("Cached Java Classed not inited") }
}
}