⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 denismakogon.github.io/openjdk/panama/2022/05/31/introduction-to-project-panama-part-1.html 「Denys Makogon」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注**微信公众号:【芋道源码】**有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言将得到认真回复。甚至不知道如何读源码也可以请教噢
  4. 新的源码解析文章实时收到通知。每周更新一篇左右
  5. 认真的源码交流微信群。

随着 JDK 19 在未来几周*内发布,是时候讨论巴拿马(Panama)项目了,更具体地说,是新的外部函数和内存 API,它简化了 Java 和本机代码之间的互操作性。

编注:2022年9月20日 JDK 19 已正式发布。

本文使用一个简单的基于 Java 的“Hello World”应用程序调用一些 C 本机代码来介绍外部函数和内存 API。

准备

要使用 Foreign Function & Memory API 和示例代码,请先下载 JDK 19(build 24 或更高版本)。

项目概述

巴拿马项目旨在为 JVM 和用其他语言(如 C/C++)编写的本机代码之间搭建桥梁。包含以下 3 个部分:

  • 外部函数和内存 API:JEP 424
  • Jextract 工具
  • Vector API:JEP 338

外部函数和内存 API 提供一些重要的抽象:

  • 内存段及其地址:一组 API 类,用于处理本机内存和指向它的指针;
  • 内存布局和描述符:用于模拟外部类型(结构、原语)和函数描述符的 API;
  • 内存会话:管理一个或多个内存资源生命周期的抽象;
  • 链接器和符号查找:一组用于执行向下和向上调用的 API 类;
  • 段分配器:一种用于在内存会话中分配内存段的 API。

Hello World 程序

对巴拿马了解得越深,就越会发现拥有一个好的介绍是至关重要的,这样就不会错过重要的概念、技术和方法。

本文将介绍链接器(Linker),并简要介绍 SymbolLookup 方法和本机内存管理 ( MemorySession )。上面描述的这三个主要组件是构建块,用于更深入地开发由 Java 和本机代码组成的程序。

链接器

从技术角度来看,链接器是两个二进制接口之间的桥梁:JVM 和 C/C++ 本机代码,也称为 C ABI。

JDK 19 为所有流行的平台提供了一组 C ABI 实现:

public static Linker getSystemLinker() {
return switch (CABI.current()) {
case Win64 - > Windowsx64Linker.getInstance();
case SysV - > SysVx64Linker.getInstance();
case LinuxAArch64 - > LinuxAArch64Linker.getInstance();
case MacOsAArch64 - > MacOsAArch64Linker.getInstance();
};
}

在 JDK 术语中,链接器是特定于平台的 C ABI 实现的一个实例。链接器提供一组方法来执行向下调用和向上调用,其中:

  • downcall 是从高级子系统发起的事件。在我们的例子中是 JVM 到较低级别的子系统,如操作系统内核或者一些 Java 代码调用一些本机代码。稍后将通过外部函数和内存 API 说明这一点。
  • upcall 例如一些本机代码调用一些 Java 代码。

虽然链接器就像电话一样,想打电话给谁,只需拨入正确的电话号码即可。符号查找方法就像通讯录,只需提供要打电话的人正确的信息即可。

要执行向下调用,需要提供调用的(本机)函数的描述符、通过符号查找分配的本机地址,以及用于创建调用本机函数的方法句柄对应的链接器。

从 Java 实现经典的 C 风格的 Hello World:

int printf(const char * __restrict, ...)

Java 中的 C 语言风格的“Hello World”

要编写使用本机 printf 函数的基于 Java 的“Hello World”应用程序,我们需要:

1. 找到 native 函数的地址

首先,我们需要搜索 printf 函数的本机内存地址:

Linker linker = Linker.nativeLinker();
SymbolLookup linkerLookup = linker.defaultLookup();
SymbolLookup systemLookup = SymbolLookup.loaderLookup();

SymbolLookup symbolLookup = name ->
systemLookup.lookup(name).or(() -> linkerLookup.lookup(name));

Optional<MemorySegment> printfMemorySegment = symbolLookup.lookup("printf");

从技术上讲,查找可能会失败,因此需要提供适当的错误处理。

