完成外部 API

外部内存访问 API 和外部链接器 API 已存在一段时间,现在是时候全面了解这些 API 的结构和使用方法,并查看在完成这些 API 之前是否还有一些最终简化机会。在本文件中,我们将重点关注当前 API 迭代中的未解决问题,并为前进的道路铺平道路。我要感谢 Paul Sandoz、John Rose 和 Brian Goetz,他们在整篇文档中讨论的问题上提供了许多有用的见解。

内存取消引用

在查看客户端如何与外部内存访问 API 交互时(尤其涉及 jextract 生成的代码时),我们注意到内存分配方式和内存取消引用方式之间存在不对称性。以下代码段总结了该问题

MemorySegment c_sizet_array = allocator.allocateArray(SIZE_T, new long[] { 1L, 2L, 3L });
// print contents
for (int i = 0; i < 3; i++) {
   System.out.println(MemoryAccess.getLongAtIndex(c_sizet_array, i));
}

在上面,我们可以看到分配段的 API (SegmentAllocator::allocateArray) 同时采用布局(即 SIZE_T)和 long[] 数组。此惯用语提供动态安全性:如果数组组件类型的大小和所提供布局的大小不匹配,则会抛出异常。也许令人惊讶的是,取消引用 API (MemoryAccess::getLongAtIndex) 不会发生这种情况,它只采用段和偏移量;这里没有布局参数,运行时无法使用它来强制执行其他验证。

这种不一致不仅仅是一个外观问题,而是反映了外部内存访问 API 随着时间的推移而演变的方式。在 API 的第一次迭代中,取消引用内存段的唯一方法是通过内存访问 var 句柄。虽然 var 句柄在我们的取消引用故事中仍然扮演着核心角色,尤其是在涉及结构化访问(考虑 C 结构和张量)时,但在 API 的后续版本中,我们做了一些可用性让步,最终在侧类 (MemoryAccess) 中添加了一整套取消引用方法,最近又添加了一组从 Java 数组复制到内存段并返回的方法 (MemoryCopy)。但这种方法存在问题

  • 这些静态方法与 API 的其余部分不一致;如上所见,它们不接受布局参数,而只接受可选的 ByteOrder 参数。这并不是很通用,因为字节序仅仅一个维度,它可以影响内存取消引用应如何执行(例如对齐呢?)
  • 在辅助类中添加方法可以使 MemorySegment API 保持简单,但会产生可发现性问题:使用 IDE 时,可能并不明显的是取消内存段引用的方法是调用单独类中的静态方法。

换句话说,现在是时候重新审视这些辅助类,看看是否有更好的解决方案了。

将载体附加到值布局

一个有希望的举措(我们将在本文档的其余部分中讨论)是将载体类型附加到值布局。也就是说,如果我们可以表示 ValueLayout<int>ValueLayout<double 这样的类型,那么我们的取消引用 API 将如下所示

interface MemorySegment {
   ...
   <Z> Z get(ValueLayout<Z> layout, long offset)
   <Z> void set(ValueLayout<Z> layout, long offset, Z value)
}

请注意这是如何很好地对称的:假设我们有像 JAVA_INT(其类型将是 ValueLayout<int>)这样的常量,我们现在可以以更直接的方式从段中读取 int 值,如下所示

MemorySegment segment = ...
int i = segment.get(JAVA_INT, 0);

在这里,布局信息(对齐、字节序)自然地流入取消引用操作,因此无需支持基于 ByteOrder 的重载。此处所示的取消引用 API 也更易于发现(使用 IDE 时,只需一个代码补全)[1]。

这似乎是一个胜利;API 不仅更易于使用和简洁,而且更具可扩展性:如果我们添加另一个载体 (Float16Long128),我们只需要定义其布局,而无需额外的 API。最后,将载体附加到值布局允许我们极大地简化 Foreign Linker API(稍后会详细介绍)。

由于我们还没有专门的泛型,我们如何使用我们今天拥有的语言来近似上述 API?我们可以使用的一个技巧是引入额外的值布局叶,每个载体一个(例如 ValueLayout.OfIntValueLayout.OfFloat 等),然后定义许多取消引用重载,每个布局载体一个

byte get(ValueLayout.OfByte layout, long offset)
short get(ValueLayout.OfShort layout, long offset)
int get(ValueLayout.OfInt layout, long offset)
...

这在实践中非常有效:它为我们提供了类型安全性(用户不再可能对错误的布局使用错误的载体)——并且,当 Valhalla 准备就绪时,我们可以重新连接这些类以使其成为 ValueLayout 的参数化子类,并最终弃用它们(因为 ValueLayout<Z> 就足够了)。有了此 API,我们用来开始本节的有问题的代码段将变成如下内容

