OutOfMemoryError: Direct buffer memory and MaxDirectMemorySize

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:
- A process started with
-Xmx4gand no explicit limit may allocate around 4 GB of direct buffers in addition to its 4 GB heap. - Changing
-Xmxsilently changes the direct memory limit too. Shrinking the heap to save memory can turn a working service into one that fails on its first traffic peak.
# 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:
-XX:+DisableExplicitGCremoves the safety net. The launcher reference says that with this option, calls toSystem.gc()are not processed. Services that allocate direct buffers and set this flag are more likely to hit the error. If you need the flag, consider-XX:+ExplicitGCInvokesConcurrentwith G1 instead, which keeps the call but runs it as a concurrent cycle.- A genuine leak still fails. If the code keeps references to buffers (in a cache, a queue, a map keyed by connection), no collection can free them, and the error is real.
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
- Read the limit in the error message (recent JDKs) or compute it from
-XmxifMaxDirectMemorySizeis unset. - Check for
-XX:+DisableExplicitGC. - Graph the
directbuffer pool: leak, peak or undersized limit? - Take a heap dump and find what retains
DirectByteBufferobjects. - Replace per-request allocation with reuse; set an explicit limit inside a full memory budget.
Sources
- The java command, JDK 21 reference (MaxDirectMemorySize, DisableExplicitGC)
- ByteBuffer javadoc, section on direct buffers
- OpenJDK source: java.nio.Bits and jdk.internal.misc.VM
- JEP 454: Foreign Function & Memory API
MemoryNative Memory Tracking
Heap is only part of the process: read the NMT summary, diff against a baseline, and know what it cannot see.
ProductionThe JVM in containers
How limits are detected, what MaxRAMPercentage really sets, and a budget that keeps the OOM killer away.
MemoryTaking and reading a heap dump
Capture safely with jcmd, dump automatically on OOM, then read the dominator tree and paths to GC roots.