Jorge Turbica / blog · Blog ← All articles ES · Leer en español
Languages

JDK 28 allocates off-heap memory up to 18x faster, with no code changes

JDK 28's confined arenas serve small native allocations from a per-thread pool: 15.9 ns down to 1.05 ns, with no FFM API changes.

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:

Platform5 bytes20 bytes
Linux AArch6412.3x8.6x
Linux x646.8x7.8x
macOS AArch6418.6x16.8x
Windows x6416.8x18.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:

java
@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 allocate ends in a malloc.
  • Find out how many of your bindings' allocations exceed 64 bytes: those will behave exactly as before.

Sources

Jorge Turbica

Software Engineer and SRE. I write about the real challenges I run into in production.

See portfolio ↗