For most of Java's life, calling a C library meant JNI: write a Java class with native methods, write C glue that converts between JVM types and C types, compile that glue for every operating system and CPU you ship, and hope nobody frees memory twice. Project Panama is the OpenJDK project that set out to replace that experience. It connects the JVM to code and data that live outside it: native libraries, off-heap memory, and the vector units inside modern CPUs.
This article looks at Panama as a project you adopt, not as an API reference. It covers what Panama delivers and how mature each piece is, walks one real C library (zlib) from JNI to hand-written foreign-function code to generated bindings, explains the native-access rules that newer JDKs enforce, and covers packaging, measurement and the mistakes teams make. The API itself, including memory segments, arenas, layouts, downcalls and upcalls, is covered in depth in the FFM architecture article, which this one builds on rather than repeats.
What Panama is made of
Panama is an umbrella, and it helps to separate its pieces because they have very different maturity. The Foreign Function and Memory (FFM) API, in the package java.lang.foreign, lets Java allocate and access memory outside the heap and call native functions directly, with no C glue. It is final and supported. jextract is a tool that reads C header files and generates Java bindings that use the FFM API; it ships separately from the JDK. The Vector API, in jdk.incubator.vector, lets you write data-parallel loops that the JIT compiles into SIMD instructions; it is still an incubator module.
All three share one idea: describe the boundary in Java (a FunctionDescriptor, a MemoryLayout, a VectorSpecies) and let the JVM generate the crossing, which is safer and optimisable by the JIT.
The timeline: from incubator to final
The FFM API took roughly five years of public iteration. Foreign memory access first incubated in JDK 14 (JEP 370), and a foreign linker incubated in JDK 16 (JEP 389). The two were merged into one incubating API in JDK 17 (JEP 412) and JDK 18 (JEP 419), then previewed in JDK 19, 20 and 21 (JEPs 424, 434 and 442). JEP 454 made it final in JDK 22. Anything written against an incubator or preview version will need changes, because names moved several times; code written for JDK 22 or later uses the stable API.
The Vector API first incubated in JDK 16. JEP 529 is its eleventh incubation, in JDK 26, and JEP 537 proposes a twelfth for JDK 27, both with no substantial implementation changes. The stated plan is to keep it incubating until Project Valhalla's value classes are available as a preview, then adapt the API to them and move it to preview. The Valhalla article explains why value types matter here: a vector object should be a value the JIT can keep in a register, not a heap object with identity.
The rule: use FFM in production on JDK 22 or later; use the Vector API only if you accept --add-modules jdk.incubator.vector, a startup warning and API churn. The Vector API article covers species, masks and the C2 intrinsics.
The same function three ways: JNI, hand-written FFM, jextract
Take zlib, the compression library installed on almost every Unix system, and two of its functions: crc32, which checksums a buffer, and compress, which compresses one. Here is the JNI version of crc32. Notice that you ship a second native library, your glue, which must be compiled per platform, and that GetByteArrayElements may copy the whole array.
// JNI: the Java half
public final class ZlibJni {
static { System.loadLibrary("zlibjni"); } // your glue library, not libz itself
public static native long crc32(long crc, byte[] buf);
}
/* JNI: the C half, compiled separately for every OS and CPU you ship */
#include <jni.h>
#include <zlib.h>
JNIEXPORT jlong JNICALL Java_ZlibJni_crc32(JNIEnv *env, jclass cls, jlong crc, jbyteArray buf) {
jsize len = (*env)->GetArrayLength(env, buf);
jbyte *p = (*env)->GetByteArrayElements(env, buf, NULL); /* may copy */
uLong r = crc32((uLong) crc, (const Bytef *) p, (uInt) len);
(*env)->ReleaseByteArrayElements(env, buf, p, JNI_ABORT);
return (jlong) r;
}With FFM there is no C code: look up the symbol in libz, describe its signature, get a MethodHandle. Argument memory comes from an Arena that frees everything when closed.
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import static java.lang.foreign.ValueLayout.*;
// Linux: libz.so.1 is the Linux library name (macOS uses libz.dylib). On LP64 platforms
// C 'unsigned long' (zlib's uLong) is 64 bits; on Windows it is 32 bits, which is exactly why generated bindings are per platform.
public final class Zlib {
private static final Linker LINKER = Linker.nativeLinker();
private static final SymbolLookup LIBZ = SymbolLookup.libraryLookup("libz.so.1", Arena.global());
private static final MethodHandle COMPRESS_BOUND = LINKER.downcallHandle(
LIBZ.find("compressBound").orElseThrow(),
FunctionDescriptor.of(JAVA_LONG, JAVA_LONG));
private static final MethodHandle COMPRESS = LINKER.downcallHandle(
LIBZ.find("compress").orElseThrow(),
FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS, JAVA_LONG));
public static byte[] compress(byte[] input) throws Throwable {
try (Arena arena = Arena.ofConfined()) { // freed at the closing brace
long bound = (long) COMPRESS_BOUND.invokeExact((long) input.length);
MemorySegment src = arena.allocateFrom(JAVA_BYTE, input);
MemorySegment dst = arena.allocate(bound);
MemorySegment dstLen = arena.allocateFrom(JAVA_LONG, bound); // in/out parameter
int rc = (int) COMPRESS.invokeExact(dst, dstLen, src, (long) input.length);
if (rc != 0) throw new IllegalStateException("zlib compress returned " + rc);
return dst.asSlice(0, dstLen.get(JAVA_LONG, 0)).toArray(JAVA_BYTE);
}
}
}Two details matter. destLen is an in/out pointer: zlib reads the buffer size from it and writes the compressed size back. And zlib's uLong is 64 bits on Linux and macOS but 32 on Windows; a hand-written descriptor hard-codes that, and getting it wrong passes garbage rather than throwing.
That is the argument for jextract: it parses the header with Clang for your target platform, so type sizes come from the compiler rather than from memory.
# Generate bindings for just the functions you use, into your source tree
jextract --output src/main/java \
-t org.example.zlib \
-l z \
--include-function compress \
--include-function compressBound \
--include-function uncompress \
--include-function crc32 \
--include-function zlibVersion \
/usr/include/zlib.h
# Discover what a header exposes before choosing what to include
jextract --dump-includes zlib-symbols.txt /usr/include/zlib.hThe output is a header class, zlib_h by default, with a static method per included function, so the call becomes zlib_h.crc32(crc, segment, length). The -l option names the library to load; a value starting with : is treated as a path. Filter with --include-function, because a full header pulls in hundreds of transitively included symbols. Run jextract per target platform in CI and package the results per platform.
Native access is now a policy decision
Native code can crash the JVM in ways no exception can catch, so the JDK marks the methods that open this door as restricted: in FFM, creating downcall handles and reinterpreting segments; since JEP 472 in JDK 24, also System.loadLibrary, System.load and binding JNI native methods. One policy now governs both routes.
--enable-native-access names the modules allowed to use restricted methods, or ALL-UNNAMED for the class path. --illegal-native-access decides what happens to everyone else: allow, warn (today's default) or deny, which throws IllegalCallerException. A future release is planned to restrict native access by default, so today's warnings are tomorrow's failures.
# Classpath application: grant native access to all unnamed-module code
java --enable-native-access=ALL-UNNAMED -jar app.jar
# Modular application: grant it only to the module that holds the bindings
java --enable-native-access=org.example.zlib -m org.example.app/org.example.app.Main
# Executable JAR: the manifest can carry the grant instead of the command line
# Enable-Native-Access: ALL-UNNAMED
# JDK 24 and later: decide what happens to code that was NOT granted access
java --illegal-native-access=deny --enable-native-access=org.example.zlib -m ...Isolate bindings in their own module, grant access to it alone, and test with --illegal-native-access=deny, so an unexpected native call from a dependency becomes a test failure rather than an unread warning.
Packaging and loading native libraries
FFM does not solve the hardest part of native interop: getting the right shared library onto the machine. System libraries are already installed; pin the major version (libz.so.1, not libz.so, which often ships only with development packages). Bundled libraries travel inside the JAR per platform and are extracted and loaded by path at startup. Container images install the library, the simplest option if you control the runtime.
Symbol lookup has three sources: SymbolLookup.libraryLookup loads a library by name or path, tied to an arena; Linker.nativeLinker().defaultLookup() finds standard C library symbols; and SymbolLookup.loaderLookup() finds symbols in libraries loaded with System.loadLibrary. Pick one convention per project and document it, because a library present on a laptop and missing in production is the most common Panama bug.
Performance: what to measure and how
A downcall still crosses from managed to native code, so the JVM switches the thread's state for the garbage collector. FFM's advantage over JNI is that the stub is generated from the descriptor and the JIT can inline everything up to it, with no JNIEnv lookup and no array copying. For very short functions that never block or call back into Java, Linker.Option.critical lets the JVM skip the thread-state transition entirely, and can optionally allow heap segments to be passed directly. Use it only when both conditions truly hold. Linker.Option.captureCallState("errno") captures errno immediately after the call, before the JVM can overwrite it.
Do not trust intuition about where the time goes. Measure with JMH, across buffer sizes, because the boundary cost is fixed while the work grows with size.
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class Crc32Bench {
@Param({"16", "4096", "1048576"}) int size;
byte[] data;
MemorySegment nativeData; // pre-allocated off-heap copy for the FFM path
Arena arena;
@Setup public void setup() {
data = new byte[size];
ThreadLocalRandom.current().nextBytes(data);
arena = Arena.ofConfined();
nativeData = arena.allocateFrom(ValueLayout.JAVA_BYTE, data);
}
@TearDown public void tearDown() { arena.close(); }
@Benchmark public long javaUtilZip() { var c = new java.util.zip.CRC32(); c.update(data); return c.getValue(); }
@Benchmark public long jni() { return ZlibJni.crc32(0, data); }
@Benchmark public long ffm() { return zlib_h.crc32(0, nativeData, (int) size); }
}Expect a pattern, then confirm it on your hardware. For tiny buffers the pure-Java CRC32, a JIT intrinsic with no boundary, often wins; for large buffers all three converge on the cost of the checksum itself. The biggest real gains come from keeping data off-heap across many calls rather than from any single call being faster.
Worked example: moving a service off a JNI wrapper
A log-ingestion service compresses batches through an in-house JNI zlib wrapper whose build only one person understands, whose Windows build is broken, and which crashed last quarter on a missed ReleaseByteArrayElements.
They migrate in five steps. They run jextract per platform in CI with --include-function for the four functions they call. They put the bindings behind a facade with the old JNI class's interface, so callers do not change. They give each batch one confined arena, closed when the batch is written. They grant native access to the bindings module only and run CI with --illegal-native-access=deny, which flags another dependency loading its own native library. Finally, they benchmark production batch sizes and compare output bytes on a week of shadow traffic.
The result is no C in the repository, a working Windows build, and use-after-free turned into an exception instead of a crash.
Failure modes and how they show up
| Symptom | Likely cause | Fix |
|---|---|---|
| JVM crash (SIGSEGV) inside a downcall | Descriptor does not match the C signature, often a type-size mismatch | Generate descriptors with jextract per platform; never hand-code platform-dependent types |
IllegalStateException: already closed | Segment used after its arena closed | Widen the arena's scope or copy the data to the heap before closing |
WrongThreadException | Confined arena's segment used from another thread | Use a shared arena, or keep the work on the owning thread |
| Warnings about restricted methods at startup | Module not granted native access | Add --enable-native-access for that module; test with deny |
| Library not found in production | Different library name or path than on the build machine | Pin the versioned name, bundle per platform, or install it in the image |
| Virtual-thread throughput collapses | Long native calls pin the carrier thread | Run blocking native calls on a platform-thread executor |
The last row surprises people: a virtual thread cannot unmount while a native frame is on its stack, so a slow native call holds a carrier thread throughout. The virtual threads article explains the scheduler.
Trade-offs: when not to use Panama
A pure-Java library avoids the boundary, per-platform builds and crash risk entirely. A stable JNI wrapper is worth migrating only when you next need to change it. FFM earns its place for large or evolving C APIs, precisely controlled off-heap memory, or JNI glue that has become a burden.
What to do next
- Confirm your runtime is JDK 22 or later, and list every JNI library and native dependency your application loads today.
- Run your test suite with
--illegal-native-access=denyto discover native access you did not know about, then grant it per module. - Pick one small C function you already call through JNI, bind it with jextract using
--include-function, and put it behind the existing Java interface. - Give every native allocation an arena with a clear owner, and prefer confined arenas unless data is truly shared across threads.
- Write a JMH benchmark across realistic sizes comparing pure Java, JNI and FFM before deciding anything on performance grounds.
- Generate and test bindings per target platform in CI, and document how the shared library reaches each environment.
- Keep the Vector API in experiments until it leaves incubation, and read the FFM architecture article before binding structs or callbacks.