Java 17 更新(10):访问外部函数的新 API,JNI 要凉了?
JNI 不安全还繁琐,所以 Java 搞了一套新的 API,结果把这事儿搞得更复杂了。。。
- Java 17 更新(0):前言
- Java 17 更新(1):更快的 LTS 节奏
- Java 17 更新(2):没什么存在感的 strictfp 这回算是回光返照了
- Java 17 更新(3):随机数生成器来了一波稳稳的增强
- Java 17 更新(4):这波更新,居然利好 mac 用户
- Java 17 更新(5):历史包袱有点儿大,JDK 也在删代码啦
- Java 17 更新(6):制裁!我自己私有的 API 你们怎么随便一个人都想用?
- Java 17 更新(7):模式匹配要支持 switch 啦
- Java 17 更新(8):密封类终于转正
- Java 17 更新(9):Unsafe 不 safe,我们来一套 safe 的 API 访问堆外内存
- Java 17 更新(10):访问外部函数的新 API,JNI 要凉了?
- Java 17 更新(11):支持矢量运算,利好科学计算?
- Java 17 更新(12):支持上下文的序列化过滤器,又一次给序列化打补丁
我们书接上回,接着聊 JEP 412: Foreign Function & Memory API (Incubator) 当中访问外部函数的内容。
调用自定义 C 函数
新 API 加载 Native 库的行为没有发生变化,还是使用 System::loadLibrary 和 System::load 来实现。
相比之前,JNI 需要提前通过声明 native 方法来实现与外部函数的绑定,新 API 则提供了直接在 Java 层通过函数符号来定位外部函数的能力:
1 | System.loadLibrary("libsimple"); |
对应的 C 函数如下:
1 | int GetCLangVersion() { |
通过以上手段,我们直接获得了外部函数的地址,接下来我们就可以使用它们来完成调用:
1 | MethodHandle getClangVersionHandle = CLinker.getInstance().downcallHandle( |
运行程序的时候需要把编译好的 Native 库放到 java.library.path 指定的路径下,例如我把编译好的 libsimple.dll 放到了 lib/bin 目录下,所以:
1 | -Djava.library.path=./lib/bin |
运行结果:
1 | 201112 |
可以看出来,我的 C 编译器觉得自己的版本是 C11。
调用系统 C 函数
如果是加载 C 标准库当中的函数,则应使用 CLinker::systemLookup,例如:
1 | MemoryAddress strlen = CLinker.systemLookup().lookup("strlen").get(); |
程序输出:
1 | 13 |
结构体入参
对于比较复杂的场景,例如传入结构体:
1 | typedef struct Person { |
这种情况我们首先需要在 Java 当中构造一个 Person 实例,然后把它的地址传给 DumpPerson,这个过程比较复杂,我们分步骤来介绍:
1 | MemoryLayout personLayout = MemoryLayout.structLayout( |
首先我们定义好内存布局,每一个成员我们可以指定一个名字,这样在后面方便定位。注意,由于 Person 的 name 只占 10 个字节(我说我是故意的你信吗),因此这里还有内存对齐问题,根据实际情况设置对应大小的 paddingLayout。
接下来我们用这个布局来开辟堆外内存:
1 | MemorySegment person = MemorySegment.allocateNative(personLayout, newImplicitScope()); |
下面就要初始化这个 Person 了:
1 | VarHandle idHandle = personLayout.varHandle(long.class, MemoryLayout.PathElement.groupElement("id")); |
使用 id 和 name 分别定位到对应的字段,并初始化它们,这两个都比较简单。
接下来我们看下如何初始化一个 char[]。
方法1,逐个写入:
1 | VarHandle nameHandle = personLayout.varHandle( |
注意我们获取 nameHandle 的方式,要先定位到 name 对应的布局,它实际上是个 sequenceLayout,所以要紧接着用 sequenceElement 来定位它。如果还有更深层次的嵌套,可以在 varHandle(…) 方法当中添加更多的参数来逐级定位。
1 | byte[] bytes = "bennyhuo".getBytes(); |
然后就是循环赋值,一个字符一个字符写入,比较直接。不过,有个细节要注意,Java 的 char 是两个字节,C 的 char 是一个字节,因此这里要用 Java 的 byte 来写入。
方法2,直接复制 C 字符串:
1 | person.asSlice(personLayout.byteOffset(MemoryLayout.PathElement.groupElement("name"))) |
asSlice 可以通过内存偏移得到 name 这个字段的地址对应的 MemorySegment 对象,然后通过它的 copyFrom 把字符串直接全部复制过来。
两种方法各有优缺点。
接下来就是函数调用了,与前面几个例子基本一致:
1 | MemoryAddress dumpPerson = loaderLookup.lookup("DumpPerson").get(); |
结果:
1 | Person%24(id=1000000, name=bennyhuo, age=30) |
我们把内存的每一个字节都打印出来,在 Java 层也可以打印这个值,这样方便我们调试:
1 | for (byte b : person.toByteArray()) { |
以上是单纯的 Java 调用 C 函数的情形。
函数指针入参
很多时候我们需要在 C 代码当中调用 Java 方法,JNI 的做法就是反射,但这样会有些安全问题。 新 API 也提供了类似的手段,允许我们把 Java 方法像函数指针那样传给 C 函数,让 C 函数去调用。
下面我们给出一个非常简单的例子,大家重点关注如何传递 Java 方法给 C 函数。
我们首先给出 C 函数的定义,它的功能实际上就是遍历一个数组,调用传入的函数 on_each。
1 | typedef void (*OnEach)(int element); |
Java 层想要调用 ForEach 这个函数,最关键的地方就是构造 on_each 这个函数指针。接下来我们给出它的 Java 层的定义:
1 | public static void onEach(int element) { |
然后把 onEach 转成函数指针,我们只需要通过 MethodHandles 来定位这个方法,得到一个 MethodHandle 实例:
1 | MethodHandle onEachHandle = MethodHandles.lookup().findStatic( |
接着获取这个函数的地址:
1 | MemoryAddress onEachHandleAddress = CLinker.getInstance().upcallStub( |
再调用 CLinker 的 upcallStub 来得到它的地址。
1 | int[] originalArray = new int[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; |
剩下的就是构造一个 int 数组,然后再调用 ForEach 这个 C 函数,这与前面调用其他 C 函数的方式是一致的。
运行结果显而易见:
1 | onEach: 1 |
小结
这篇文章我们介绍了一下 Java 新提供的这套访问外部函数的 API,相比之下它确实比过去有了更丰富的能力,不过用起来也并不轻松。将来即便正式发布,我个人觉得也需要一些工具来处理这些模板代码的生成(例如基于注解处理器的代码生成框架),以降低使用复杂度。
就目前的情况来讲,其实我更愿意用 JNI,不安全怎么了,小心点儿不就行了嘛。算了,写什么垃圾 Java,直接写 C++ 不香吗?
关于作者
霍丙乾 bennyhuo,Google 开发者专家(Kotlin 方向);《深入理解 Kotlin 协程》 作者(机械工业出版社,2020.6);《深入实践 Kotlin 元编程》 作者(机械工业出版社,2023.8);移动客户端工程师,先后就职于腾讯地图、猿辅导、腾讯视频。
- GitHub:https://github.com/bennyhuo
- 博客:https://www.bennyhuo.com
- bilibili:霍丙乾 bennyhuo
- 微信公众号:霍丙乾 bennyhuo