浅析一下Java FFM API(Project Panama)

这篇文章并不是讲如何使用Java FFM API,而是浅谈其背后的实现原理。

前言

前不久,OpenJDK宣布了Java Foreign Function & Memory API将在JDK 22退出预览,这意味着在JDK 22后,FFM API不会有重大改动。借此机会,我想可以好好聊聊FFM API是怎么实现的。

FFM API介绍

FFM API由两大部分组成,一个是Foreign Function Interface,另一个是Memory API。前者是外部函数接口,简称FFI,用它来实现Java代码和外部代码之间相互操作;后者是内存接口,用于安全地管理堆外内存。

Memory API

为了方便切入,我这里写了一个很简单的Demo:

java 复制代码
    private static void allocDemo() throws Throwable {
        try (Arena arena = Arena.ofConfined()) {
            MemorySegment cString = arena.allocateUtf8String("Panama");
            String jString = cString.getUtf8String(0l);
            System.out.println(jString);
        }
    }
  • 这里在堆外开辟了一段内存来存放字符串Panama,接下来copy到JVM栈上,最后输出。
  • 简单介绍下,MemorySegment用于表示一段内存片段(既可以是堆内也可以是堆外),Arena划定了一个作用域,便于进行内存回收。

这背后做了些什么工作呢?我们跳过去看看。

Memory API是对jdk.internal.misc.Unsafe的安全封装

我们首先跳到cString.getUtfString,因为很明显该部分涉及到访问堆外内存的操作。局部代码如下:

MemorySegment.java line:1089

java 复制代码
    default String getUtf8String(long offset) {
        return SharedUtils.toJavaStringInternal(this, offset);
    }
  • 这里写把操作写到了一个Util里面。

我们跳过去看看。


ShareUtils.java line:250

java 复制代码
    public static String toJavaStringInternal(MemorySegment segment, long start) {
        int len = strlen(segment, start);
        byte[] bytes = new byte[len];
        MemorySegment.copy(segment, JAVA_BYTE, start, bytes, 0, len);
        return new String(bytes, StandardCharsets.UTF_8);
    }
  • 这里设置了一个byte数组,这是在JVM栈上的,之后访问堆外内存,进行copy操作。

很明显MemorySegment.copy才是我们关心的,再跳过去看看。


MemorySegment.java line:2209

java 复制代码
    @ForceInline
    static void copy(
            MemorySegment srcSegment, ValueLayout srcLayout, long srcOffset,
            Object dstArray, int dstIndex, int elementCount) {
        Objects.requireNonNull(srcSegment);
        Objects.requireNonNull(dstArray);
        Objects.requireNonNull(srcLayout);

        AbstractMemorySegmentImpl.copy(srcSegment, srcLayout, srcOffset,
                dstArray, dstIndex,
                elementCount);
    }
  • 这里先是一顿判空,之后再调用了AbstractMemorySegmentImpl.copy方法,这里AbstractMemorySegmentImplMemorySegment的实现,MemorySegment是一个密封接口,只允许了AbstractMemorySegmentImpl实现。

OK,继续跳转。


AbstractMemorySegmentImpl.java line:625

java 复制代码
    @ForceInline
    public static void copy(MemorySegment srcSegment, ValueLayout srcLayout, long srcOffset,
                            Object dstArray, int dstIndex,
                            int elementCount) {
	// 此处省略了原本一系列判空、校验等操作。
        if (dstWidth == 1 || srcLayout.order() == ByteOrder.nativeOrder()) {
            ScopedMemoryAccess.getScopedMemoryAccess().copyMemory(srcImpl.sessionImpl(), null,
                    srcImpl.unsafeGetBase(), srcImpl.unsafeGetOffset() + srcOffset,
                    dstArray, dstBase + (dstIndex * dstWidth), elementCount * dstWidth);
        } else {
            ScopedMemoryAccess.getScopedMemoryAccess().copySwapMemory(srcImpl.sessionImpl(), null,
                    srcImpl.unsafeGetBase(), srcImpl.unsafeGetOffset() + srcOffset,
                    dstArray, dstBase + (dstIndex * dstWidth), elementCount * dstWidth, dstWidth);
        }
    }
  • 这里可以看到一个ScopedMemoryAccess,熟悉jdk.internal.misc.Unsafe的大佬估计到这就懂了,ScopedMemoryAccess也是在jdk.internal.misc下的,并且其中有一个UNSAFE字段。

最终,我们顺着ScopedMemoryAccess.getScopedMemoryAccess().copyMemory最终会跳转到哪里呢?答案是Unsafe中的一个native方法:copyMemory0,而该native方法是通过JNI实现的。

OK,可以下结论了:Memory API是对jdk.internal.misc.Unsafe的封装,使得Java程序员操纵堆外内存更加得心应手,让Unsafe变得Safe。这一点其实已在相关的JEP里表明了:

Non-goals