2. 构建正在调用的函数的描述符

一旦知道了 C printf 所在的位置,就需要定义由结果类型和接受的参数组成的 printf 描述符。值得一提的是,像 printf 这样的本机函数称为可变参数函数。在 Java 中,接受可变参数集的方法称为具有可变参数的方法。

为了简化,我们可以为 printf 定义 FunctionDescriptor 的简化版本:

FunctionDescriptor printfDescriptor = FunctionDescriptor.of(JAVA_INT, ADDRESS);

注意:从 Java 运行时的角度来看,C 指针背后的值类型无关紧要,因为 C 指针的内存布局不保存类型,而是平台固定的 32/64 位值。

一个描述符定义了一个返回值类型为 int 的函数,它的参数是一个指针。假设一个描述符几乎对应于它在 stdio.h 中的 C 定义,因为它定义了一个标准函数,而 printf 是一个可变参数函数。

通过值布局(Value Layout)在 Java 中对 C 类型建模

在 Java 中,值布局用于对与基本数据类型的值关联的内存布局建模,例如整数类型(有符号或无符号)和浮点类型。JAVA_INT 和 ADDRESS 都是对应的 C 类型的值布局。

JAVA_INT :

// ValueLayout.OfInt.class
OfInt JAVA_INT = new OfInt(ByteOrder.nativeOrder()).withBitAlignment(32);

这是值布局的一个实例,它的载体是 int.class。通过这种布局,链接器被指示在 C int32和具有运营商类 int.class 的相应 Java int 类型之间创建桥梁。

ADDRESS:

// ValueLayout.OfAddress.class
OfAddress ADDRESS = new OfAddress(ByteOrder.nativeOrder())
.withBitAlignment(ValueLayout.ADDRESS_SIZE_BITS);

ADDRESS 是一个值布局,其中对应的 C 类型是一个指向变量的指针,载体是MemoryAddress.class。

3. 从函数的本机内存地址构建方法句柄

使用 C printf 本机地址及其函数描述符,我们现在可以为 C printf 创建一个方法句柄:

MethodHandle printfMethodHandle = symbolLookup.lookup("printf").map(
addr - > linker.downcallHandle(addr, printfDescriptor)
).orElse(null);

上面的代码创建了 C print 的可执行引用,简而言之:一个方法句柄,来自 printf 的本机内存地址及其函数描述符。

注意:方法句柄是对底层方法、构造函数、字段或类似低级操作的类型化、可执行引用,具有参数或返回值的可选转换。

现在已经解释了必要的概念,我们可以扩展 downcalls 和 upcalls 的定义:

  • downcall 是通过由本机函数地址及其 Java 版本的函数描述符形成的 MethodHandle调用本机函数。
  • upcall 是通过 MethodHandle 调用一些用 Java 编写的代码,该 MethodHandle 转换为本机内存段,然后可以将其作为函数指针传递给本机函数。

4. 分配本机内存

我们需要以某种方式将 Java 对象绑定到本机内存段,以确保 C printf 可以访问它们。

C 中的内存分配和释放内存都很痛苦,因为开发人员可能会忘记分配或释放内存,这会导致程序泄漏或因分段错误而崩溃。

另一方面,Java 依靠垃圾收集器来分配和释放内存。但是巴拿马的外部函数和内存 API 是在堆外分配内存,有助于分配堆外内存,这是任何本机互操作的关键部分!

外部函数和内存 API 允许开发人员分配和访问内存段、它们的地址以及位于堆上或堆外的连续内存区域的形状。所有分配的内存段都绑定到特定的内存会话 ( MemorySession )。内存会话的实例提供一组 API 来分配本机内存段。考虑一个内存会话,就像一个统一的内存分配工具,比如 C malloc。MemorySession 实现了 AutoClosable 接口,它使用 try-with-resources 结构极大地简化了取消分配。

外部函数和内存 API 提供了不止一种分配内存段的正确方法。一种可能的本机内存分配方法是 SegmentAllocator,它类似于 MemorySession:

try (var memorySession = MemorySession.openConfined()) {
SegmentAllocator allocator = SegmentAllocator.newNativeArena(memorySession);
var cStringFromAllocator = allocator.allocateUtf8String("Hello World" + "\n");
var cStringFromSession = memorySession.allocateUtf8String("Hello World" + "\n");
}

