Executive Summary

I just read an Inside Java post about a performance change in JDK 28 for the Foreign Function & Memory (FFM) API: off-heap memory allocation via confined arenas is now pooled at the native level, making small allocations 6-18x faster without developers needing to change a single line of code. What I find interesting is that this is a purely under-the-hood optimization — the lifetime contract, zero-initialization, and thread-confinement of Arena.ofConfined() remain exactly the same as before.

Problem Being Solved

The most common pattern is using a confined arena as scratch space around a single native call, for example to hold an errno result or just a single pointer/int/long. Before JDK 28, even an allocation that small still went through the full native allocator path plus cleanup-action registration, and according to the article, this bookkeeping overhead was sometimes more expensive than accessing the memory itself. Instrumentation data from the OpenJDK test suite gathered during the development of this feature showed that more than 99.99% of confined arenas that actually allocate memory use less than 64 bytes — so this "tiny allocation, overhead-dominated" case isn't rare, it's the majority.

Key Feature and Code Example

The mechanism: each platform thread lazily maintains a small cache containing native-memory pools, defaulting to 4 pools of 64 bytes each. A new arena doesn't grab a pool as soon as it's created — a pool is only acquired when the arena receives its first allocation that can actually be served from a pool. Once acquired, that pool becomes the exclusive property of that arena, and subsequent allocations are carved sequentially from the pool's remaining capacity. If the size doesn't fit or requires alignment the pool can't guarantee, the allocation automatically falls back to the regular native allocator path — so this is opportunistic behavior, not a replacement for the old path.

Common pattern highlighted in the article (this pattern is now automatically faster without needing any changes):

try (Arena arena = Arena.ofConfined()) {
    MemorySegment result = arena.allocate(ValueLayout.JAVA_INT);
    nativeFunction.invokeExact(result); // menulis hasil ke segment yang disediakan
    return result.get(ValueLayout.JAVA_INT, 0);
}

When the arena closes, the used portion of the pool is zeroed and then returned to that thread's cache, or released to the native allocator if the cache is already full (4 pools). For virtual threads, the design is different: adding a native-memory cache to every virtual thread would ruin its lightweight footprint, so a virtual thread instead "borrows" a pool from its carrier thread on the first allocation. There's brief pinning during that handoff process, but once the pool's ownership transfers to the arena, the virtual thread can still safely migrate between carriers as long as the arena remains open. When that arena is closed, the pool is returned to the cache of whichever carrier happens to be in use at that moment.

All the old safety guarantees are preserved: as soon as the arena closes, its scope immediately becomes not-alive and its segment immediately becomes inaccessible. Before a pool is reused by another arena, its contents are zeroed first — so there's no data leakage between arenas through the same pool.

Impact and Upgrade Notes

The performance numbers are quite significant. For 5-byte and 20-byte allocations, the reported nominal speedup (JMH average-time baseline divided by the pooled-version latency): Linux AArch64 12.3x/8.6x, Linux x64 6.8x/7.8x, macOS AArch64 18.6x/16.8x, and Windows x64 16.8x/18.9x. In additional benchmarks on Apple M4, a 5-byte confined allocation dropped from about 15.9 ns/op (without pooling) to just 1.05 ns/op (with pooling). But once the allocation size exceeds 64 bytes (the default pool capacity), the numbers revert to the regular path and there's no more speedup — so this optimization is specific to small allocations. Virtual threads also benefit, and even with the extra overhead from carrier handoff, the speedup remains significant compared to non-pooled.

One thing worth noting for anyone maintaining diagnostic tooling: closing a pooled confined arena may now simply return memory to the JDK's cache rather than directly calling native free. So the 1:1 assumption between arena allocations and native-level malloc/free calls no longer holds — if any instrumentation or memory profiler relies on that assumption, it needs to be adjusted.

The biggest beneficiaries: FFM bindings that create a confined arena per native call, wrappers generated by jextract, native handle/pointer out-parameters, short strings or byte arrays sent to native code, and latency-sensitive code that calls small native functions very frequently. It's also worth remembering this only applies to confined arenas (Arena.ofConfined()) — shared, global, or auto arenas don't get this optimization. For further technical detail, the article points to JDK-8385697, OpenJDK PR #31365, and CSR JDK-8390770.

---

First announced on Inside Java. Read the original source for full details.