JNI是什么
JNI
是Java Native Interface
的简写,是在java中调用本地方法的一种实现方式。
例如我们常用到的Arrays.copyOf
方法,查看他的源码,会发现他使用System.arraycopy
方法,后者的定义如下:
public static native void arraycopy(Object src, int srcPos,
Object dest, int destPos,
int length);
在System.java
下其实有很多这样的方法,他没有方法体的具体实现,在方法签名中还有native
关键字,这些方法就是调用了JNI
,这个方法是如何实现的,调用的过程中又是怎么找到对应的native
函数的,看完这篇文章,我想你就会有答案。
1 动手写个native方法
1.1 定义native函数
我们创建一个简单的Main.java
文件如下
public class Main {
public static native void native_hello();
public static void main(String[] args) {
native_hello();
}
}
运行该文件会发现javac
编译是可以通过的,但是运行时报错,报错内容本质是说找不到定义native_hello
这个方法的动态链接文件。
1.2 生成头文件
运行如下指令,得到Main.h
头文件。
$ javac -h . Main.java
简单看下这个头文件的定义,发现核心内容就是定义了一个方法JNIEXPORT void JNICALL Java_Main_native_1hello(JNIEnv *, jclass)
,这个方法形式上比普通的C语言方法稍微复杂一点:
JNIEXPORT
是一个宏定义用来输入JNI格式,可以不管他void
是返回值类型,c中的void也对应java中的voidJNICALL
也是一个宏定义,用于指定函数调用约定,可以不管他- 函数名
Java_<全限定类名>_<方法名>
,因为我们的方法名中使用了_
,而_
有特殊含义,所以转义成了_1
- 入参有两个,而我们定义的函数是没有入参的。这两个分别是
JNIEnv *
当前的jni
环境上下文;jclass
对应java中的Class
,因为方法是静态的,是属于类的,这个jclass
会传入Main.class
这个类。如果是普通的函数方法,则第二个参数是jobject
类型,对应java中的this
这个对象。
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class Main */
#ifndef _Included_Main
#define _Included_Main
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: Main
* Method: native_hello
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_Main_native_1hello
(JNIEnv *, jclass);
#ifdef __cplusplus
}
#endif
#endif
为了了解更多的出入参形式,我们可以在Main.java
中再定义一个add
函数如下。
public class Main {
public static native void native_hello();
+ public native int add(int a, int b);
public static void main(String[] args) {
native_hello();
}
}
再次运行javac -h . Main.java
,此时生成的Main.h
文件多出了add
函数的定义如下,对比刚才的native_hello
不难发现:
- java中
int
,对应的c代码类型是jint
(类比jlong
jclass
jobject
等) - 第二个入参是
jobject
类型,而不是jclass
了,因为这是普通方法,他是属于对象的,而不是类的。
JNIEXPORT jint JNICALL Java_Main_add(JNIEnv *, jobject, jint, jint);
1.3 实现方法
头文件都生成好了,接下来我们直接实现这些方法即可,当然了第二步中生成头文件,只是为了辅助我们写c的代码,如果对上面jni
这一套java-c
类型映射和方法名格式定义很熟悉的话,跳过第二步,直接写c
代码也是可以的,这里不做赘述了。
写一个Main.c
#include <jni.h>
#include <stdio.h>
#include "Main.h"
JNIEXPORT void JNICALL Java_Main_native_1hello
(JNIEnv * env, jclass cls) {
printf("Hello JNI!\n");
}
JNIEXPORT jint JNICALL Java_Main_add
(JNIEnv * env, jobject instance, jint a, jint b) {
return a + b;
}
然后就可以编译代码,编译成动态链接库。当然编译之前会发现代码有红色报错,主要原因是jni.h
文件没有正确导入,我们在编译的时候指定该头文件位置即可。
# linux下
$ gcc -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/linux" -shared -o libMyJNI.so Main.c
# macos下
$ gcc -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/darwin" -shared -o libMyJNI.dylib Main.c
# windows下
$ gcc -I"%JAVA_HOME%\include" -I"%JAVA_HOME%\include\win32" -shared -o libMyJNI.dll Main.c
通过工具可以查看动态链接库中声明的符号变量:
1.4 引入并启动
在java中引入得到的动态链接库文件。
public class Main {
static {
System.loadLibrary("libMyJNI");
}
public static native void native_hello();
public native int add(int a, int b);
public static void main(String[] args) {
native_hello();
System.out.println(new Main().add(1, 1));
}
}
此时运行成功:
这里-Djava.library.path=.
是指定了从当前文件夹加载动态链接库,System.loadLibrary("libMyJNI")
则是加载名为libMyJNI
的库,在windows下自动拼.dll
后缀,Linux自动拼.so
后缀,去寻找文件。
如果不想修改启动参数,即不希望添加-Djava.library.path=.
这部分,使用文件绝对路径,可以使用System.load(path);
方法,如下注意一定是绝对路径,不能用相对路径。
- System.loadLibrary("libMyJNI");
+ System.load("C:/notebook/24.06/jni/libMyJNI.dll");
2 如何转换类型
上面的jint
在头文件中能找到声明,它对应的就是long
类型
jint => long;
jlong => long long;
jbyte => signed char;
jboolean => unsigned char;
jchar => unsigned short;
jshort => short;
jfloat => float;
jdouble => double;
jstring => void*;
jobject => void*;
2.1 如何传递字符串
除了数字之外,字符串也是常见的传递数据的类型,但是c和java的字符串编码是不同的,所以不能直接拿来用
// jstring类型的变量javaString,需要用GetStringUTFChars来转换为C中的string
const char *nativeString = (*env)->GetStringUTFChars(env, javaString, 0);
// 释放 C 字符串
(*env)->ReleaseStringUTFChars(env, javaString, nativeString);
// c的字符串需要用NewStringUTF转换为jstring
char *greeting = "Hello from JNI!";
(*env)->NewStringUTF(env, greeting);
例如写一个hi函数如下
public native String hi(String name);
JNIEXPORT jstring JNICALL Java_Main_hi
(JNIEnv * env, jobject instance, jstring javaString) {
char* hello = "Hello,";
// 转成c char*
const char* name = (*env)->GetStringUTFChars(env, javaString, 0);
// 字符串拼接
size_t len = strlen(hello) + strlen(name) + 1;
char* result = (char*)malloc(len);
strcpy(result, hello);
strcat(result, name);
// 释放这部分内存,因为已经copy到result中了,这部分没用了
(*env)->ReleaseStringUTFChars(env, javaString, name);
// 转回jstring
return (*env)->NewStringUTF(env, result);
}
运行java代码
System.out.println(new Main().hi("frank"));
// 打印 Hello,frank
3 项目中如何整合
3.1 如何兼容多个操作系统
大多数时候项目启动的参数中,不会特意添加-Djava.library.path
,所以直接指定文件的绝对路径是一种更常见的手段,但是不同的操作系统的动态链接库是无法通用的,我们需要在不同系统上编译,或者使用交叉编译得到多个系统的动态链接库。
例如针对服务端常见的场景,我们至少需要window_amd64.dll
linux_amd64.so
还有mac_aarch64.dylib
,为了部分Intel Mac
老用户,还需要mac_amd64.dylib
这样四组动态链接库。
然后加载的时候,就不能直接写死文件名加载了,需要动态判断操作系统和cpu架构来加载:
static {
String lib = null;
String os = System.getProperty("os.name").toLowerCase();
String arch = System.getProperty("os.arch").toLowerCase();
if (os.contains("win") &&
(arch.equals("x86_64") || arch.equals("amd64"))) {
lib = "xxx_amd64.dll"; // win_x86
} else if (os.contains("linux") &&
(arch.equals("x86_64") || arch.equals("amd64"))) {
lib = "xxx_amd64.so"; // linux_x86
} else if (os.contains("mac")) {
if (arch.equals("x86_64") || arch.equals("amd64")) {
lib = "w_amd64.dylib"; // mac_x86
} else if (arch.equals("aarch64")) {
lib = "w_aarch64.dylib"; // mac_arm (m1 m2 m3)
}
}
if (lib == null){
System.err.println("os " + os +" not support");
throw new RuntimeException("os " + os +" not support");
}
System.load("/绝对/路径/目录/" + lib);
}
3.2 如何封入jar包
我们的项目通常最后以jar包的形式来运行,此时如果单独再创建一个文件夹,来防止上面不同系统的动态链接库文件,使用起来就比较麻烦,一种更好的实现,是直接把所有的动态链接库文件都封入jar包,由程序自己在jar包内,动态找到对应的动态链接库并加载,用户完全无感知。
实现的方式就是,利用getResourceAsStream
读取classpath
下的任意文件,将这个文件复制到一个临时目录下。然后用System.load
加载这个临时文件,加载完成,这个文件就可以删除了,因为已经在内存中了。
代码可以直接用我之前项目中使用的NativeUtils,例如maven
项目中,将a.so
放置到resources
目录下即可,使用loadLibraryFromJar("/a.so")
,在打包为jar后,也可以加载classpath:/a.so
文件,因为resources
目录就是classpath
的一员。
4 rust
4.1 用jni启动一个旁路服务
上面其实看到,只要遵循C语言的规范,将函数名符号export
出来即可,因而本质上,所有的native
语言例如rust
golang
等,通过一些配置都可以编译成这样的动态链接库。这里以rust为例。
我们直接借助rust
丰富的库,写一个简单的http server。
public class RustHttpServer {
static {
System.load("这里等编译完动态链接库再回来写");
}
public native void startServer(int port);
public static void main(String[] args) {
RustHttpServer server = new RustHttpServer();
System.out.println("start http server at localhost:3030");
server.startServer(3030);
}
}
然后javac -h . RustHttpServer.java
生成头文件,当然这一步并不是必需的,可以看一下函数的名称。
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class RustHttpServer */
#ifndef _Included_RustHttpServer
#define _Included_RustHttpServer
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: RustHttpServer
* Method: startServer
* Signature: (I)V
*/
JNIEXPORT void JNICALL Java_RustHttpServer_startServer
(JNIEnv *, jobject, jint);
#ifdef __cplusplus
}
#endif
#endif
接下来创建一个rust lib项目,引入warp
这个http库,还有必要的tokio
依赖,以及jni
的类型声明的库jni
(这一步也非必需,因为只是一些类型声明,后面演示不依赖jni
库的用法)
$ cargo new rust-jni --lib
[package]
name = "rust-jni"
version = "0.1.0"
edition = "2021"
[dependencies]
jni = "0.21.1"
tokio = { version = "1", features = ["full"] }
warp = "0.3"
[lib]
crate-type = ["cdylib"]
编辑src/lib.rs
,如下这段代码中:
extern "C"
: 这个关键字告诉编译器使用C语言的调用约定。这对于跨语言调用非常重要,因为不同语言的编译器可能会使用不同的调用约定,而“C”调用约定是一种通用的标准,适用于大多数外部语言接口,包括Java的JNI,当然windows
下推荐使用extern "system"
,其他OS中两者则等价。#[no_mangle]
:这个属性告诉Rust编译器不要对函数名进行“mangling”(名称修饰)。Rust编译器会对函数名进行修饰以支持函数重载等高级特性,但这会导致生成的符号名在其他语言中不可识别。#[no_mangle]
确保生成的符号名与函数名直接对应,从而在外部语言中可以正确识别和调用。
#[macro_use]
extern crate jni;
use jni::JNIEnv;
use jni::objects::{JClass};
use jni::sys::jint;
use warp::Filter;
use std::net::SocketAddr;
use tokio::runtime::Runtime;
#[no_mangle]
pub extern "C" fn Java_RustHttpServer_startServer(env: JNIEnv, _class: JClass, port: jint) {
let runtime = Runtime::new().unwrap();
runtime.block_on(async move {
let hello = warp::path::end()
.map(|| "Hello, World!");
let addr = SocketAddr::from(([127, 0, 0, 1], port as u16));
warp::serve(hello)
.run(addr)
.await;
});
}
接下来编译即可,如下指令后,在target/release
下得到rust_jni.dll
动态链接库文件,当然这是windows
系统的,如果是其他操作系统后缀可能是so/dylib
等。
$ cargo b -r
通过DLL export viewer
小工具,查看这个文件的暴露的函数确实是Java_RustHttpServer_startServer
没有被rust
修饰过。
此时回去修改java
文件中的动态链接库路径。
public class RustHttpServer {
static {
System.load("C:\\Users\\sunwu\\Desktop\\base\\gateway\\n" + //
"otebook\\24.06\\rust-jni\\target\\release\\rust_jni.dll");
}
public native void startServer(int port);
public static void main(String[] args) {
RustHttpServer server = new RustHttpServer();
System.out.println("start http server at localhost:3030");
server.startServer(3030);
}
}
然后就可以运行了,通过curl
验证http服务已经启动。
$ javac RustHttpServer.java
$ java RustHttpserver
start http server at localhost:3000
$ curl http://localhost:3030
Hello, World!
4.2 使用bindgen
上面rust项目中引入了jni
这个crate,这个库里面提供了JNIEnv
JClass
还有jint
等类型的声明,比较方便,这里展示更原生的实现方式,比如我们只有h
头文件,没有专门的jni
库的时候,如何实现函数并打包动态链接库。
$ cargo install bindgen-cli
# 注意这里是win32系统的写法,如果是linux/mac请自行替换%JAVA_HOME%为$JAVA_HOME,win32替换为对应的os目录
$ bindgen RustHttpServer.h -o src/bindings.rs -- "-I%JAVA_HOME%/include" "-I%JAVA_HOME%/include/win32"
此时生成了一个5k多行的binding.rs
文件,到这个文件中搜索我们的函数名,给他删掉即可,因为我们只需要jni的类型声明,不需要他把我们自己要写的函数也给声明了
use warp::Filter;
use std::net::SocketAddr;
use tokio::runtime::Runtime;
// 引入生成的绑定文件
include!("bindings.rs");
#[no_mangle]
pub extern "C" fn Java_RustHttpServer_startServer(env: JNIEnv, _class: jclass, port: jint) {
let runtime = Runtime::new().unwrap();
runtime.block_on(async move {
let hello = warp::path::end()
.map(|| "Hello, World!");
let addr = SocketAddr::from(([127, 0, 0, 1], port as u16));
warp::serve(hello)
.run(addr)
.await;
});
}
同样通过cargo b -r
得到了动态链接库文件,与之前的效果完全一样。
通过bindgen
生成的头文件的类型,使用起来和c语言一样,c中有什么方法,这里都有,只是写法按照rust改一下即可。
上述代码均可在sunwu51/notebook/24.06下的
jni / rust-jni
目录下获取到。