Technical Deep Dive

Why Off-Heap? Understanding Java's Memory Model and the FFM API

How the Mapish project stores millions of map entries with zero GC pressure—and why Java’s Foreign Function & Memory API makes it practical.

· 12 min read

1. The GC Problem

Java’s garbage collector is one of the language’s greatest strengths—until it isn’t. Modern collectors like G1, ZGC, and Shenandoah have pushed pause times impressively low, but every one of them must still find live objects before it can reclaim dead ones. The more live objects on the heap, the more work that scan requires.

Now picture a HashMap<String, String> holding one million entries. How many Java objects does that create?

That’s easily 4 million+ live objects—all of which the GC must traverse on every collection cycle. Even with concurrent collectors, tail latency grows. A G1 mixed-GC pause that normally takes 10 ms might spike to 50–200 ms when your live set is measured in gigabytes. ZGC keeps pauses short, but its concurrent mark phase still consumes CPU cycles proportional to the live object graph.

For latency-sensitive systems—high-frequency trading, real-time ad bidding, game servers—those spikes are unacceptable. You can’t serve a market order 80 ms late, and you can’t stutter a multiplayer game loop because the GC decided now was a good time to clean house.

Key insight: The problem isn’t that GC is slow. The problem is that GC cost scales with live object count, and large in-memory data structures create an enormous live set by definition.

Here’s the familiar culprit:

// Innocent looking, but creates ~4M+ heap objects
Map<String, String> cache = new HashMap<>(1_000_000);

for (int i = 0; i < 1_000_000; i++) {
    cache.put("key-" + i, "value-" + i);
}
// Every one of those keys, values, and Node wrappers
// is now a live object the GC must scan.

2. The Off-Heap Solution

The idea is disarmingly simple: store your data outside the Java heap. Native memory—allocated directly from the operating system—is invisible to the garbage collector. It doesn’t get scanned, doesn’t get compacted, and doesn’t contribute to pause times.

Think of native memory as a warehouse floor. The JVM heap is a managed storage unit—the facility tracks every box, rearranges them periodically, and bills you for the service. Off-heap memory is the raw warehouse floor next door: you get unlimited space, but you decide where things go, and you are responsible for cleaning up.

This trade-off is the crux of off-heap design. You gain predictable latency and freedom from GC pressure, but you accept responsibility for memory lifecycle management. Forget to release a segment and you have a native memory leak—one that won’t show up in your heap dumps.

Historical approaches

Java developers have been reaching for off-heap memory for years, but the available tools were crude:

Both approaches worked, but they forced developers to choose between “limited and awkward” or “powerful and dangerous.” The JDK needed a third option.

3. Enter Java’s FFM API (JEP 454)

The Foreign Function & Memory API was finalized in Java 22 after previewing since Java 19. It provides a safe, supported, performant API for working with native memory. No --add-opens hacks, no unsupported internals—just a first-class JDK citizen.

Three classes form the core of the API:

Arena

A lifecycle scope that owns one or more memory allocations. When the arena closes, all its segments are freed. Choose ofConfined() for deterministic release, ofShared() for multi-threaded access, or ofAuto() to let the GC handle cleanup (useful for long-lived caches).

MemorySegment

A contiguous region of native memory with bounds checking. Every read and write is validated against the segment’s size. No silent buffer overflows, no segfaults—you get a clean IndexOutOfBoundsException instead.

ValueLayout

Typed access descriptors—JAVA_INT, JAVA_LONG, JAVA_BYTE, and more. They tell the segment how to interpret the raw bytes at a given offset.

Here’s the API in action:

try (Arena arena = Arena.ofConfined()) {
    // Allocate 1 KB of native memory, owned by this arena
    MemorySegment segment = arena.allocate(1024);

    // Write an int at byte offset 0
    segment.set(ValueLayout.JAVA_INT, 0, 42);

    // Read it back
    int value = segment.get(ValueLayout.JAVA_INT, 0); // 42

} // Arena closes → segment is immediately freed

That try block isn’t decorative. When the arena goes out of scope, the native memory is unmapped and returned to the OS immediately. No finalizer delays, no phantom reference queues—deterministic deallocation.

4. Why FFM Beats Unsafe

If you’ve worked with sun.misc.Unsafe, FFM will feel familiar—but with guardrails.

sun.misc.Unsafe FFM API
Bounds checking None — segfault on overrun Automatic
Lifecycle management Manual freeMemory() Arena scoping
Supported API Internal, may be removed Public, standardized
JVM flags required --add-opens None
Performance Fast Comparable

The performance story deserves emphasis. Early preview builds of FFM had measurable overhead from bounds checks, but the JIT compiler in modern JDK releases eliminates most of it. In our JMH benchmarks, FFM-based reads and writes perform within noise of raw Unsafe operations—with the safety net included.

5. How Mapish Uses FFM

With the theory covered, let’s look at how the Off-Heap Mapish project puts FFM to work. The design goal is simple: implement java.util.Map with zero GC-visible objects for the stored data.

One giant segment

Instead of allocating an object per entry, Mapish allocates a single contiguous MemorySegment sized to hold the entire hash table:

// From OffHeapMap.java — the entire table is one allocation
this.memorySegment = arena.allocate((long) this.capacity * entrySize);

