A complete guide with hands-on examples – No more JNI complexity!
When developing a Java application, there are scenarios where you need to interact with system-level APIs or libraries written in languages like C, C++, OpenGL, TensorFlow, Rust, or Python. Traditionally, this required using the complex and error-prone Java Native Interface (JNI). To overcome these limitations, Project Panama was introduced to simplify and enhance Java’s interoperability with native code. As part of this initiative, Java 21 introduced the Foreign Function and Memory (FFM) API, a powerful and modern way to call native functions and manage native memory directly from Java — without JNI’s boilerplate. Together, Project Panama and the FFM API open new possibilities for Java developers who need efficient, type-safe access to native libraries or low-level memory operations.
Understanding the Foreign Function & Memory (FFM) API
The Foreign Function & Memory (FFM) API is a modern, platform-independent framework in Java that enables seamless interaction with native code and memory. It serves two key purposes:
- Invoking native functions — Java programs can directly call functions defined in native libraries (like C or C++) without relying on the complex JNI layer.
- Managing off-heap memory — It provides a clean and safe way to allocate, access, and manipulate memory regions that exist outside the Java heap.
Status Check: The FFM API is a preview feature in Java 21 (JEP 442) and became final in Java 22 (JEP 454). If you’re using Java 21, you’ll need the --enable-preview flag. Java 22+ users get it out of the box.
Before we jump into code, let’s understand the five concepts that make FFM work. Once you grasp these, everything else will click into place.
The 6 Core Concepts
Before diving into code, let’s understand the building blocks. We’ll use these to call C’s strlen() function from pure Java – no JNI, no complexity. Once you grasp these six concepts, the code examples will be straightforward.
The FFM API is built on six fundamental building blocks. Let’s explore each one — you’ll see them all in action in the code that follows.
1. Linker 🌉 — The Bridge

Think of it as: The bridge connecting two worlds
Linker linker = Linker.nativeLinker();
The Linker is your entry point to native code. It connects the managed Java world (with its garbage collection, type safety, and JIT optimization) to native libraries written in C, Rust, or any language that exposes a C-compatible interface.
Every interaction with native code starts here. The Linker helps you:
- Find native functions in libraries
- Create Java method handles that call those functions
- Set up callbacks so native code can call your Java methods
2. MemorySegment 📦 — The Safe Pointer

Think of it as: A secure box for memory with built-in safety
MemorySegment segment = arena.allocate(ValueLayout.JAVA_INT);
segment.set(ValueLayout.JAVA_INT, 0, 42); // Safe ✓
segment.set(ValueLayout.JAVA_INT, 4, 99); // Exception! Out of bounds ✗
In C, pointers are just raw memory addresses – powerful but dangerous. C pointers crash your process on invalid access. Java’s MemorySegment wraps native memory with safety guarantees.A MemorySegment knows its own size. Try to access memory outside its bounds? You get a clean Java exception (IndexOutOfBoundsException), not a JVM crash.
3. Arena 🧹 — The Memory Manager

Think of it as: Your automatic cleanup crew
Native memory isn’t managed by Java’s garbage collector. Forget to free it in C, and you have a memory leak. The Arena solves this elegantly using a pattern Java developers already know: try-with-resources.
When the Arena closes, every MemorySegment allocated through it is freed automatically. No manual free() calls, no memory leaks. It’s safe, simple, and elegant.
try (Arena arena = Arena.ofConfined()) {
MemorySegment data = arena.allocate(1024);
// Use the memory...
} // Arena closes, all memory automatically freed! ✨
This is Java-style resource management for native memory.
4. MemoryLayout 🗺️ — The Blueprint

