vlambda博客
学习文章列表

可轻松管理大内存,JDK14外部内存访问API探秘

随着 JDK 14 的发布,新版带来了很多全新或预览的功能,如 instanceof 模式匹配、信息量更多的 NullPointerExceptions、switch 表达式等。大部分功能已经被许多新闻和博客网站广泛报道,但是孵化中的外部内存访问 API 还没有得到那么多的报道,许多报道 JDK 14 的新闻都省略了它,或者只提到了 1-2 行。很可能没有多少人知道它,也不知道它最终会允许你在 Java 中做什么。


简而言之,外部内存访问 API 是 Project Panama (1) 的一部分,是对 ByteBuffer 的替代,而 ByteBuffer 之前是用于堆外内存。对于任何低级的 I/O 来说,堆外内存是需要的,因为它避免了 GC,从而比堆内内存访问更快、更可靠。但是,ByteBuffer 也存在局限,比如 2GB 的大小限制等。


如果你想了解更多,你可以在下面链接观看 Maurizio Cimadamore 的演讲 (2)。


正如上面的视频所描述的那样,孵化外部内存访问 API 并不是最终的目标,而是通往更高的目标:Java 中的原生 C 库访问。遗憾的是,目前还没有关于何时交付的时间表。


话虽如此,如果你想尝试真正的好东西,那么你可以从 Github (3) 中构建自己的 JDK。我一直在做这个工作,为我的超频工具所需要的各种 Nvidia API 做绑定,这些 API 利用 Panama 的抽象层来使事情变得更简单。


说了这么多,那你实际是怎么使用它的呢?


MemoryAddress 以及 MemorySegment



import jdk.incubator.foreign.MemoryAddress;import jdk.incubator.foreign.MemorySegment;public class PanamaMain{ public static void main(String[] args){ MemoryAddress address = MemorySegment.allocateNative(4).baseAddress(); }}


当然,你可以通过 MemoryAddress 的 segment() 方法再次获取同一个 MemoryAddress 的段。在上面的例子中,我们使用的是重载的 allocateNative() 方法,该方法接收了一个新的 MemorySegment 的字节大小的 long 值。这个方法还有另外两个版本,一个是接受一个 MemoryLayout,我稍后会讲到,另一个是接受一个以字节为单位的大小和字节对齐。



而 MemorySegment 则有更多的 API。你可以通过 asByteBuffer() 将 MemorySegment 转换为 ByteBuffer,通过 close() 关闭(读:free)段(来自 AutoClosable 接口),然后用 asSlice() 将其切片(后面会有更多的内容)。


好了,我们已经分配了一大块内存,但如何对它进行读写呢?


MemoryHandle


MemoryHandles 是一个提供 VarHandles 的类,用于读写内存值。它提供了一些静态的方法来获取 VarHandle,但主要的方法是 varHandle,它接受下面任一类。


  • byte.class

  • short.class

  • char.class

  • int.class

  • double.class

  • long.class


(这些都不能和Object版本混淆,比如Integer.class)


在大多数情况下,你只需要通过 nativeOrder() 来使用原生顺序。至于你使用的类,你要使用一个适合 MemorySegment 的字节大小的类,所以在上面的例子中是 int.class,因为在 Java 中 int 占用了 4 个字节。


一旦你创建了一个 VarHandle,你现在就可以用它来读写内存了。读取是通过 VarHandle 的各种 get() 方法来完成的。关于这些 get 方法的文档并不是很有用,但简单的说就是你把 MemoryAddress 实例传递给 get 方法,就像这样。


import java.lang.invoke.VarHandle;import java.nio.ByteOrder;import jdk.incubator.foreign.MemoryAddress;import jdk.incubator.foreign.MemoryHandles;import jdk.incubator.foreign.MemorySegment;public class PanamaMain{ public static void main(String[] args) { MemoryAddress address = MemorySegment.allocateNative(4).baseAddress();
VarHandle handle = MemoryHandles.varHandle(int.class, ByteOrder.nativeOrder());
int value = (int)handle.get(address);
System.out.println("Memory Value: " + value); } }


