JNI教程

17 min read,created at 2024-06-17
javajnic/c++rust

JNI是什么

JNIJava Native Interface的简写,是在java中调用本地方法的一种实现方式。

例如我们常用到的Arrays.copyOf方法,查看他的源码,会发现他使用System.arraycopy方法,后者的定义如下:

System.java
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文件如下

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中的void
  • JNICALL也是一个宏定义,用于指定函数调用约定,可以不管他
  • 函数名Java_<全限定类名>_<方法名>,因为我们的方法名中使用了_,而_有特殊含义,所以转义成了_1
  • 入参有两个,而我们定义的函数是没有入参的。这两个分别是JNIEnv *当前的jni环境上下文;jclass对应java中的Class,因为方法是静态的,是属于类的,这个jclass会传入Main.class这个类。如果是普通的函数方法,则第二个参数是jobject类型,对应java中的this这个对象。
Main.h
/* 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函数如下。

Main.java
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了,因为这是普通方法,他是属于对象的,而不是类的。
Main.h
JNIEXPORT jint JNICALL Java_Main_add(JNIEnv *, jobject, jint, jint);

1.3 实现方法

头文件都生成好了,接下来我们直接实现这些方法即可,当然了第二步中生成头文件,只是为了辅助我们写c的代码,如果对上面jni这一套java-c类型映射和方法名格式定义很熟悉的话,跳过第二步,直接写c代码也是可以的,这里不做赘述了。

写一个Main.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

通过工具可以查看动态链接库中声明的符号变量:

img

1.4 引入并启动

在java中引入得到的动态链接库文件。

Main.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);方法,如下注意一定是绝对路径,不能用相对路径。

Main.java
- 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函数如下

Main.java
public native String hi(String name);
Main.c
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。

RustHttpServer.java
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生成头文件,当然这一步并不是必需的,可以看一下函数的名称。

RustHttpServer.h
/* 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
Cargo.toml
[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]确保生成的符号名与函数名直接对应,从而在外部语言中可以正确识别和调用。
lib.rs
#[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修饰过。

image

此时回去修改java文件中的动态链接库路径。

RustHttpServer.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的类型声明,不需要他把我们自己要写的函数也给声明了

img

lib.rs
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目录下获取到。