It is not a goal to

· Re-implement JNI on top of this API, or otherwise change JNI in any way;

· Re-implement legacy Java APIs, such as sun.misc.Unsafe, on top of this API;

· Provide tooling that mechanically generates Java code from native-code header files; or

· Change how Java applications that interact with native libraries are packaged and deployed (e.g., via multi-platform JAR files).

来源:JEP 442: Foreign Function & Memory API (Third Preview)

我在阅读该段落时陷入过一个逻辑错误,为避免大家同样陷入该逻辑错误,我在这解释一下。

该段第二条讲的是:在该API上,重新实现像sun.misc.Unsafe之类的遗留API。而这里是Non-goals,也就是说,该API不会重新实现像sun.misc.Unsafe之类的遗留API,意味着该API会做一些像完善sun.misc.Unsafe之类的工作。

可以看到,Java FFM API并没有完全脱离JNI。那FFI部分呢?该不会也是封装JNI吧?我们一探究竟。

Foreign Function Interface

我同样写了一个Demo:

java 复制代码
    private static void downCallDemo() throws Throwable {
        Linker linker = Linker.nativeLinker();
        MemorySegment strlen_address = linker.defaultLookup().find("strlen").get();
        MethodHandle strlen = linker.downcallHandle(
                strlen_address,
                FunctionDescriptor.of(JAVA_LONG, ADDRESS)
        );
        try (Arena arena = Arena.ofConfined()) {
            MemorySegment cString = arena.allocateUtf8String("Hello");
            long len = (long) strlen.invokeExact(cString); // 5
            System.out.println(len);
        }
    }
  • 这里先是整一个Linker,之后获取本地既有函数strlen的地址,接着调用它并传入一个字符串,最后获取它的返回值,输出。

我比较好奇的是,它是如何直接通过一个字符串查找到一个函数的?

Linker 与 SymbolLookup

为弄清以上问题,我们先看看linker.defaultLookup()是怎么实现的,因为很明显这里涉及到查找等操作。

Linker.java line:636

java 复制代码
SymbolLookup defaultLookup();
  • 遗憾的是,Linker是一个接口,里面并没有实现defaultLookup(),因此我们需要找到它的实现类。

Linker.java中可以发现,Linker是一个密封接口,仅允许AbstractLinker实现。


AbstractLinker.java line:60