你会注意到,这里的 VarHandle 返回的值是类型化的。如果你以前使用过 VarHandles,这对你来说并不震惊,但如果你没有使用过 VarHandle,那么你只要知道这很正常,因为 VarHandle 实例返回的是 Object。


默认情况下,所有由异构内存访问 API 分配的内存都是零。这一点很好,因为你不会在内存中留下随机的垃圾,但对于性能关键的情况下可能是不好的。


import java.lang.invoke.VarHandle;import java.nio.ByteOrder;import jdk.incubator.foreign.MemoryAddress;import jdk.incubator.foreign.MemoryHandles;import jdk.incubator.foreign.MemorySegment;public class PanamaMain{ public static void main(String[] args) { MemoryAddress address = MemorySegment.allocateNative(4).baseAddress();
VarHandle handle = MemoryHandles.varHandle(int.class, ByteOrder.nativeOrder());
handle.set(address, 10);
int value = (int)handle.get(address);
System.out.println("Memory Value: " + value); } }


MemoryLayout 以及 MemoryLayouts


MemoryLayouts 类提供了 MemoryLayout 接口的预定义实现。这些接口允许你快速分配 MemorySegments,保证分配等效类型的 MemorySegments,比如 Java int。一般来说,使用这些预定义的布局比分配大块内存要容易得多,因为它们提供了你想要使用的常用布局类型,而不需要查找它们的大小。


import java.lang.invoke.VarHandle;import java.nio.ByteOrder;import jdk.incubator.foreign.MemoryAddress;import jdk.incubator.foreign.MemoryHandles;import jdk.incubator.foreign.MemoryLayouts;import jdk.incubator.foreign.MemorySegment;public class PanamaMain{ public static void main(String[] args) { MemoryAddress address = MemorySegment.allocateNative(MemoryLayouts.JAVA_INT).baseAddress();
VarHandle handle = MemoryHandles.varHandle(int.class, ByteOrder.nativeOrder());
handle.set(address, 10);
int value = (int)handle.get(address);
System.out.println("Memory Value: " + value); } }

如果你不想使用这些预定义的布局,你也不必这样做。MemoryLayout(注意没有 "s")有静态方法,允许你创建自己的布局。这些方法会返回一些扩展接口,例如:


  • ValueLayout

  • SequenceLayout

  • GroupLayout


ValueLayout 接口的实现是由 ofValueBits() 方法返回的。它所做的就是创建一个基本的单值 MemoryLayout,就像 MemoryLayouts.JAVA_INT 一样。


SequenceLayout 是用于创建一个像数组一样的 MemoryLayout 的序列。接口实现是通过两个静态的 ofSequence() 方法返回,不过只有指定长度的方法可以用来分配内存。


GroupLayout 用于结构和联合类型的内存分配,因为它们之间相当相似。它们的接口实现来自于 structs 的 ofStruct() 或 union 的 ofUnion()。


如果之前没有说清楚,MemoryLayout(s) 的使用完全是可选的,但是,它们使 API 的使用和调试变得更容易,因为你可以用常量名代替读取原始数字。


但是,它们也有自己的问题。任何接受 var args MemoryLayout 输入作为方法或构造函数的一部分的东西都会接受 GroupLayout 或其他 MemoryLayout,而不是预期的输入。请确保你指定了正确的布局。


切片和数组


MemorySegment 可以被切片,以便在一个内存块中存储多个值,在处理数组、结构和联合时常用。如上文所述,这是通过 asSlice() 方法来完成的。为了进行分片,你需要知道你要分片的 MemorySegment 的起始位置,单位是字节,以及存储在该位置的值的大小,单位是字节。这将返回一个 MemorySegment,然后你可以获得 MemoryAddress。