MemorySegment c_sizet_array = allocator.allocateArray(SIZE_T, new long[] { 1L, 2L, 3L });
// print contents
for (int i = 0; i < 3; i++) {
   System.out.println(c_sizet_array.get(SIZE_T, i));
}

如果 SIZE_T 的类型是 ValueLayout.OfLong,那么客户端将被强制(通过静态编译器)在初始化内存段时使用 long[] 数组。此外,取消引用操作现在允许客户端指定布局,其静态类型将影响选择哪个取消引用重载——这意味着将 SIZE_T 传递给 MemorySegment::get 将保证返回 long

不安全取消引用

在某些情况下,最好也为不安全访问提供取消引用帮助器——考虑以下情况

MemoryAddress addr = ...
int v = MemorySegment.globalNativeSegment().get(JAVA_INT, addr.toRawLongOffset());

虽然此代码运行良好,但它也非常冗长。在某种程度上,这是设计使然 - 也就是说,客户端应该取消引用内存段,而不是普通地址,因为前者更安全(例如,内存段同时具有空间和时间边界)。因此,更安全的替代方法是执行此操作

MemoryAddress addr = ...
int v = addr.asSegment(100).get(JAVA_INT, 0);

但是,对于临时本机堆外访问(特别是对于一次性上调存根),客户端最好有方便的不安全取消引用例程,这些例程直接在MemoryAddress实例上工作

MemoryAddress addr = ...
int v = addr.get(JAVA_INT, 0);

MemorySegments中的对应项不同,MemoryAddress中的取消引用方法将是受限方法,使用它们需要客户端在命令行上提供--enable-native-access标志。

链接器分类

如果载体被下推到值布局,我们还可以简化外来 API 的其他区域。CLinker提供两个主要抽象,用于创建下调方法句柄(针对本机函数的方法句柄)和上调存根(针对 Java 方法句柄的本机函数指针)。链接时,用户必须同时提供 Java MethodTypeFunctionDescriptor;第一个描述调用站点将处理的 Java 签名,而后者描述链接器运行时使所有这些工作所需的分类信息

MethodHandle strlen = CLinker.getInstance().downcallHandle(
    strLenAddr, // obtained with SymbolLookup
    MethodType.methodType(long.class, MemoryAddress.class),
    FunctionDescriptor.of(C_LONG, C_POINTER)
);

如果载体附加到值布局,则很容易看出链接过程只需要组信息,即函数描述符:事实上,我们始终可以从与函数描述符关联的布局集派生出 Java MethodType,使用以下简单规则

  • 如果布局是具有载体C的值布局,则C将是与该布局关联的载体
  • 如果布局是组布局,则MemorySegment.class将用作载体

换句话说,附加到值布局的附加载体信息将允许链接器运行时区分大小相似的布局(例如,可以是 C int 或 C float 的 32 位值布局)。此外,我们始终可以添加新载体以添加链接器运行时所需的大量分类。这意味着上述链接请求可以更简洁地表示如下

MethodHandle strlen = CLinker.getInstance().downcallHandle(
    strLenAddr, // obtained with SymbolLookup
    FunctionDescriptor.of(C_LONG, C_POINTER)
);

也就是说,只需要一个函数描述符参数,并且下调方法句柄的 Java 类型将相应派生。

布局属性和常量

以这种方式进行 ABI 分类的一个直接后果是,链接器运行时不再依赖布局属性机制来区分大小相似的值布局;事实上,我们建议从布局 API 中完全删除对布局属性的支持。虽然我们不希望此功能被广泛使用,但我们始终可以在以后决定允许用户将自定义Map实例附加到布局。我们的实现不会使用此元数据,而只是将其传递(例如,当使用 API 提供的wither方法之一更改ValueLayout时)。

需要注意的另一件重要事项:由于值布局是严格类型化的,因此某些 C 布局常量的类型化(例如 C_INT)变得不明确(在 Windows/x64 上将为 ValueLayout.OfInt,在 Linux/x64 上将为 ValueLayout.OfLong)。我们不会使用不太严格的类型来定义这些常量,而是选择从 CLinker 中完全删除与平台相关的 C 布局常量:毕竟,提出适用于给定提取单元的布局常量集是提取工具的工作,而不是链接器的工作。不使用 jextract 的客户端既可以将自定义 C 布局定义为静态常量,也可以简单地使用 JAVA_INTJAVA_LONG 等,这与在 JNI 代码中使用 jintjdouble 等类型并没有太大不同。这一观察结果使我们能够从 CLinker API 中删除大部分混乱内容,并返回一个更简单的界面。