简单起见,这个“Hello World”应用程序将使用 MemorySession 作为内存段分配工具。

最后,要调用 C printf,我们需要使用 MemorySession 在内存会话中分配 const char * 内存段,并将其传递给 C printf 函数:

MemorySegment cString = memorySession.allocateUtf8String(str + "\n");

使用分配的内存段,我们可以调用函数:

private static int printf(String str, MemorySession memorySession) throws Throwable {
Objects.requireNonNull(printfMethodHandle);
var cString = memorySession.allocateUtf8String(str + "\n");
return (int) printfMethodHandle.invoke(cString);
}

public static void main(String[] args) throws Throwable {
var str = "Hello World";
try (var memorySession = MemorySession.openConfined()) {
System.out.println(printf(str, memorySession));
}
}

5. 小结

到目前为止,我们了解到内存会话 ( MemorySession ) 或段分配器 ( SegmentAllocator ) 是执行内存分配的关键 API。应使用 try-with-resources 声明内存会话以实现隐式内存释放。分配内存段有多种选择——通过段分配器或直接通过内存会话。链接器、符号查找对象、值和内存布局以及方法句柄都是静态对象。

总结

本文概述了外部函数和内存 API,并研究了如何从 Java 调用简单的 C 函数。

好消息是开发人员可以依靠 jextract 工具来处理大部分外部函数和内存机制。

使用外部函数和内存 API 从 Java 调用本机代码时需要解决几个问题:

  • 获取本机库及其对应的头文件。
  • 在 Java 中构建函数描述符 ( FunctionDescriptor )。
  • 查找函数符号的本机内存地址,并为其创建方法句柄。
  • 创建一个相关的方法句柄并确认它已经正确创建(例如,如果本机库不在系统路径中,查找将失败并且返回一个方法句柄将为空)。
  • 决定应用程序将如何分配内存段:通过段分配器或内存会话。确保内存分配技术在应用程序的整个代码库中保持一致。

代码清单

可以在这里找到本文的资源。

https://github.com/denismakogon/openjdk-project-samples/blob/master/Panama.md#openjdk-panama-part-1

package com.java_devrel.samples.panama.part_1;
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import java.util.Objects;
import static java.lang.foreign.ValueLayout.ADDRESS;
import static java.lang.foreign.ValueLayout.JAVA_INT;
public class PrintfSimplified {
private static final Linker linker = Linker.nativeLinker();
private static final SymbolLookup linkerLookup = linker.defaultLookup();
private static final SymbolLookup systemLookup = SymbolLookup.loaderLookup();
private static final SymbolLookup symbolLookup = name - > systemLookup.lookup(name).or(() - > linkerLookup.lookup(name));
private static final FunctionDescriptor printfDescriptor = FunctionDescriptor.of(JAVA_INT.withBitAlignment(32), ADDRESS.withBitAlignment(64));
private static final MethodHandle printfMethodHandle = symbolLookup.lookup("printf").map(addr - > linker.downcallHandle(addr, printfDescriptor)).orElse(null);
private static int printf(String str, MemorySession memorySession) throws Throwable {
Objects.requireNonNull(printfMethodHandle);
var cString = memorySession.allocateUtf8String(str + "\n");
return (int) printfMethodHandle.invoke(cString);
}
public static void main(String[] args) throws Throwable {
var str = "hello world";
try (var memorySession = MemorySession.openConfined()) {
System.out.println(printf(str, memorySession));
}
}
}

文章目录
  1. 1. 准备
    1. 1.1. 项目概述
    2. 1.2. Hello World 程序
    3. 1.3. 链接器
    4. 1.4. Java 中的 C 语言风格的“Hello World”
  2. 2. 1. 找到 native 函数的地址
  3. 3. 2. 构建正在调用的函数的描述符
    1. 3.1. 通过值布局(Value Layout)在 Java 中对 C 类型建模
  4. 4. 3. 从函数的本机内存地址构建方法句柄
  5. 5. 4. 分配本机内存
  6. 6. 5. 小结
    1. 6.1. 代码清单