Think of it as: The blueprint describing your data structure
A MemorySegment is just a block of bytes in memory . How does Java know what’s inside?Is it an integer? A string? A complex C struct? That’s where MemoryLayout comes in – it describes the structure of your data.
ValueLayout.JAVA_INT // 4-byte signed integer
ValueLayout.JAVA_LONG // 8-byte signed long
ValueLayout.ADDRESS // Native pointer/address
Key feature: The layout tells Java exactly how to interpret the bytes in memory – their type, size, and alignment. This enables type-safe access and prevents data corruption.
This blueprint is what keeps your data safe and correctly interpreted.
5. FunctionDescriptor 📋 — The Contract

Think of it as: The function’s signature or contract
When you call a native function, Java needs to know: What does it return? What parameters does it expect? The FunctionDescriptor captures this information as a Java object.
// C function: size_t strlen(const char *str)
FunctionDescriptor descriptor = FunctionDescriptor.of(
ValueLayout.JAVA_LONG, // Returns: size_t (mapped to long)
ValueLayout.ADDRESS // Parameter: const char* (mapped to address)
);
Key feature: By explicitly declaring the function signature, you help Java correctly marshal arguments and interpret results. It’s type-safe and compiler-checked.
This contract bridges two worlds with type safety, ensuring that Java and C understand each other perfectly.
6. MethodHandle 🔗 — The Callable Reference

Think of it as: A typed, invokable reference to a function
MethodHandle strlen = linker.downcallHandle(strlenAddr, descriptor);
long length = (long) strlen.invoke(nativeStr);
The bridge object that actually calls the native function. Created by the Linker using the FunctionDescriptor, it’s type-safe, optimizable by the JIT compiler, and works seamlessly across the Java/native boundary. Think of it like a method reference in Java, but one that can call functions in C, Rust, or any native library.
Putting It All Together

Now you understand all six building blocks. Here’s how they work together in practice:
- Linker finds the native function in the library
- FunctionDescriptor describes its signature (parameters and return type)
- MethodHandle creates the callable bridge to invoke the function
- Arena manages the lifecycle of all memory allocations
- MemorySegment safely holds the data in native memory
- MemoryLayout interprets the structure of that data
Here’s the complete code showing all six concepts working together:

