Java Project Panama: What It Actually Does and How to Use It
Project Panama is Oracle's effort to give Java a proper foreign function interface and foreign memory access API. It lets you call native libraries and manipulate native memory directly from Java without writing JNI boilerplate. You skip the .c file, the build script, and the header generation dance. The JEP 412 and JEP 424 work together to make this happen, and the API has been stabilizing through each JDK release. Most people coming from JNI do it the hard way. You write a C file, compile it with javac's -h flag, deal with jni.h, and pray your struct offsets match. With Panama, you describe the native layout in Java and call into the library directly. It's faster to set up and easier to read, though it comes with its own set of footguns.
Setting Up a Basic FFI Call with Panama
You need a JDK build that includes Panama. The first stable release with proper support is JDK 22, but if you're on an earlier version you can use the incubator modules. For this walkthrough I'm assuming JDK 22 or later with the appropriate flags. Here's a concrete example. Say you want to call libc's strlen function from Java. You set up a MethodHandles.Lookup, link against the library, and you're done. No generated headers, no native compilation step.
Code example: ```java import java.lang.foreign.*; import java.lang.invoke.*; import java.nio.charset.StandardCharsets; public class PanamaDemo { public static void main(String[] args) throws Throwable { var linker = Linker.ofDefaults(); var arena = Arena.ofConfined(); // Link strlen from libc SymbolLookup libc = SymbolLookup.libraryLookup("libc", arena); FunctionDescriptor desc = FunctionDescriptor.of( ValueLayout.JAVA_LONG, Addressable.LAYOUT ); MethodHandle strlen = linker.downcallHandle( libc.find("strlen").get(), desc ); // Allocate native memory for the string byte[] bytes = "hello world".getBytes(StandardCharsets.US_ASCII); MemorySegment input = arena.allocate(bytes); // Call strlen long length = (long) strlen.invokeExact(input); System.out.println("Length: " + length); } } ```Compile with these flags: Note the exact module name changed between early access builds and the final release. On JDK 22+ you typically don't need the incubator module flag anymore. The --enable-native-access flag controls whether untrusted code can access native memory, which matters if you're running this in a sandboxed environment. The biggest mistake I see is people treating MemoryArena like garbage-collected Java heap. It isn't. When you create an Arena, you're creating a scope. Memory allocated within that arena is freed when the arena closes. Forget to close it and you leak native memory, and the GC won't touch it.
Get the Full Details

I ran into this during a benchmarking run last year. I was calling into a Rust library that returned heap-allocated strings, and I was accumulating MemorySegments inside a loop without closing the arena. After about 400 iterations the process hit the OOM killer. The JVM itself was fine. It was the native side bleeding out. The fix is simple: use confined arenas properly, or switch to global arenas for long-lived segments. For scoped work, Arena.ofConfined() is your friend. Close it in a try-with-resources block and you're golden.
Better pattern: ```java try (var arena = Arena.ofConfined()) { // ... all native calls and allocations ... } // arena.close() is called automatically here ```Working with Complex Types: Structs and Unions
Native code loves structs. Panama lets you describe them with MemoryLayout and SequenceLayout. But here's where people get tripped up: layout padding and alignment aren't automatic. You have to specify them explicitly, or the JIT won't know how to lay out your data correctly. Consider a C struct like this:
```c struct Person { char name[64]; int age; double score; }; ```In Panama, you describe it like this: This creates a MemorySegment layout that matches the C struct on a typical system. You can then use varhandles to read and write individual fields. The downcall mechanism handles the pointer arithmetic for you. There are a few things that will bite you if you've only ever used JNI. First, the calling convention matters. Panama uses the platform default by default, which on x86-64 Linux is the System V ABI. If you're calling into a Windows DLL, you might need to specify a different calling convention explicitly.

Second, string conversion. Panama doesn't auto-convert between Java Strings and C strings. You have to allocate a MemorySegment and copy the bytes yourself. Use StandardCharsets.UTF_8 or US_ASCII depending on what the native code expects. Getting this wrong produces garbage results that are hard to debug because there's no exception thrown — the native function just processes invalid memory. Third, thread safety. MemorySegments are not thread-safe across arenas. If you're passing a segment from one thread to another, both threads need to share the same arena, and you need to handle synchronization yourself. Panama doesn't solve this for you. If you need a more production-ready solution and don't want to manage arenas manually, consider JNR-FFI as an alternative. It abstracts away some of the memory management complexity, though it's a separate dependency and doesn't have the same level of integration with the JDK toolchain.
When Panama Isn't the Right Tool
There are scenarios where Panama adds overhead without real benefit. If you're making thousands of calls per second to the same native function, the downcall handle dispatch has measurable cost. In those cases, a well-tuned JNI implementation with inline caching patterns can be faster. I've seen benchmarks where Panama was 3x slower than hand-written JNI for tight loops calling into libm. Also, Panama doesn't help with legacy codebases that already have JNI wrappers. The migration path isn't trivial because you need to refactor the entire memory management layer, not just swap out the call site. If your project has 50 JNI .c files, rewriting them in Panama is a significant undertaking. But for new projects, scripting, or one-off integrations with native libraries, Panama cuts the setup time from hours to minutes and produces code that's easier to maintain than equivalent JNI. That trade-off is worth it for most use cases.