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 .loadLibrary("simplejni")
System}
fun helloJni()
external 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 {
fun helloJni()
external
companion object {
{
init .loadLibrary("simplejni")
System}
}
}
或者可以用顶层函数直接写,不过这样就不能自动加载了,需要调用方在首次调用前手动加载动态库:
@file:JvmName("NativeLib")
package moe.sdl.r2k
fun helloJni() external
可以自定义加载逻辑,以下是根据运行时 JVM 属性和环境变量自定义加载动态库的案例:
{
init val loadFuncs = buildList {
{ System.loadLibrary("simplejni") }
add (
arrayOf.getProperty("simplejni.dylib.path"),
System.getenv("SIMPLEJNI_DYLIB_PATH"),
System).forEach {
{ System.load(it) }
add }
}
val exceptions = arrayListOf<Throwable>()
var success = false
for (load in loadFuncs) {
try {
()
load= true
success break
} catch (e: UnsatisfiedLinkError) {
.add(e)
exceptions}
}
if (!success) {
throw UnsatisfiedLinkError("Failed to load native library `simplejni`, errors:\n" +
.joinToString("\n") { it.stackTraceToString() })
exceptions}
}
此外,我们常常需要将动态库打包至 Jar
中,以满足单文件运行需求。此时我们不能直接加载 Jar
包中的动态库,而是提取出来,放到临时文件夹中,再调用
deleteOnExit
方法,在结束时删除。这似乎是唯一可行的方式。
生成头文件
其实对于和 Rust 交互而言,有没有头文件都无伤大雅,只是生成后会更为方便。我们在这里只是要生成符合 JNI 命名规范的函数名:
- 前缀
Java_
- 完全限定类名
- 使用下划线
_
分隔路径 - 转义后的方法名
- 对于重载方法,需要在两个下划线
__
后,加上转义后的类型签名
也就是说下划线不能直接使用,已经被用作了分隔符,所以,我们有以下转义序列:
_0XXXX
其中 XXXX 为 Unicode 字符_1
:转义为_
_2
:转义为签名中的;
_3
:转义为签名中的[
然而 Kotlin 暂时还没有自己的类似 javah
或
javac -h
的工具,所以需要通过转换 class 文件生成。
Gradle 任务如下:
.create("generateJniHeaders") {
tasks= "build"
group (tasks.getByName("compileKotlin"))
dependsOn
.sourceSets.getByName("main").kotlin.srcDirs.filter {
kotlin.exists()
it}.forEach {
.dir(it)
inputs}
.dir("src/main/generated/jni")
outputs
{
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")
.mkdirs()
tmpDir
.walk()
buildDir.asSequence()
.filter { "META" !in it.absolutePath }
.filter { it.isFile }
.filter { it.extension == "class" }
.forEach { file ->
val output = ByteArrayOutputStream().use {
.exec {
project(javap, "-private", "-cp", buildDir.absolutePath, file.absolutePath)
commandLine= it
standardOutput }.assertNormalExitValue()
.toString()
it}
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 =
.findAll(methodInfo).map { it.groups }
nativeMethodExtractingRegex.flatMap { it.asSequence().mapNotNull { group -> group?.value } }.toList()
if (nativeMethods.isEmpty()) return@forEach
val generatedCode = buildString {
("package $packageName;")
appendLine("public class $className {")
appendLine.forEach { method ->
nativeMethodsval newMethod = if (method.contains("()")) {
method} else buildString {
(method)
appendvar count = 0
var i = 0
while (i < length) {
if (this[i] == ',' || this[i] == ')') {
++
count(i, " arg$count".also { i += it.length + 1 })
insert} else {
++
i}
}
}
(newMethod)
appendLine}
("}")
appendLine}
val javaFile = tmpDir
.resolve(packageName.replace(".", "/"))
.resolve("$className.java")
.parentFile.mkdirs()
javaFileif (javaFile.exists()) delete()
.createNewFile()
javaFile.writeText(generatedCode)
javaFile.exec {
project(javac, "-h", "src/main/generated/jni", javaFile.absolutePath)
commandLine}.assertNormalExitValue()
}
}
}
如下 C 头文件(删除了前后的样板代码):
/*
* Class: moe_sdl_r2k_NativeLib
* Method: helloJni
* Signature: ()V
*/
void JNICALL Java_moe_sdl_r2k_NativeLib_helloJni
JNIEXPORT (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(
: JNIEnv,
_env: JClass,
_class{
) println!("Hello from Rust!");
}
需要标上 #[no_mangle]
以避免 Rust
编译器改变名称,pub extern "system"
表示这要给外部调用,函数名则使用头文件中生成的。此处 _env
_class
虽然没用,但是形参的组成部分,所以用下划线起头表示不会用到。
fun main() {
.helloJni()
NativeLib}
// Hello from Rust!
如果涉及堆内存分配,则需要使用 env
了:
#[no_mangle]
pub extern "system" fn Java_moe_sdl_r2k_NativeLib_helloJniString(
: JNIEnv,
env: JClass,
_class: JString,
string-> jstring {
) let string: String = env.get_string(string).unwrap().into();
let output = env
.new_string(format!("Hello, {}!", string))
.unwrap();
.into_raw()
output}
可以看到我们需要使用 get_string
转换字符串并在堆上重新分配。返回时我们也要重新分配两次,一次是
format!()
一次是 env.new_string()
。
数组的情况类似:
#[no_mangle]
pub extern "system" fn Java_moe_sdl_r2k_NativeLib_helloJniArray(
: JNIEnv,
env: JClass,
_class: jbyteArray,
array-> jbyteArray {
) let mut array = env.convert_byte_array(array).unwrap();
.push(array.last().unwrap().add(1));
arraylet output = env.byte_array_from_slice(&array).unwrap();
output}
在 Kotlin 中调用:
fun main() {
.helloJni()
NativeLib(NativeLib.helloJniString("JNI"))
println(NativeLib.helloJniArray(byteArrayOf(1, 2, 3, 4, 5)).joinToString())
println}
// Hello from Rust!
// Hello, JNI!
// 1, 2, 3, 4, 5, 6