Allocating 5 bytes of native memory in an Arena.ofConfined() drops from 15.9 to 1.05 nanoseconds on an Apple M4. That is the headline number behind pooled confined arenas, an optimization coming in JDK 28 that, according to Oracle's Java team, cuts the latency of small off-heap allocations by up to 90% on Linux, macOS and Windows. There is no new API and nothing to rewrite: code that already uses the Foreign Function & Memory (FFM) API simply gets faster on upgrade.
Why a 5-byte allocation used to be expensive
The most common FFM pattern is to open a confined arena around a native call, allocate room for the result (an int, a pointer, a short string) and close it when the try block ends. Until now even that tiny allocation went through the native allocator and registered a cleanup action, and that bookkeeping could cost more than actually using the memory.
The real-world distribution is what justifies the change: in one instrumented test-suite run, more than 99.99% of confined arenas that allocated memory used less than 64 bytes. With that shape, a small, lazy pool covers almost everything.
How the pool works
Each platform thread lazily keeps a cache of native-memory pools: four pools of 64 bytes by default. An arena does not grab a pool when it is created, only on its first allocation that fits, so arenas that never allocate cost nothing. From then on, allocations are carved sequentially from the pool, which belongs exclusively to that arena; nested arenas stay isolated from each other.
Anything that does not fit, or needs an alignment the pool cannot guarantee, takes the regular path. When the arena closes, the used part is zeroed and the pool goes back to the thread cache, or is freed if the cache is full. According to the pull request that merged it into OpenJDK mainline on September 3, 2026, the sizing can be tuned through an internal, unsupported system property (up to eight pools per thread, 8 bytes to 1 MiB, and a negative value turns pooling off). Being internal, it can change or disappear without notice.
For virtual threads, the design avoids a cache per thread, which would wipe out their tiny footprint. A virtual thread borrows a pool from its carrier, is pinned only during that handoff, and can then migrate while the arena stays open; on close, the pool returns to whichever carrier it is mounted on at that point.
The numbers, before and after
JMH measurements for 5- and 20-byte allocations:
| Platform | 5 bytes | 20 bytes |
|---|---|---|
| Linux AArch64 | 12.3x | 8.6x |
| Linux x64 | 6.8x | 7.8x |
| macOS AArch64 | 18.6x | 16.8x |
| Windows x64 | 16.8x | 18.9x |
On the M4 with virtual threads, the 5-byte allocation goes from 15.7 to 3.2 ns: a smaller gain because of the carrier handling, but still almost five times faster. Above 64 bytes nothing changes: at 100 bytes both variants sit around 19 ns because they use the regular path.
What matters for production is where this adds up. It shows in FFM bindings that open an arena per call, in jextract-style generated wrappers and in latency-sensitive code making lots of cheap native calls. If your service makes one native call per request, the gain is real but will not move your p99. It only applies to confined arenas: the other arena types are unchanged.
Until now, anyone who needed to get rid of this cost had to hand-roll reuse of a long-lived arena or segment and slice it up, giving away part of the guarantee that everything disappears when the try block ends. With JDK 28 the simple, safe pattern also becomes the fast one.
The behavior change that does exist
Segment lifetime is unchanged: once the arena closes they are no longer accessible, and fresh memory is zeroed, as the API requires. What changes is the lifetime of the underlying native allocation: closing an arena may hand the memory back to the JDK cache instead of calling free. If you have tools or tests that count malloc and free calls to catch leaks in FFM code, they will no longer match arena allocations one to one.
How to measure it in your own code
JDK 28 is scheduled for general availability on March 23, 2027; until then you can try it with the early-access builds at jdk.java.net/28. A minimal benchmark to compare against your current JDK:
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class ConfinedAlloc {
@Param({"8", "32", "128"})
long size;
@Benchmark
public long allocateAndClose() {
try (Arena arena = Arena.ofConfined()) {
MemorySegment segment = arena.allocate(size);
return segment.byteSize();
}
}
}Run it with your production JDK and with the JDK 28 build on the same machine. Before counting on the gain:
- Measure your real code path, not just the microbenchmark: the win depends on how much of the native call was spent allocating and freeing.
- Check whether any native-memory test or profiler assumes every
allocateends in amalloc. - Find out how many of your bindings' allocations exceed 64 bytes: those will behave exactly as before.
Sources
- Allocating Off-Heap Memory: Typically 6-18x Faster in JDK 28 — Inside Java
- 8385697: Add a Pooled Confined Arena — OpenJDK (GitHub)
- JDK Releases — java.com
- JDK 28 Early-Access Builds — OpenJDK