Memory

OutOfMemoryError: Direct buffer memory and MaxDirectMemorySize

Memory · Published 2026-10-20

Several laptop memory modules with black chips and gold contacts lying on a light surface
Direct buffers live outside the Java heap, in ordinary process memory. Photo: dumanag, WordPress Photo Directory, CC0.

A service with plenty of free heap suddenly throws java.lang.OutOfMemoryError with a message about direct buffer memory. The heap is not the problem: the JVM has run out of a separate budget for NIO buffers that live outside it. This guide explains where that budget comes from and how to work with it.

What a direct buffer is

A java.nio.ByteBuffer can be heap or direct. A heap buffer wraps an ordinary byte[] inside the Java heap. A direct buffer, created with ByteBuffer.allocateDirect(capacity), is backed by native memory allocated outside the heap. The ByteBuffer javadoc explains the trade-off: the JVM makes a best effort to perform native I/O directly on such buffers, avoiding a copy, but they typically have higher allocation and deallocation costs and their contents are not subject to normal garbage-collected heap management.

That is why network stacks and I/O libraries use them heavily: socket channels, file channels and many networking frameworks keep pools of direct buffers so that data can travel between the kernel and the application without an extra copy through the heap.

The limit and its default

The JVM caps the total size of direct buffers allocated through ByteBuffer.allocateDirect. The cap is set with -XX:MaxDirectMemorySize. The java launcher reference describes it as the maximum total size of java.nio direct-buffer allocations and says that, if it is not set, the JVM chooses the size automatically.

What "automatically" means is visible in the OpenJDK source. In jdk.internal.misc.VM, when the option is absent the limit is set to Runtime.getRuntime().maxMemory(), that is, roughly the maximum heap size. Two consequences follow:

# fix the direct buffer budget independently of the heap
$ java -Xmx4g -XX:MaxDirectMemorySize=1g -jar service.jar

What the error looks like

The error is thrown from java.nio.Bits.reserveMemory, which every allocateDirect call goes through. On older releases such as JDK 8 the message is simply:

java.lang.OutOfMemoryError: Direct buffer memory
    at java.nio.Bits.reserveMemory(Bits.java:...)
    at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:...)
    at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:...)

In recent JDKs (the JDK 21 source for example) the message is more useful because it states the request, the amount already reserved and the limit:

java.lang.OutOfMemoryError: Cannot reserve 1048576 bytes of direct buffer memory (allocated: ..., limit: ...)

If the limit in that message is not what you expected, look at your flags first: the most common surprise is a limit derived from a small -Xmx.

Why it happens even though memory is "free"

Native memory behind a direct buffer is released when the small DirectByteBuffer object on the heap becomes unreachable and is processed by the garbage collector, which runs a cleaner that frees the native block. That design creates a gap: the heap side of a direct buffer is tiny, so the heap can stay almost empty for a long time, no collection happens, and the large native blocks behind dead buffers are never freed.

The JDK tries to close that gap. When a reservation fails, Bits.reserveMemory first waits for pending reference processing, then calls System.gc() and retries several times with short, increasing sleeps before giving up. Two practical points follow from that code:

Measure before you resize

The JDK exposes direct buffer usage through BufferPoolMXBean. There are normally two pools, direct and mapped, each reporting a count, the memory used and the total capacity. Memory-mapped files (FileChannel.map) are reported in the mapped pool and are not limited by MaxDirectMemorySize.

import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;

for (BufferPoolMXBean pool :
        ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) {
    System.out.printf("%s count=%d used=%d capacity=%d%n",
        pool.getName(), pool.getCount(),
        pool.getMemoryUsed(), pool.getTotalCapacity());
}

Export these values to your metrics system and watch the trend. A sawtooth that returns to a stable floor is normal pooling behaviour. A floor that keeps rising across hours is a leak. A flat line close to the limit means the limit is simply too small for the workload.

The same MBeans are visible in JDK Mission Control and any JMX console under java.nio:type=BufferPool,name=direct. For the whole native picture, including memory that is not a direct buffer at all, enable Native Memory Tracking.

Finding who holds the buffers

Because each direct buffer has a heap object, a heap dump can tell you who holds them. Take one with jcmd <pid> GC.heap_dump /tmp/heap.hprof, open it in Eclipse Memory Analyzer and look for java.nio.DirectByteBuffer instances. Their capacity field gives the native size; the path to GC roots view shows which object keeps each one alive. The heap dump guide covers the workflow.

Be careful with one detail: a buffer created by slicing or duplicating another buffer shares its parent's memory, so do not simply add up the capacity of every instance.

Fixes that work

Allocate once, reuse often

Allocating a direct buffer per request is the classic cause. The allocation is expensive, and release depends on GC timing. Allocate buffers at startup or from a pool and reuse them. Many I/O frameworks already do this; check that you are not bypassing their pool with your own allocateDirect calls.

Size the limit explicitly

Set -XX:MaxDirectMemorySize deliberately instead of inheriting it from the heap size, and include it in your memory budget. In a container, the heap, direct buffers, Metaspace, code cache, thread stacks and the JVM's own structures all share the same memory limit; see the container guide.

Release memory deterministically

Since JDK 22 the Foreign Function & Memory API (JEP 454) is final. Memory allocated from an Arena is freed when the arena is closed, independently of GC timing:

try (Arena arena = Arena.ofConfined()) {
    MemorySegment seg = arena.allocate(1 << 20);   // 1 MiB off-heap
    ByteBuffer view = seg.asByteBuffer();
    // ... use view for I/O ...
}   // memory released here

Check frameworks with their own accounting

Some networking libraries allocate native memory through their own code path and enforce their own limit, separate from the JVM's. If the error comes from inside such a library, read its documentation for the property that controls that limit before changing JVM flags.

Checklist

Sources