That single call replaces what would be millions of new Node<>() allocations in a standard HashMap.

Entry layout

Each entry occupies a fixed-size slot in the segment. The layout is calculated once at construction time:

Status
1 byte
Pad
3 bytes
Hash
4 bytes
Key bytes
serialized
Value bytes
serialized
// Status flags — stored as a single byte per slot
private static final byte EMPTY   = 0;
private static final byte FILLED  = 1;
private static final byte DELETED = 2;

// Offset calculations — computed once in the constructor
this.hashOffset  = 4;                                  // after 1-byte status + 3-byte pad
this.keyOffset   = 8;                                  // after the 4-byte hash
this.valueOffset = this.keyOffset + align(keySize, 8); // key size, 8-byte aligned
this.entrySize   = this.valueOffset + align(valueSize, 8);

Reads and writes use ValueLayout accessors on the single segment, jumping to the correct offset with simple arithmetic:

long offset = ((long) index) * entrySize;

// Read the status byte
byte status = memorySegment.get(ValueLayout.JAVA_BYTE, offset);

// Read the cached hash (fast rejection on mismatch)
int storedHash = memorySegment.get(ValueLayout.JAVA_INT, offset + hashOffset);

// Serialize the key directly into the segment
keySerializer.serialize(key, memorySegment, offset + keyOffset);

The Serializer interface

Because the segment stores raw bytes, Mapish needs a way to convert Java objects to and from binary. That’s the job of the Serializer<T> interface:

public interface Serializer<T> {
    void serialize(T obj, MemorySegment segment, long offset);
    T deserialize(MemorySegment segment, long offset);
    long sizeBytes();

    // Compare a Java object against raw bytes — no deserialization needed
    boolean equals(T obj, MemorySegment segment, long offset);
}

That equals() method is the performance secret sauce. During a get() or containsKey() call, the map probes through slots comparing the search key against stored bytes without ever deserializing them. The StringSerializer takes this further by using MemorySegment.mismatch()—a method that maps down to hardware-accelerated vector comparison:

// From StringSerializer.java — zero-allocation equality check
@Override
public boolean equals(String obj, MemorySegment segment, long offset) {
    int length = segment.get(ValueLayout.JAVA_INT, offset);
    byte[] bytes = obj.getBytes(StandardCharsets.UTF_8);
    if (length != bytes.length) return false;
    if (length == 0) return true;

    MemorySegment objSegment = MemorySegment.ofArray(bytes);
    return MemorySegment.mismatch(
        segment, offset + 4, offset + 4 + length,
        objSegment, 0, length
    ) == -1;
}

Arena lifecycle

Mapish gives you a choice. The default constructor uses Arena.ofAuto()—the GC will eventually reclaim the native memory when the map becomes unreachable. For strict deterministic release, use the confined factory:

// GC-managed — behaves like a normal Java object
OffHeapMap<String, String> auto = new OffHeapMap<>(
    new StringSerializer(100), new StringSerializer(100), 2_000_000
);

// Deterministic — memory freed on close()
try (OffHeapMap<String, String> confined = OffHeapMap.createConfined(
        new StringSerializer(100), new StringSerializer(100), 2_000_000)) {
    confined.put("key", "value");
    // ...
} // Native memory immediately unmapped and returned to the OS

Open addressing, not separate chaining

Standard HashMap resolves collisions with linked lists (and tree nodes at high load). Each link is another heap object, another pointer the GC must chase. Mapish uses open addressing with linear probing—colliding entries simply slide into the next available slot in the same contiguous segment. No extra allocations, and scanning adjacent memory slots is exactly what modern CPUs are optimized for (cache-line prefetching).

6. The Payoff

All of this engineering serves one outcome: predictable, low-latency map operations regardless of data size.

~0 B/op

GC allocation rate on lookups
(JMH gc profiler)

No pauses

Stored data is invisible
to the garbage collector

Full Map API

Implements java.util.Map
verified by Guava testlib

Because the entire hash table lives in a single MemorySegment, a Mapish instance holding two million entries contributes exactly one object to the GC’s live set (the OffHeapMap instance itself). Compare that to the 4–8 million objects a standard HashMap would create.

The best part? You don’t need to learn a new API. Mapish implements the full java.util.Map contract— put(), get(), entrySet(), remove(), iterators, all of it. Drop it into existing code, swap your serializers in, and the GC pressure vanishes.

// Swap in, enjoy the silence from the GC
Map<String, String> map = new OffHeapMap<>(
    new StringSerializer(100),
    new StringSerializer(100),
    2_000_000
);

map.put("ticker", "AAPL");
String value = map.get("ticker"); // "AAPL" — zero GC overhead

for (Map.Entry<String, String> entry : map.entrySet()) {
    // Standard iteration — works exactly as you'd expect
}

Where to Go from Here

If your application keeps large reference data in memory—caches, lookup tables, session stores—and you’re fighting GC pauses, off-heap storage with the FFM API is worth exploring. The API is stable, the performance is proven, and projects like Mapish show that you don’t have to sacrifice developer ergonomics to get there.

Mapish is an open-source, zero-dependency off-heap hash map for Java 22+. It fully implements java.util.Map, ships built-in serializers for all primitive wrapper types and String, and is verified against hundreds of contract tests via Google’s guava-testlib.