iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
📑

FFI in Java (Project Panama)

に公開3件

This is the 7th day article for the Java Calendar | Advent Calendar 2021.

Overview

Doing FFI using JNI in Java is quite a challenge, isn't it?
Writing C code for bindings, or writing C itself...

To improve this situation, Project Panama is currently underway and has been included in OpenJDK releases as an Incubator since Java 14.

In this article, I would like to try out Project Panama using Java 17.

Trying puts(3)

First, let's try a simple Hello, World.

import java.lang.invoke.MethodType;

import jdk.incubator.foreign.CLinker;
import jdk.incubator.foreign.MemoryAddress;
import jdk.incubator.foreign.FunctionDescriptor;
import jdk.incubator.foreign.ResourceScope;

public class Main {
    public static void main(String...args) throws Throwable {
        var puts = CLinker.getInstance().downcallHandle(
            CLinker.systemLookup().lookup("puts").orElseThrow(),
            MethodType.methodType(void.class, MemoryAddress.class),
            FunctionDescriptor.ofVoid(CLinker.C_POINTER));
        try (var scope = ResourceScope.newConfinedScope()) {
            var cstr = CLinker.toCString("Hello, World!", scope);
            puts.invokeExact(cstr.address());
        }
    }
}

I felt that the atmosphere is similar to Python's ctypes.

$ java --add-modules jdk.incubator.foreign --enable-native-access=ALL-UNNAMED ./Main.java
WARNING: Using incubator modules: jdk.incubator.foreign
warning: using incubating module(s): jdk.incubator.foreign
1 warning
Hello, World!
$

It worked!

With Class Loaders

I was able to perform FFI easily. Given that, I'm personally curious about the java.lang.UnsatisfiedLinkError that often crops up when class loaders are involved.
It's that restriction where a specific dynamic library can only be loaded by a single class loader.
I'll prepare two libraries with almost identical content and try to load them.
(For no particular reason, I wrote the native side in Rust.)

.
├── ...
├── Cargo.toml
├── Main.java
├── v1
│  ├── Cargo.toml
│  └── src
│     └── lib.rs
└── v2
   ├── Cargo.toml
   └── src
      └── lib.rs
// v1/src/lib.rs & v2/src/lib.rs
#[no_mangle]
pub extern "C" fn hello() {
    println!("Hello, world!{}", env!("CARGO_PKG_NAME"))
}
// Main.java
...
public class Main implements Consumer<String> {
    ...
    public static void main(String...args) throws Exception {
        new Main().accept("target/debug/libv1.so");
        new Main().accept("target/debug/libv2.so");

        // create new classloader. (same class path)
        var cp = System.getProperty("java.class.path");
        var paths = Stream.of(cp.split(Pattern.quote(File.pathSeparator))).map(p -> must(() -> Path.of(p).toUri().toURL())).toArray(URL[]::new);
        var loader = new URLClassLoader(paths, null);
        @SuppressWarnings("unchecked")
        var instance = (Consumer<String>) loader.loadClass("Main").getDeclaredConstructor().newInstance();
        instance.accept("target/debug/libv2.so");
        instance.accept("target/debug/libv2.so");
    }

    @Override
    public void accept(String lib) {
        System.load(Path.of(lib).toAbsolutePath().toString());

        var hello = CLinker.getInstance().downcallHandle(
            SymbolLookup.loaderLookup().lookup("hello").orElseThrow(),
            MethodType.methodType(void.class),
            FunctionDescriptor.ofVoid());
        try {
            hello.invokeExact();
        } catch (RuntimeException e) {
            throw e;
        } catch (Throwable t) {
            throw new RuntimeException(t);
        }
    }
}

While writing this, I noticed that symbol lookups seem to be performed against libraries loaded via System.load(String) or System.loadLibrary(String).
https://docs.oracle.com/en/java/javase/17/docs/api/jdk.incubator.foreign/jdk/incubator/foreign/SymbolLookup.html

A symbol lookup. Exposes a lookup operation for searching symbol addresses by name, see lookup(String). A symbol lookup can be used to lookup a symbol in a loaded library. Clients can obtain a loader lookup, which can be used to search symbols in libraries loaded by the current classloader (e.g. using System.load(String), or System.loadLibrary(String)). Alternatively, clients can obtain a platform-dependent lookup, to search symbols in the standard C library.

Since I'm using System.load, it seems likely that a java.lang.UnsatisfiedLinkError will occur. Let's try running it for now.

$ java --add-modules jdk.incubator.foreign --enable-native-access=ALL-UNNAMED Main
WARNING: Using incubator modules: jdk.incubator.foreign
Hello, world!v1
Hello, world!v1
Exception in thread "main" java.lang.UnsatisfiedLinkError: Native Library <...>/target/debug/libv2.so already loaded in another classloader
        at java.base/jdk.internal.loader.NativeLibraries.loadLibrary(NativeLibraries.java:197)
        at java.base/jdk.internal.loader.NativeLibraries.loadLibrary(NativeLibraries.java:170)
        at java.base/java.lang.ClassLoader.loadLibrary(ClassLoader.java:2389)
        at java.base/java.lang.Runtime.load0(Runtime.java:755)
        at java.base/java.lang.System.load(System.java:1953)
        at Main.accept(Main.java:48)
        at Main.accept(Main.java:21)
        at Main.main(Main.java:42)

As expected, it failed. Since the symbols also overlap, v2 wasn't called either.

Using libdl

Since FFI can be handled so easily, why not use libdl to bypass Java's restrictions?
With that in mind, let's give it a try.