The equation: 🌉 Linker + 📋 FunctionDescriptor + 🔗 MethodHandle + 🧹 Arena + 📦 MemorySegment + 🗺️ MemoryLayout = Safe, Fast Native Interop
With these six concepts mastered, you’re ready to see them in action. Let’s dive into the complete example!
Demo 1: Calling a C Function (Basic Downcall)
Let’s put theory into practice. We’ll call C’s strlen() function from pure Java – no JNI, no C wrapper code. This is called a “downcall” because we’re calling down from Java into native code.
The Goal
Call the C standard library’s strlen() function to calculate string length:
size_t strlen(const char *str);
Complete Code
package org.example.concepts.ffi;
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
@SuppressWarnings("preview")
public class BasicDowncall {
public static void main(String[] args) {
System.out.println("=== Demo 1: Basic Downcall ===");
System.out.println("Calling C's strlen() from Java\n");
try {
// Step 1: Get the native linker
Linker linker = Linker.nativeLinker();
// Step 2: Lookup the C standard library
SymbolLookup stdlib = linker.defaultLookup();
// Step 3: Find the strlen function
MemorySegment strlenAddr = stdlib.find("strlen")
.orElseThrow(() -> new RuntimeException("strlen not found"));
// Step 4: Define the function signature
// C: size_t strlen(const char *str)
FunctionDescriptor descriptor = FunctionDescriptor.of(
ValueLayout.JAVA_LONG, // return: size_t
ValueLayout.ADDRESS // param: const char*
);
// Step 5: Create a method handle (Java -> Native bridge)
MethodHandle strlen = linker.downcallHandle(strlenAddr, descriptor);
// Step 6: Use it!
testStrlen(strlen, "Hello, World!");
testStrlen(strlen, "Java 21 FFM API");
testStrlen(strlen, "🚀 Unicode!");
} catch (Throwable e) {
System.err.println("Error: " + e.getMessage());
e.printStackTrace();
}
}
private static void testStrlen(MethodHandle strlen, String text) throws Throwable {
try (Arena arena = Arena.ofConfined()) {
// Allocate native memory for the string
MemorySegment nativeStr = arena.allocateUtf8String(text);
// Call native strlen
long length = (long) strlen.invoke(nativeStr);
System.out.printf("\"%s\"%n", text);
System.out.printf(" C strlen(): %d bytes%n", length);
System.out.printf(" Java length(): %d chars%n", text.length());
if (length != text.length()) {
System.out.println(" (UTF-8 encoding uses multiple bytes for some characters)");
}
System.out.println();
}
}
}
Breaking It Down
Steps 1-3: Finding the Function
Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();
MemorySegment strlenAddr = stdlib.find("strlen").orElseThrow();
We get the Linker (Concept 1 — our bridge), use it to access the C standard library, and find the strlen function. The result is a MemorySegment (Concept 5) containing the function’s address.
How does this work without installing anything?
The defaultLookup() automatically searches platform-specific system libraries. On Windows, it searches msvcrt.dll (Microsoft C Runtime), which ships with Windows. On Linux, it searches libc.so. On macOS, it searches libSystem.dylib. The FFM API abstracts away these platform differences – your Java code
Step 4: Describing the Function
FunctionDescriptor descriptor = FunctionDescriptor.of(
ValueLayout.JAVA_LONG, // return: size_t
ValueLayout.ADDRESS // param: const char*
);
We create a FunctionDescriptor (Concept 2) that matches the C signature. We’re telling Java: “This function returns a long (mapped from C’s size_t) and takes a pointer as a parameter (mapped from const char*).”
The MemoryLayout (Concept 6) types like JAVA_LONG and ADDRESS tell Java exactly how to interpret the raw bytes – their size, alignment, and meaning.
Step 5: Creating the Method Handle
MethodHandle strlen = linker.downcallHandle(strlenAddr, descriptor);
The Linker combines the function address and descriptor to create a MethodHandle (Concept 3) — our Java-callable reference to the native function. This is the bridge object that will actually invoke the C code.
Step 6: Calling It
try (Arena arena = Arena.ofConfined()) {
MemorySegment nativeStr = arena.allocateUtf8String(text);
long length = (long) strlen.invoke(nativeStr);
}
We use an Arena (Concept 4) to allocate a MemorySegment (Concept 5) for our string. The Arena automatically cleans up all allocated memory when we exit the try block — no manual free() needed!
The strlen.invoke(nativeStr) call is where the magic happens – Java calls the actual C strlen function using the MethodHandle.
Running the Code
javac --enable-preview --release 21 BasicDowncall.java
java --enable-preview --enable-native-access=ALL-UNNAMED BasicDowncall
Why does this work on a vanilla Windows installation?
The C standard library (msvcrt.dll) is part of Windows itself. The FFM API’s defaultLookup() automatically finds and loads it. You don’t need Visual Studio, MinGW, or any C compiler installed. The same code works on Linux and macOS using their respective system libraries – it’s completely platform-independent!
Output
=== Demo 1: Basic Downcall ===
Calling C's strlen() from Java
"Hello, World!"
C strlen(): 13 bytes
Java length(): 13 chars
"Java 21 FFM API"
C strlen(): 15 bytes
Java length(): 15 chars
"🚀 Unicode!"
C strlen(): 13 bytes
Java length(): 10 chars
(UTF-8 encoding uses multiple bytes for some characters)
Notice the emoji example! The rocket emoji (🚀) is 4 bytes in UTF-8, so strlen counts 13 bytes while Java’s length() counts 10 characters. This proves we’re really calling native C code and working with actual UTF-8 encoding.
Key Takeaways
✅ Pure Java — No C wrapper code needed
✅ Type-safe — The FunctionDescriptor ensures correct types
✅ Memory-safe — Arena handles cleanup automatically
✅ Simple — Just 6 clear steps to call any C function
✅ Platform-independent — Works on Windows, Linux, and macOS without any additional installation
✅ Zero dependencies — Uses system libraries that are already present on your OS
Demo 2: Callbacks – Native Code Calling Java
In the previous demo, Java called C. Now let’s reverse it: we’ll have C code call our Java methods. This is called an “upcall” or callback, and it demonstrates the bidirectional nature of FFM.
The Goal
Use C’s qsort() function to sort an array of integers. The twist? The comparison logic will be written in Java. So we’ll have:
- Downcall: Java →
qsort() - Upcall:
qsort()→ Java comparator
The C Function
Here’s what we’re calling:
void qsort(void *base, size_t nel, size_t width,
int (*compar)(const void *, const void *));
The compar parameter is a function pointer – a callback that qsort uses to compare elements.
Complete Code
package org.example.concepts.ffi;
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.util.Arrays;
@SuppressWarnings("preview")
public class CallbackUpcall {
// Our Java comparator - will be called by C code
private static int compare(MemorySegment a, MemorySegment b) {
int valueA = a.get(ValueLayout.JAVA_INT, 0);
int valueB = b.get(ValueLayout.JAVA_INT, 0);
return Integer.compare(valueA, valueB);
}
public static void main(String[] args) {
System.out.println("=== Demo 2: Callbacks (Upcalls) ===");
System.out.println("Native qsort() calling our Java comparator\n");
try {
// Step 1-3: Get linker, lookup stdlib, find qsort
Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();
MemorySegment qsortAddr = stdlib.find("qsort")
.orElseThrow(() -> new RuntimeException("qsort not found"));
// Step 4: Define qsort signature
// C: void qsort(void *base, size_t nel, size_t width, int (*compar)(...))
FunctionDescriptor qsortDesc = FunctionDescriptor.ofVoid(
ValueLayout.ADDRESS, // void *base
ValueLayout.JAVA_LONG, // size_t nel
ValueLayout.JAVA_LONG, // size_t width
ValueLayout.ADDRESS // comparator function pointer
);
// Step 5: Create downcall handle for qsort
MethodHandle qsort = linker.downcallHandle(qsortAddr, qsortDesc);
// Step 6: Create upcall stub for our Java comparator
MethodHandle compareHandle = MethodHandles.lookup()
.findStatic(CallbackUpcall.class, "compare",
MethodType.methodType(int.class,
MemorySegment.class, MemorySegment.class));
FunctionDescriptor compareDesc = FunctionDescriptor.of(
ValueLayout.JAVA_INT,
ValueLayout.ADDRESS.withTargetLayout(ValueLayout.JAVA_INT),
ValueLayout.ADDRESS.withTargetLayout(ValueLayout.JAVA_INT)
);
try (Arena arena = Arena.ofConfined()) {
// Create the upcall stub (native function that calls Java)
MemorySegment comparatorStub = linker.upcallStub(
compareHandle, compareDesc, arena);
// Step 7: Prepare data
int[] unsorted = {42, 7, 23, 91, 5, 68, 13, 99, 1, 56};
System.out.println("Unsorted: " + Arrays.toString(unsorted));
MemorySegment nativeArray = arena.allocateArray(
ValueLayout.JAVA_INT, unsorted);
// Step 8: Call qsort - it will call our Java comparator!
System.out.println("\nCalling qsort()...");
qsort.invoke(
nativeArray,
(long) unsorted.length,
(long) ValueLayout.JAVA_INT.byteSize(),
comparatorStub
);
// Step 9: Read sorted results
int[] sorted = nativeArray.toArray(ValueLayout.JAVA_INT);
System.out.println("Sorted: " + Arrays.toString(sorted));
System.out.println("\n✓ C code successfully called our Java comparator!");
}
} catch (Throwable e) {
System.err.println("Error: " + e.getMessage());
e.printStackTrace();
}
}
}
Breaking It Down
The Java Comparator
private static int compare(MemorySegment a, MemorySegment b) {
int valueA = a.get(ValueLayout.JAVA_INT, 0);
int valueB = b.get(ValueLayout.JAVA_INT, 0);
return Integer.compare(valueA, valueB);
}
This is just a normal Java method. It receives two MemorySegment (Concept 5) pointers (what C sees as void*), reads the integers using MemoryLayout (Concept 6) with ValueLayout.JAVA_INT, and compares them. Simple!
Steps 1–5: Setting Up the Downcall
Linker linker = Linker.nativeLinker(); // Concept 1
SymbolLookup stdlib = linker.defaultLookup();
MemorySegment qsortAddr = stdlib.find("qsort").orElseThrow();
FunctionDescriptor qsortDesc = FunctionDescriptor.ofVoid(...); // Concept 2
MethodHandle qsort = linker.downcallHandle(qsortAddr, qsortDesc); // Concept 3
Just like Demo 1, we use the Linker (Concept 1) to find qsort, create a FunctionDescriptor (Concept 2) for its signature, and get a MethodHandle (Concept 3) to call it. The key difference? The last parameter is ValueLayout.ADDRESS – a function pointer that we’ll provide!
Step 6: Creating the Upcall Stub (The Magic Part)
MethodHandle compareHandle = MethodHandles.lookup()
.findStatic(CallbackUpcall.class, "compare", ...);
FunctionDescriptor compareDesc = FunctionDescriptor.of(
ValueLayout.JAVA_INT,
ValueLayout.ADDRESS.withTargetLayout(ValueLayout.JAVA_INT),
ValueLayout.ADDRESS.withTargetLayout(ValueLayout.JAVA_INT)
);
MemorySegment comparatorStub = linker.upcallStub(
compareHandle, compareDesc, arena);
Here’s what’s happening:
- We get a MethodHandle (Concept 3) to our Java
comparemethod using reflection - We describe its signature with a FunctionDescriptor (Concept 2)
- We use the Linker (Concept 1) to create an upcall stub
What’s an upcall stub? It’s a native function pointer (stored in a MemorySegment — Concept 5) that, when called by C code, will execute our Java method. It’s a bridge in the opposite direction!
The stub is allocated in the Arena (Concept 4), so it will be automatically freed when the arena closes.
Steps 7–8: Preparing Data and Calling qsort
int[] unsorted = {42, 7, 23, 91, 5, 68, 13, 99, 1, 56};
MemorySegment nativeArray = arena.allocateArray(ValueLayout.JAVA_INT, unsorted);
qsort.invoke(
nativeArray, // pointer to array
(long) unsorted.length, // number of elements
(long) ValueLayout.JAVA_INT.byteSize(), // size of each element (4 bytes)
comparatorStub // our Java callback!
);
We allocate a MemorySegment (Concept 5) for the array using the Arena (Concept 4), and pass our comparatorStub to qsort.
As qsort runs, it repeatedly calls our Java compare method to determine the sort order. The sorting algorithm executes in C, but the comparison logic is pure Java!
Step 9: Reading Results
int[] sorted = nativeArray.toArray(ValueLayout.JAVA_INT);
The MemoryLayout (Concept 6) JAVA_INT tells Java how to interpret the sorted bytes back into a Java int[] array.
Running the Code
javac --enable-preview --release 21 CallbackUpcall.java
java --enable-preview --enable-native-access=ALL-UNNAMED CallbackUpcall
Output
=== Demo 2: Callbacks (Upcalls) ===
Native qsort() calling our Java comparator
Unsorted: [42, 7, 23, 91, 5, 68, 13, 99, 1, 56]
Calling qsort()...
Sorted: [1, 5, 7, 13, 23, 42, 56, 68, 91, 99]
✓ C code successfully called our Java comparator!
Perfect sorting! And the comparison logic was 100% Java.
Real-World Use Cases
The FFM API is new (preview in Java 21, final in Java 22), so production adoption is still emerging. Current real-world uses of callbacks include:
- Legacy system integration: Companies integrating decades-old C libraries without rewriting to JNI
- Performance-critical systems: Low-latency trading platforms calling native pricing engines with callback-based APIs
- Scientific computing: Research labs integrating specialized C/Fortran libraries that use callback patterns
The callback pattern will become more common as FFM adoption grows and developers move away from JNI for native integration.
Key Takeaways
✅ Bidirectional — Native code can call Java methods seamlessly
✅ Type-safe — FunctionDescriptor ensures correct callback signature
✅ Memory-safe — The upcall stub is managed by Arena and freed automatically
✅ Flexible — Any Java method can become a native callback
✅ Powerful — Enables deep integration with native libraries that expect callbacks
✅ Performance — The JVM optimizes these calls, making them nearly as fast as native function pointers
The beauty of this approach? You get to keep your business logic in Java (with all its safety, debugging tools, and ecosystem) while leveraging the performance and capabilities of native libraries!
Demo 3: Direct Memory Management
In the previous demos, we called native functions. Now let’s explore the other side of FFM: managing native memory directly. This is powerful when you need to work with large datasets, interface with native libraries that expect specific memory layouts, or optimize performance-critical code.
The Goal
Demonstrate how to safely allocate, access, and manage off-heap (native) memory:
- Working with primitive values
- Managing arrays in native memory
- Understanding different Arena types
- Leveraging built-in safety features
Why Native Memory?
Native memory lives outside the Java heap, which means:
- No GC pressure — Large data structures don’t trigger garbage collection
- Native library compatibility — Pass memory directly to C libraries without copying
- Predictable performance — No GC pauses in latency-sensitive code
- Memory mapping — Work with memory-mapped files efficiently
Complete Code
package org.example.concepts.ffi;
import java.lang.foreign.*;
import java.util.Arrays;
/**
* Demo 3: Direct Memory Management
*
* Shows how to work with native memory (off-heap):
* - Allocating and accessing primitives
* - Working with arrays
* - Arena lifecycle management
* - Memory safety features
*
* Requirements: Java 21+ with --enable-preview --enable-native-access=ALL-UNNAMED
*/
@SuppressWarnings("preview")
public class MemoryManagement {
public static void main(String[] args) {
System.out.println("=== Demo 3: Memory Management ===\n");
demoPrimitives();
demoArrays();
demoArenaTypes();
demoSafety();
}
// Example 1: Working with primitives
private static void demoPrimitives() {
System.out.println("1. Primitives in Native Memory");
try (Arena arena = Arena.ofConfined()) {
MemorySegment intMem = arena.allocate(ValueLayout.JAVA_INT);
MemorySegment longMem = arena.allocate(ValueLayout.JAVA_LONG);
// Write
intMem.set(ValueLayout.JAVA_INT, 0, 42);
longMem.set(ValueLayout.JAVA_LONG, 0, 9876543210L);
// Read
System.out.printf(" int: %d (at 0x%x)%n",
intMem.get(ValueLayout.JAVA_INT, 0),
intMem.address());
System.out.printf(" long: %d (at 0x%x)%n",
longMem.get(ValueLayout.JAVA_LONG, 0),
longMem.address());
}
System.out.println(" ✓ Memory auto-freed\n");
}
// Example 2: Working with arrays
private static void demoArrays() {
System.out.println("2. Arrays in Native Memory");
try (Arena arena = Arena.ofConfined()) {
// Allocate array
MemorySegment array = arena.allocateArray(ValueLayout.JAVA_INT, 5);
// Write using setAtIndex
for (int i = 0; i < 5; i++) {
array.setAtIndex(ValueLayout.JAVA_INT, i, i * i);
}
// Read back
int[] values = array.toArray(ValueLayout.JAVA_INT);
System.out.println(" Array: " + Arrays.toString(values));
// Modify
array.setAtIndex(ValueLayout.JAVA_INT, 0, 999);
System.out.printf(" Modified: array[0] = %d%n",
array.getAtIndex(ValueLayout.JAVA_INT, 0));
}
System.out.println(" ✓ Memory auto-freed\n");
}
// Example 3: Arena types
private static void demoArenaTypes() {
System.out.println("3. Arena Types");
// Confined: single-threaded, best performance
try (Arena confined = Arena.ofConfined()) {
MemorySegment mem = confined.allocate(ValueLayout.JAVA_INT);
mem.set(ValueLayout.JAVA_INT, 0, 123);
System.out.println(" Confined: " + mem.get(ValueLayout.JAVA_INT, 0));
}
// Shared: multi-threaded
try (Arena shared = Arena.ofShared()) {
MemorySegment mem = shared.allocate(ValueLayout.JAVA_INT);
mem.set(ValueLayout.JAVA_INT, 0, 456);
System.out.println(" Shared: " + mem.get(ValueLayout.JAVA_INT, 0));
}
// Auto: GC-managed
MemorySegment autoMem = Arena.ofAuto().allocate(ValueLayout.JAVA_INT);
autoMem.set(ValueLayout.JAVA_INT, 0, 789);
System.out.println(" Auto: " + autoMem.get(ValueLayout.JAVA_INT, 0));
System.out.println(" (Auto memory freed by GC later)\n");
}
// Example 4: Safety features
private static void demoSafety() {
System.out.println("4. Memory Safety");
// Bounds checking
try (Arena arena = Arena.ofConfined()) {
MemorySegment mem = arena.allocate(ValueLayout.JAVA_INT); // 4 bytes
mem.set(ValueLayout.JAVA_INT, 0, 42); // OK
System.out.println(" ✓ In-bounds write: OK");
try {
mem.set(ValueLayout.JAVA_INT, 4, 99); // Out of bounds!
} catch (IndexOutOfBoundsException e) {
System.out.println(" ✓ Out-of-bounds prevented");
}
}
// Use-after-free prevention
MemorySegment freed;
try (Arena arena = Arena.ofConfined()) {
freed = arena.allocate(ValueLayout.JAVA_INT);
} // Memory freed here
try {
freed.get(ValueLayout.JAVA_INT, 0); // Try to use freed memory
} catch (IllegalStateException e) {
System.out.println(" ✓ Use-after-free prevented");
}
System.out.println("\n✓ Much safer than raw C pointers!");
}
}
Breaking It Down
Example 1: Working with Primitives
try (Arena arena = Arena.ofConfined()) {
MemorySegment intMem = arena.allocate(ValueLayout.JAVA_INT);
intMem.set(ValueLayout.JAVA_INT, 0, 42);
int value = intMem.get(ValueLayout.JAVA_INT, 0);
}
The Arena (Concept 4) allocates a MemorySegment (Concept 5) sized for an integer (4 bytes). The MemoryLayout (Concept 6) JAVA_INT tells the segment how to interpret those bytes. When the arena closes, memory is freed automatically.
Note the .address() output – this is the actual native memory address, just like a C pointer!
Example 2: Working with Arrays
MemorySegment array = arena.allocateArray(ValueLayout.JAVA_INT, 5);
for (int i = 0; i < 5; i++) {
array.setAtIndex(ValueLayout.JAVA_INT, i, i * i);
}
int[] values = array.toArray(ValueLayout.JAVA_INT);
Allocating arrays is straightforward. The setAtIndex and getAtIndex methods provide convenient indexed access. You can also convert between native arrays and Java arrays with toArray().
Performance note: Native arrays bypass the JVM heap, which is ideal for large datasets (millions of elements) where GC overhead matters.
Example 3: Arena Types
FFM provides three types of arenas, each with different trade-offs:
Confined Arena
Arena.ofConfined()
- Thread-safe? No — single thread only
- Performance: Fastest (no synchronization overhead)
- Use when: You’re doing memory operations on one thread
- Most common choice
Shared Arena
Arena.ofShared()
- Thread-safe? Yes — multiple threads can access
- Performance: Slower (requires synchronization)
- Use when: Multiple threads need to access the same memory
- Example: Shared buffer between producer/consumer threads
Auto Arena
Arena.ofAuto()
- Thread-safe? Yes
- Performance: Slower (GC-managed)
- Lifecycle: Freed by garbage collector (not deterministic)
- Use when: You can’t use try-with-resources and don’t want to manage lifecycle manually
- Caveat: Loses the deterministic cleanup benefit
Best practice: Always prefer ofConfined() unless you have a specific reason to use the others.
Example 4: Safety Features
FFM provides critical safety guarantees that raw C pointers don’t:
Bounds Checking
MemorySegment mem = arena.allocate(ValueLayout.JAVA_INT); // 4 bytes
mem.set(ValueLayout.JAVA_INT, 4, 99); // IndexOutOfBoundsException!
Try to access memory outside the segment’s bounds? You get a clean Java exception, not a segmentation fault.
Use-After-Free Prevention
MemorySegment freed;
try (Arena arena = Arena.ofConfined()) {
freed = arena.allocate(ValueLayout.JAVA_INT);
} // Arena closed, memory freed
freed.get(ValueLayout.JAVA_INT, 0); // IllegalStateException!
Try to access freed memory? Java catches it and throws an exception. In C, this would be undefined behavior (often a crash or silent corruption).
These safety features make FFM dramatically safer than raw C memory management while maintaining similar performance.
Running the Code
javac --enable-preview --release 21 MemoryManagement.java
java --enable-preview --enable-native-access=ALL-UNNAMED MemoryManagement
Output
=== Demo 3: Memory Management ===
1. Primitives in Native Memory
int: 42 (at 0x7f8a4c000b70)
long: 9876543210 (at 0x7f8a4c000b80)
✓ Memory auto-freed
2. Arrays in Native Memory
Array: [0, 1, 4, 9, 16]
Modified: array[0] = 999
✓ Memory auto-freed
3. Arena Types
Confined: 123
Shared: 456
Auto: 789
(Auto memory freed by GC later)
4. Memory Safety
✓ In-bounds write: OK
✓ Out-of-bounds prevented
✓ Use-after-free prevented
✓ Much safer than raw C pointers!
Real-World Use Cases
FFM’s memory management is very new (final in Java 22). Current practical uses are limited:
- Latency-sensitive systems: Financial and trading systems experimenting with off-heap allocation to eliminate GC pauses in critical paths
- Native library integration: Projects that need to pass large data structures to C libraries (structs, buffers) without copying between heap and native memory
- Research and prototyping: Teams evaluating FFM as a modern replacement for
sun.misc.Unsafein custom memory-intensive applications
The reality: Most production systems still use existing solutions (Chronicle, Aeron for off-heap, LWJGL for native graphics). FFM adoption is growing but not yet widespread. The API’s main value is providing a safe, supported alternative to deprecated Unsafe APIs.
Key Takeaways
✅ Off-heap memory — Allocate memory outside the Java heap for GC-free performance
✅ Automatic cleanup — Arena manages lifecycle, no manual free() calls
✅ Type-safe access – MemoryLayout ensures correct interpretation of bytes
✅ Bounds checking – Prevents buffer overflows and out-of-bounds access
✅ Use-after-free protection – Catches dangling pointer access
✅ Flexible arenas – Choose the right arena type for your threading model
✅ C-like performance with Java-like safety – Best of both worlds
Native memory management with FFM gives you the performance of C with the safety of Java!