import java.lang.invoke.VarHandle;import java.nio.ByteOrder;import jdk.incubator.foreign.MemoryAddress;import jdk.incubator.foreign.MemoryHandles;import jdk.incubator.foreign.MemorySegment;public class PanamaMain{ public static void main(String[] args) { MemoryAddress address = MemorySegment.allocateNative(24).baseAddress();  MemoryAddress address1 = address.segment().asSlice(0, 8).baseAddress(); MemoryAddress address2 = address.segment().asSlice(8, 8).baseAddress(); MemoryAddress address3 = address.segment().asSlice(16, 8).baseAddress();  VarHandle handle = MemoryHandles.varHandle(long.class, ByteOrder.nativeOrder());handle.set(address1, Long.MIN_VALUE); handle.set(address2, 0); handle.set(address3, Long.MAX_VALUE);  long value1 = (long)handle.get(address1); long value2 = (long)handle.get(address2); long value3 = (long)handle.get(address3);   System.out.println("Memory Value 1: " + value1); System.out.println("Memory Value 2: " + value2); System.out.println("Memory Value 3: " + value3); } }


这里需要指出的是,你不需要为每个 MemoryAddress 创建新的 VarHandles。


在一个 24 字节的内存块中,我们把它分成了 3 个不同的切片,使之成为一个数组。


你可以使用一个 for 循环来迭代它,而不是硬编码分片值。


import java.lang.invoke.VarHandle;import java.nio.ByteOrder;import jdk.incubator.foreign.MemoryAddress;import jdk.incubator.foreign.MemoryHandles;import jdk.incubator.foreign.MemorySegment;public class PanamaMain{ public static void main(String[] args) { MemoryAddress address = MemorySegment.allocateNative(24).baseAddress();  VarHandle handle = MemoryHandles.varHandle(long.class, ByteOrder.nativeOrder());  for(int i = 0; i <= 2; i++) { MemoryAddress slice = address.segment().asSlice(i*8, 8).baseAddress();  handle.set(slice, i*8);  System.out.println("Long slice at location " + handle.get(slice)); } } }


当然,你可以使用 SequenceLayout 而不是使用原始的、硬编码的值。


import java.lang.invoke.VarHandle;import java.nio.ByteOrder;import jdk.incubator.foreign.MemoryAddress;import jdk.incubator.foreign.MemoryHandles;import jdk.incubator.foreign.MemoryLayout;import jdk.incubator.foreign.MemoryLayouts;import jdk.incubator.foreign.MemorySegment;import jdk.incubator.foreign.SequenceLayout;public class PanamaMain{ public static void main(String[] args) { SequenceLayout layout = MemoryLayout.ofSequence(3, MemoryLayouts.JAVA_LONG); MemoryAddress address = MemorySegment.allocateNative(layout).baseAddress();  VarHandle handle = MemoryHandles.varHandle(long.class, ByteOrder.nativeOrder());  for(int i = 0; i < layout.elementCount().getAsLong(); i++) { MemoryAddress slice = address.segment().asSlice(i*layout.elementLayout().byteSize(), layout.elementLayout().byteSize()).baseAddress();  handle.set(slice, i*layout.elementLayout().byteSize());  System.out.println("Long slice at location " + handle.get(slice)); } } }


不包括的内容


到目前为止,所有的东西都只在 JDK 14 的孵化版的范围内,然而,正如前面提到的,这一切都是迈向原生 C 库访问的垫脚石,甚至有一两个方法名被更改了,已经过时了。在这一切的基础上,还有另外一层终于可以让你访问原生库调用。总结一下还缺什么。


  • jextract

  • Library 查找

  • ABI specific ValueLayout

  • Runtime ABI 布局

  • FunctionDescriptor 接口

  • ForeignUnsafe


所有这些都是在外部访问 API 的基础上分层,也是对外存访问 API 的补充。如果你打算为一些原生 C 语言库创建绑定,那么现在学习这些 API 就不会浪费。


文中链接


  1. https://openjdk.java.net/projects/panama/

  2. https://www.youtube.com/watch?v=r4dNRVWYaZI

  3. https://github.com/openjdk/panama-foreign


原文

https://medium.com/@youngty1997/jdk-14-foreign-memory-access-api-overview-70951fe221c9


参考阅读:






高可用架构

改变互联网的构建方式