// Main2.java
...
public class Main2 implements Consumer<String> {
    ...
    public static void main(String...args) throws Exception {
        new Main2().accept("target/debug/libv1.so");
        // same class loader.
        new Main2().accept("target/debug/libv2.so");

        // create new classloader. (same class path)
        var cp = System.getProperty("java.class.path");
        var paths = Stream.of(cp.split(Pattern.quote(File.pathSeparator))).map(p -> must(() -> Path.of(p).toUri().toURL())).toArray(URL[]::new);
        var loader = new URLClassLoader(paths, null);
        @SuppressWarnings("unchecked")
        var instance = (Consumer<String>) loader.loadClass("Main2").getDeclaredConstructor().newInstance();
        instance.accept("target/debug/libv1.so");
        instance.accept("target/debug/libv2.so");
    }

    @Override
    public void accept(String lib) {
        try {
            var handle = dlopen(lib, 0x00001 /* RTLD_LAZY */);
            var helloAddr = dlsym(handle, "hello");
            var hello = CLinker.getInstance().downcallHandle(
                helloAddr,
                MethodType.methodType(void.class),
                FunctionDescriptor.ofVoid());
            hello.invokeExact();
        } catch (RuntimeException e) {
            throw e;
        } catch (Throwable t) {
            throw new RuntimeException(t);
        } finally {
            //dlclose(handle);
        }
    }

    static MemoryAddress dlopen(String filename, int flag) throws Throwable {
        var dlopen = CLinker.getInstance().downcallHandle(
                CLinker.systemLookup().lookup("dlopen").orElseThrow(),
                MethodType.methodType(MemoryAddress.class, MemoryAddress.class, int.class),
                FunctionDescriptor.of(CLinker.C_POINTER, CLinker.C_POINTER, CLinker.C_INT));
        try (var scope = ResourceScope.newConfinedScope()) {
            var cname = CLinker.toCString(filename, scope);
            var result = (MemoryAddress) dlopen.invokeExact(cname.address(), flag);
            if (result.equals(MemoryAddress.NULL)) {
                String msg = dlerror();
                throw new RuntimeException(msg);
            }
            return result;
        }
    }

    static String dlerror() {
        try {
            var dlerror = CLinker.getInstance().downcallHandle(
                    CLinker.systemLookup().lookup("dlerror").orElseThrow(),
                    MethodType.methodType(MemoryAddress.class),
                    FunctionDescriptor.of(CLinker.C_POINTER));
            var result = (MemoryAddress) dlerror.invokeExact();
            if (result.equals(MemoryAddress.NULL)) {
                return "";
            }
            return CLinker.toJavaString(result);
        } catch (Throwable t) {
            return "failed to get error message. because: " + t;
        }
    }

    static MemoryAddress dlsym(MemoryAddress handle, String symbol) throws Throwable {
        var dlsym = CLinker.getInstance().downcallHandle(
                CLinker.systemLookup().lookup("dlsym").orElseThrow(),
                MethodType.methodType(MemoryAddress.class, MemoryAddress.class, MemoryAddress.class),
                FunctionDescriptor.of(CLinker.C_POINTER, CLinker.C_POINTER, CLinker.C_POINTER));
        try (var scope = ResourceScope.newConfinedScope()) {
            var cname = CLinker.toCString(symbol, scope);
            var result = (MemoryAddress) dlsym.invokeExact(handle, cname.address());
            if (result.equals(MemoryAddress.NULL)) {
                String msg = dlerror();
                throw new RuntimeException(msg);
            }
            return result;
        }
    }

    static void dlclose(MemoryAddress handle) throws Throwable {
        var dlclose = CLinker.getInstance().downcallHandle(
                CLinker.systemLookup().lookup("dlclose").orElseThrow(),
                MethodType.methodType(int.class, MemoryAddress.class),
                FunctionDescriptor.of(CLinker.C_INT, CLinker.C_POINTER));
        var result = (int) dlclose.invokeExact(handle); // Probably no NPE will occur. (Object -> Integer -> int)
        if (result != 0) {
            String msg = dlerror();
            throw new RuntimeException(msg);
        }
    }
}

Let's run it.

$ java --add-modules jdk.incubator.foreign --enable-native-access=ALL-UNNAMED Main2
WARNING: Using incubator modules: jdk.incubator.foreign
Hello, world!v1
Hello, world!v2
Hello, world!v1
Hello, world!v2

It worked!

Conclusion

The code is available at https://github.com/yskszk63/panama-test/tree/master.

Since I had been away from Java for a while, I'm constantly amazed by the recent evolution of Java, not just Project Panama.

I'm really looking forward to a future where FFI can be handled easily in Java.
I hope the "canal" opens soon so we can take the shortcut!

GitHubで編集を提案

Discussion

110416110416

Scala 3 のコンパイルタイムマクロとpanamaのAPIを使ってFFI のボイラープレートを減らすプロジェクトがあるのでもし興味があったら見てみてください(^ω^)

https://dev.to/markehammons/slinc-update-5afi

https://github.com/markehammons/SLInC

yskszk63yskszk63

すごい! おもしろそうです。
教えていただき、ありがとうございます。

Javaのランタイムが進化すると、JVM系の言語にも良い影響がありますね。たしかにです。

1
110416110416

Javaのランタイムが進化すると、JVM系の言語にも良い影響が

ですね!こういう基盤となるところをJVMが支えてくれていて嬉しい限りです

Scala はちょくちょく破壊的変更が入るのでエンタープライズ受けが悪いらしいですが、その分古いAPIの削除・刷新や新しい機能の追加があってとても楽しいのでぜひともScalaも使ってみてください....! 特にScala 3はとても良くできた言語なのでぜひ...

1