JNI 简述
Java 原生接口(Java Native Interface),亦即 JNI,是 Java(包括其他
JVM 语言)与外部动态库交互的唯一手段。尽管如今有 JNA
之类,但其底层实质也使用 JNI,并通过 libffi
和其他原生库交互,本文就不再赘述了。
JVM 的主要优势是可移植性,JVM 语言编译的结果也是独立于平台的字节码,能够运行在任何有 JVM 实现的设备上。
然而在某些情况下,我们确实需要使用平台特定的原生代码,原因可能是:
- 要求严苛的性能
- 所需的库没有 JVM 实现,但又不想重写
- 安全考量,原生代码往往比字节码难逆向
自然的,JNI 也会带来许多问题:
- 可移植性降低,需要依赖原生库
- 开发成本变高,工作流需要改变,测试、编译需要同时覆盖多个平台。
- Jar 包体积变大,为了可移植性,可能会同时打包多个平台的原生库,而运行时只可能有一个平台。
- 性能可能不会改善太多,因为原生代码和 Java 代码之间的类型并不直接兼容,可能会导致额外的类型转换和内存分配开销。因此应该做充足的性能测试,以免做无用功。
总之,我建议在必要时才使用 JNI。
Kotlin 侧代码编写
Java 中使用 native 关键字标明 JNI 方法,而 Kotlin 使用
external 关键字,该关键字类似
abstract,不需要在声明处实现方法,而是让外部库完成:
package moe.sdl.r2k
object NativeLib {
init {
System.loadLibrary("simplejni")
}
external fun helloJni()
external fun helloJniString(string: String): String
external fun helloJniArray(arr: ByteArray): ByteArray
}你可以像上面那样用一个单例 object 用作 JNI
类。在这里,System.loadLibrary("simplejni")
方法的作用就是让 JVM
根据名称在可用路径中查找该动态库,路径可用命令行传参
-Djava.library.path=path/to/dylibDir
手动指定。simplelib 是欲加载动态库的名字,不包含
lib 前缀或 .so .dll
.dylib
等后缀,因为这些前后缀是平台特定的。如果你想要加载特定路径下的动态库,则使用
System.load("path/to/dylib")。这两个方法都会在找不到动态库时抛出
UnsatisfiedLinkError,自定义加载策略时,请注意
try-catch。
还可以像这样用普通类加伴生对象的方式加载:
package moe.sdl.r2k
class NativeLib {
external fun helloJni()
companion object {
init {
System.loadLibrary("simplejni")
}
}
}或者可以用顶层函数直接写,不过这样就不能自动加载了,需要调用方在首次调用前手动加载动态库:
@file:JvmName("NativeLib")
package moe.sdl.r2k
external fun helloJni()可以自定义加载逻辑,以下是根据运行时 JVM 属性和环境变量自定义加载动态库的案例:
init {
val loadFuncs = buildList {
add { System.loadLibrary("simplejni") }
arrayOf(
System.getProperty("simplejni.dylib.path"),
System.getenv("SIMPLEJNI_DYLIB_PATH"),
).forEach {
add { System.load(it) }
}
}
val exceptions = arrayListOf<Throwable>()
var success = false
for (load in loadFuncs) {
try {
load()
success = true
break
} catch (e: UnsatisfiedLinkError) {
exceptions.add(e)
}
}
if (!success) {
throw UnsatisfiedLinkError("Failed to load native library `simplejni`, errors:\n" +
exceptions.joinToString("\n") { it.stackTraceToString() })
}
}此外,我们常常需要将动态库打包至 Jar
中,以满足单文件运行需求。此时我们不能直接加载 Jar
包中的动态库,而是提取出来,放到临时文件夹中,再调用
deleteOnExit
方法,在结束时删除。这似乎是唯一可行的方式。
生成头文件
其实对于和 Rust 交互而言,有没有头文件都无伤大雅,只是生成后会更为方便。我们在这里只是要生成符合 JNI 命名规范的函数名:
- 前缀
Java_ - 完全限定类名
- 使用下划线
_分隔路径 - 转义后的方法名
- 对于重载方法,需要在两个下划线
__后,加上转义后的类型签名
也就是说下划线不能直接使用,已经被用作了分隔符,所以,我们有以下转义序列:
_0XXXX其中 XXXX 为 Unicode 字符_1:转义为__2:转义为签名中的;_3:转义为签名中的[
然而 Kotlin 暂时还没有自己的类似 javah 或
javac -h 的工具,所以需要通过转换 class 文件生成。
Gradle 任务如下:
tasks.create("generateJniHeaders") {
group = "build"
dependsOn(tasks.getByName("compileKotlin"))
kotlin.sourceSets.getByName("main").kotlin.srcDirs.filter {
it.exists()
}.forEach {
inputs.dir(it)
}
outputs.dir("src/main/generated/jni")
doLast {
val javaHome = org.gradle.internal.jvm.Jvm.current().javaHome
val javap = javaHome.resolve("bin").walk()
.firstOrNull { it.name.startsWith("javap") }
?.absolutePath ?: error("javap not found")
val javac = javaHome.resolve("bin").walk()
.firstOrNull { it.name.startsWith("javac") }
?.absolutePath ?: error("javac not found")
val buildDir = file("build/classes/kotlin/main")
val tmpDir = file("build/tmp/jvmJni")
tmpDir.mkdirs()
buildDir.walk()
.asSequence()
.filter { "META" !in it.absolutePath }
.filter { it.isFile }
.filter { it.extension == "class" }
.forEach { file ->
val output = ByteArrayOutputStream().use {
project.exec {
commandLine(javap, "-private", "-cp", buildDir.absolutePath, file.absolutePath)
standardOutput = it
}.assertNormalExitValue()
it.toString()
}
val (qualifiedName, methodInfo) = bodyExtractingRegex.find(output)?.destructured ?: return@forEach
val lastDot = qualifiedName.lastIndexOf('.')
val packageName = qualifiedName.substring(0, lastDot)
val className = qualifiedName.substring(lastDot + 1, qualifiedName.length)
val nativeMethods =
nativeMethodExtractingRegex.findAll(methodInfo).map { it.groups }
.flatMap { it.asSequence().mapNotNull { group -> group?.value } }.toList()
if (nativeMethods.isEmpty()) return@forEach
val generatedCode = buildString {
appendLine("package $packageName;")
appendLine("public class $className {")
nativeMethods.forEach { method ->
val newMethod = if (method.contains("()")) {
method
} else buildString {
append(method)
var count = 0
var i = 0
while (i < length) {
if (this[i] == ',' || this[i] == ')') {
count++
insert(i, " arg$count".also { i += it.length + 1 })
} else {
i++
}
}
}
appendLine(newMethod)
}
appendLine("}")
}
val javaFile = tmpDir
.resolve(packageName.replace(".", "/"))
.resolve("$className.java")
javaFile.parentFile.mkdirs()
if (javaFile.exists()) delete()
javaFile.createNewFile()
javaFile.writeText(generatedCode)
project.exec {
commandLine(javac, "-h", "src/main/generated/jni", javaFile.absolutePath)
}.assertNormalExitValue()
}
}
}如下 C 头文件(删除了前后的样板代码):
/*
* Class: moe_sdl_r2k_NativeLib
* Method: helloJni
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_moe_sdl_r2k_NativeLib_helloJni
(JNIEnv *, jobject);
/*
* Class: moe_sdl_r2k_NativeLib
* Method: helloJniString
* Signature: (Ljava/lang/String;)Ljava/lang/String;
*/
JNIEXPORT jstring JNICALL Java_moe_sdl_r2k_NativeLib_helloJniString
(JNIEnv *, jobject, jstring);
/*
* Class: moe_sdl_r2k_NativeLib
* Method: helloJniArray
* Signature: ([B)[B
*/
JNIEXPORT jbyteArray JNICALL Java_moe_sdl_r2k_NativeLib_helloJniArray
(JNIEnv *, jobject, jbyteArray);Rust 侧代码编写
本文使用 jni 库,在 Cargo.toml 中添加:
[lib]
crate-type = ["cdylib"]
[dependencies]
jni = "0.20"cdylib 即为 C ABI 的动态库,这对 JNI 而言是必须的。
首先看一个最简单无参方法:
use jni::{JNIEnv, objects::JClass};
#[no_mangle]
pub extern "system" fn Java_moe_sdl_r2k_NativeLib_helloJni(
_env: JNIEnv,
_class: JClass,
) {
println!("Hello from Rust!");
}需要标上 #[no_mangle] 以避免 Rust
编译器改变名称,pub extern "system"
表示这要给外部调用,函数名则使用头文件中生成的。此处 _env
_class
虽然没用,但是形参的组成部分,所以用下划线起头表示不会用到。
fun main() {
NativeLib.helloJni()
}
// Hello from Rust!如果涉及堆内存分配,则需要使用 env 了:
#[no_mangle]
pub extern "system" fn Java_moe_sdl_r2k_NativeLib_helloJniString(
env: JNIEnv,
_class: JClass,
string: JString,
) -> jstring {
let string: String = env.get_string(string).unwrap().into();
let output = env
.new_string(format!("Hello, {}!", string))
.unwrap();
output.into_raw()
}可以看到我们需要使用 get_string
转换字符串并在堆上重新分配。返回时我们也要重新分配两次,一次是
format!() 一次是 env.new_string()。
数组的情况类似:
#[no_mangle]
pub extern "system" fn Java_moe_sdl_r2k_NativeLib_helloJniArray(
env: JNIEnv,
_class: JClass,
array: jbyteArray,
) -> jbyteArray {
let mut array = env.convert_byte_array(array).unwrap();
array.push(array.last().unwrap().add(1));
let output = env.byte_array_from_slice(&array).unwrap();
output
}在 Kotlin 中调用:
fun main() {
NativeLib.helloJni()
println(NativeLib.helloJniString("JNI"))
println(NativeLib.helloJniArray(byteArrayOf(1, 2, 3, 4, 5)).joinToString())
}
// Hello from Rust!
// Hello, JNI!
// 1, 2, 3, 4, 5, 6