java 复制代码
public abstract sealed class AbstractLinker implements Linker permits LinuxAArch64Linker, MacOsAArch64Linker,
                                                                      SysVx64Linker, WindowsAArch64Linker,
                                                                      Windowsx64Linker, LinuxPPC64leLinker,
                                                                      LinuxRISCV64Linker, FallbackLinker {
// ......
}
  • 可以看到,AbstractLinker是个密封类,并提供了多个平台的实现。这里便可以推断出,在某个地方存在根据平台返回不同Linker的操作。实际上,这个操作就在Linker.nativeLinker() :(

AbstractLinker实现了defaultLookup()方法:

AbstractLinker.java line:133

java 复制代码
    @Override
    public SystemLookup defaultLookup() {
        return SystemLookup.getInstance();
    }
  • 这里是的返回类型是SystemLookup,而在Linker内,defaultLookup的返回类型为SymbolLookup。实际上,SystemLookupSymbolLookup的实现。

至此,我们可以判断,在我们的Demo中,linker为对应平台的Linker实现;linker.defaultLookup()实际返回一个SystemLookup对象。

那么,在linker.defaultLookup().find("strlen")中又发生了什么?


SystemLookup.java line:137

java 复制代码
    @Override
    public Optional<MemorySegment> find(String name) {
        return SYSTEM_LOOKUP.find(name);
    }

SystemLookup.java line:57

java 复制代码
    private static final SymbolLookup SYSTEM_LOOKUP = makeSystemLookup();

    private static SymbolLookup makeSystemLookup() {
        try {
            if (Utils.IS_WINDOWS) {
                return makeWindowsLookup();
            } else {
                return libLookup(libs -> libs.load(jdkLibraryPath("syslookup")));
            }
        } catch (Throwable ex) {
            // This can happen in the event of a library loading failure - e.g. if one of the libraries the
            // system lookup depends on cannot be loaded for some reason. In such extreme cases, rather than
            // fail, return a dummy lookup.
            return FALLBACK_LOOKUP;
        }
    }

SystemLookup.java line:106

java 复制代码
    private static SymbolLookup libLookup(Function<RawNativeLibraries, NativeLibrary> loader) {
        NativeLibrary lib = loader.apply(RawNativeLibraries.newInstance(MethodHandles.lookup()));
        return name -> {
            Objects.requireNonNull(name);
            if (Utils.containsNullChars(name)) return Optional.empty();
            try {
                long addr = lib.lookup(name);
                return addr == 0 ?
                        Optional.empty() :
                        Optional.of(MemorySegment.ofAddress(addr));
            } catch (NoSuchMethodException e) {
                return Optional.empty();
            }
        };
    }
  • 不难发现,Demo中调用linker.defaultLookup().find("strlen")时,实际返回一个类型为Optional<MemorySegment>的对象。
  • SystemLookup.libLookup()中,进行了加载本地库,获取函数地址等操作。我们需要研究的,就是在这个方法内。

jdk.internal.loader下的本地库相关实例

在以上代码片段中,NativeLibrary用于表示已加载的本地库。官方给的解释如下:

NativeLibrary represents a loaded native library instance.

在以上代码片段中,RawNativeLibraries用于管理已加载的本地库。它有一个libraries哈希表,显然是用于存放已加载的本地库;提供了loadunload等方法的实现。

注意,使用RawNativeLibraries::load方法加载的本地库,不会被视为JNI本地库,而是被当成一个普通的本地库对待。RawNativeLIbraries可以加载JNI本地库,但是JNI本地库中,包含JNI_OnLoadJNI_OnUnload 函数,这两个函数会被RawNativeLIbraries忽略,可能导致无法维持原JNI库的功能,同时不支持将JNI本地库中的函数和native方法链接。官方解释如下:

RawNativeLibraries has the following properties: 1. Native libraries loaded in this RawNativeLibraries instance are not JNI native libraries. Hence JNI_OnLoad and JNI_OnUnload will be ignored. No support for linking of native method. 2. Native libraries not auto-unloaded. They may be explicitly unloaded via NativeLibraries::unload. 3. No relationship with class loaders

同时,这里存在另外的类用于加载JNI本地库:NativeLibraries

从这我们可以看出来,Project Panama将FFI与JNI做了严格的切割。主要原因有这几点:

  1. FFI默认需要加载的本地库不是专门为JVM设计的。
  2. FFI要支持本地库可以被不同的Classloader加载。
  3. FFI要支持本地库可以根据需要被多次加载。

这也是FFI与JNI重要的不同之处,FFI赢太多了。

在以上代码片段中,不难发现,本地函数的地址由NativeLibrary::lookup()得到。

实际上,在代码片段SystemLookup.java line:106中,lib的类型最终为RawNativeLibraryImpl。该类为RawNativeLibraries的内部类,继承NativeLibrary。而RawNativeLibraries::load的返回值类型就是RawNativeLibraryImpl,因此本地函数的地址由RawNativeLibraryImpl::lookup()得到。

但它并没有重写lookup()方法,哈哈!而是重写了find()方法:

RawNativeLibraries.java line:168

java 复制代码
        @Override
        public long find(String name) {
            return findEntry0(handle, name);
        }

NativeLibrary.java line:48

java 复制代码
    public final long lookup(String name) throws NoSuchMethodException {
        long addr = find(name);
        if (0 == addr) {
            throw new NoSuchMethodException("Cannot find symbol " + name + " in library " + name());
        }
        return addr;
    }

    /*
     * Returns the address of the named symbol defined in the library of
     * the given handle.  Returns 0 if not found.
     */
    static native long findEntry0(long handle, String name);

至此,我们已经到达了Java宇宙的边界。显然,这里还是用到了JNI,来查找本地函数的地址。

总结

通过这次分析可以看到,FFM API在架构上做出了很大的优化,这一点或许可以说明FFM API的性能优势。

FFI在加载机制上做出了很大改变,大大提高了互操作性。

FFM API并没有独立于jdk.internal.misc.Unsafe和JNI存在,但也不是简单的封装,更不是对JNI的修改,而是一种利用关系。

遗憾的是,这篇文章并没有涉及到MethodHandle相关的分析,没有讲Java FFI是如何实现downcallupcall的,其中还有很多有趣的技术和解决方案。

参考:

JEP draft: Foreign Function & Memory API (openjdk.org)

JDK 21 (openjdk.org)

相关推荐
书院门前细致的苹果8 小时前
JVM 全面详解:深入理解 Java 的核心运行机制
java·jvm
稻草人想看远方10 小时前
GC垃圾回收
java·开发语言·jvm
我真的是大笨蛋12 小时前
从源码和设计模式深挖AQS(AbstractQueuedSynchronizer)
java·jvm·设计模式
我真的是大笨蛋15 小时前
G1 垃圾收集器深入解析
java·jvm·笔记·缓存
好多1717 小时前
《JVM如何排查OOM》
开发语言·jvm·python
getdu20 小时前
JVM第一部分
jvm
海梨花21 小时前
字节一面 面经(补充版)
jvm·redis·后端·面试·juc
Mr_Xuhhh1 天前
项目-sqlite类的实现
java·jvm·sqlite
佛祖让我来巡山1 天前
深入理解Java对象:从创建到内存访问的JVM底层机制
jvm·对象创建过程·对象是如何创建的
用手手打人1 天前
JVM详解(一)--JVM和Java体系结构
jvm