链接器安全性

我们希望通过 Foreign Linker API 更明确地解决的另一个问题是外部调用的安全性:换句话说,当通过引用将结构传递给本机调用时,如果在完成本机调用之前与结构关联的范围关闭,会发生什么情况?这可能发生在受限和共享的情况下,尽管要使用受限范围重现此问题,我们至少需要使用上调用(例如,从 Java 上调用关闭范围)。

这里的问题是,链接器 API 强制下调用方法句柄的客户端将通过引用参数擦除到 MemoryAddress 实例,然后传递这些实例。这在 API 中造成了一些紧张:要么我们也使 MemoryAddress 成为一个作用域抽象(以便它们跟踪其源自的作用域),要么我们就失去了安全性。但使 MemoryAddress 成为一个作用域抽象(如我们在 17 中所做的那样)有缺点:通常在与本机代码交互时使用 MemoryAddress,以对来自下调用方法句柄的本机指针进行建模;因此,将 MemoryAddress 视为一个长值(机器地址)的简单包装器很有吸引力,该值可以根据用户请求转换为更完整的段(通过提供自定义大小和范围)。但如果 MemoryAddress 已经具有作用域,事情就会变得更加复杂,并且我们必须定义当客户端碰巧(可能意外地)覆盖现有作用域时会发生什么。

我们建议通过以下举措来解决此问题

  • CLinker 不再将通过引用参数擦除到 MemoryAddress - 而使用 Addressable 载体;
  • Addressable 接口还获取资源范围访问器;链接器运行时将使用此范围在整个调用过程中保持通过引用参数处于活动状态;
  • MemoryAddress 是一个 Addressable 实现,其作用域始终为全局作用域

有了这些更改,当我们像上面那样链接 strlen 时,生成的 downcall 方法句柄的类型将不是 (MemoryAddress)long,而是 (Addressable)long。这意味着客户端可以直接传递内存段,并让链接器运行时按引用传递它们,如下所示

MemorySegment str = ...
long length = strlen.invokeExact((Addressable)str);

或者,不使用 invokeExact

MemorySegment str = ...
long length = strlen.invoke(str);

使用 invokeExact 语义时存在额外的强制转换,这很不幸,但经过评估许多替代方案后,它似乎也是最小的恶。在大多数情况下,工具只对 Addressable 类型感到满意 - 事实上,这正是 jextract 生成其包装器所需的内容

long strlen(Addressable x1) {
   try {
       return strlen_handle.invokeExact(x1);
   } ...
}

请注意,上面的代码不需要强制转换,因为 jextract 包装器已经是通用的。不使用 jextract 时,用户可以选择:添加强制转换,如上所述(其冗长程度与添加尾随 .address() 调用差不多),或转换方法句柄类型,如下所示

MethodHandle strlen_segment = CLinker.getInstance().downcallHandle(
    strLenAddr, // obtained with SymbolLookup
    FunctionDescriptor.of(C_LONG, C_POINTER)
).asType(long.class, MemorySegment.class);

...

MemorySegment str = ...
long length = strlen_exact.invokeExact(str);

由于我们可以使用 MethodHandle::asType 调整与 downcall 方法句柄关联的方法类型,因此很容易将更清晰的类型注入到 downcall 方法句柄中,并在调用站点处删除强制转换,即使使用 invokeExact 也是如此。

资源范围

目前有不同类型的资源范围,它们部分重叠。查看 ResourceScope 类,我们发现三个主要工厂,用于创建受限共享隐式范围。前两个被称为显式范围 - 也就是说,客户端可以使用 close() 方法(确定性地)关闭此类范围。另一方面,隐式范围无法关闭 - 尝试这样做将导致异常。因此,处理与隐式范围关联的资源的唯一方法是让范围变得不可达

实际上,情况有点复杂,因为 API 还允许创建与清理器对象关联的显式范围;此类范围可以通过 close() 方法(作为任何其他显式范围)关闭,但它们还允许在范围变得不可达时清理范围。在某种程度上,这些范围是隐式的,又是显式的。

虽然资源范围 API 本身相对简单,但它提供的不同且微妙重叠的工厂数量可能会令人吃惊。我们建议通过始终针对清理器注册资源范围来解决此问题;毕竟,范围是长期存在的实体,并且使用内部清理器注册范围的开销很小。由于现在所有范围都具有显式和隐式释放,因此 API 只能提供两种类型的范围,即受限范围和共享范围,并删除隐式范围。生成的 API 更安全,因为客户端不再可能忘记调用 close()(清理器将启动并执行关联的清理)。API 也更加统一,因为现在所有范围(但全局范围是单例)都可以关闭,并用于 try-with-resources [2]。

我们提出的最后一个简化已在此处 首次讨论,并用更直接的方式替换资源范围句柄机制来表达范围之间的依赖关系。有了此机制,以下代码

void accept(MemorySegment segment1, MemorySegment segment2) {
   try {
       var handle1 = segment1.scope().acquire();
       var handle2 = segment2.scope().acquire();
       <critical section>
   } finally {
       segment1.scope().release(handle1);
       segment2.scope().release(handle2);
   }
}

可以更简洁地表示为以下内容

void accept(MemorySegment segment1, MemorySegment segment2) {
   try (ResourceScope scope = ResourceScope.newConfinedScope()) {
       scope.keepAlive(segment1.scope());
       scope.keepAlive(segment2.scope());
       <critical section>
   }
}

最后,我们希望使 ResourceScope 实现 SegmentAllocator 接口。在仅提供范围的上下文中调用需要段分配器的方法的情况并不少见。 ResourceScope 接口的实现已经实现了 SegmentAllocator,但此实现并未在公共 API 中公开,而是允许客户端使用 SegmentAllocator::ofScope 方法从范围转换为分配器。我们相信公开资源范围和分配器之间的关系将有助于减少外国 API 提供的不同抽象之间所需的转换次数。

预览重组

为了使 API 成为 预览 API,我们计划将 jdk.incubator.foreign 包中的所有类移至 java.lang.foreign 包 [3] 中的 java.base 模块。此外,我们计划进行以下更改(此工作可能在单独的分支上进行,以避免冲突)

  • MemoryHandles 类将被删除,其所有内容都将移至 MethodHandles 下;这是有道理的,因为此类包含内存访问变量句柄的通用工厂,以及一组通用变量句柄组合器。
  • 删除 SymbolLookup 抽象;为了查找符号加载器符号,我们计划在 ClassLoader 类中添加一个查找方法。现在删除 SymbolLookup 不会阻止我们在将来添加更强大的查找机制;它也不会阻止客户端定义自定义链接查找,例如使用 Function<String, MemoryAddress>
  • 重命名 ResourceScope。有人指出,ResourceScope 名称略有误导,因为 scope 一词有时会被解释为词法范围的语境。虽然 ResourceScope 确实可以通过 try-with-resource 结构提供一个进行分配的词法范围,但 ResourceScope 抽象的某些用法与词法范围无关(例如存储在字段中的共享段)。因此,可以选择一个更具体的名字。

由于前面各节中描述的更改已经导致删除了许多辅助类,例如 MemoryAccessMemoryCopyMemoryLayouts,因此无需进一步调整。

总结

总体而言,此处描述的更改使 Foreign API 变得更加紧凑、简单且更安全。将载体附加到值布局允许取消引用操作更通用、统一且静态安全;它还允许我们简化链接器分类故事,因为在构建下调方法句柄时无需使用单独的 MethodType 参数冗余地提供相同的信息。而且,由于下调方法句柄不再要求客户端将按引用参数擦除到 MemoryAddress,因此客户端可以传递 Addressable(最显着的内存段)的任何子类型 - 链接器 API 将在调用期间保持按引用参数的范围。随着 MemoryAddress 现在成为一个简单的 long 包装器,用于对本机指针建模(换句话说,不再允许从堆上段获取 MemoryAddress),MemoryAddress 的作用变得更加简单。最后,将范围与清理器关联默认允许我们极大地简化 API,并在防止意外内存泄漏方面使其更安全。

可以在 此处 找到一个总结建议的 API 更改的 javadoc;可以在此实验性 分支 中找到相应的代码更改,其中还包含 jextract 工具与新 API 配合使用的必要调整。

  1. 类似的惯用法还可用于增强批量内存操作的可用性和静态安全性(此处未显示) 

  2. 如果范围分配性能至关重要,我们可能会提供允许客户端选择退出清理器的重载范围工厂。也就是说,这应该是一个高级选项,我们确实希望大多数客户端对更简单的工厂提供的默认值感到满意。 

  3. 我们可能会决定将功能拆分为不同的包 - 例如 java.lang.foreign 用于内存访问 API,java.lang.foreign.invoke 用于外部链接器 API。