Quick fix
# Stop the bleeding: separate heap and direct memory, give the container room
java -XX:MaxDirectMemorySize=1000m -XX:+UseG1GC -Xms1000m -Xmx1000m -jar gateway.jar
# K8s Deployment memory limit 1500Mi -> 2500Mi
# Root fix: cap body caching (skip cache above the limit, log a warn), add release guarantees
# - Filter: MAX_CACHE_BODY_SIZE = 1MB, skip body caching above it
# - readBody: add onErrorResume / doFinally to the flux chain
# - AccessLogFilter: cap aggregation size too
What you will run into
A Spring Cloud Gateway (reactor-netty) style gateway suddenly returns occasional 500s; the logs show:
io.netty.util.internal.OutOfDirectMemoryError: failed to allocate 16777216 byte(s) of direct memory (used: 1006632967, max: 1013645312)
The frequency climbs, the service becomes unavailable, health checks fail. Heap (-Xmx) looks fine and GC logs look normal - but direct memory is nearly exhausted (used 960MB / max 967MB).
Why this happens
Netty's ByteBuf defaults to PooledByteBufAllocator, i.e. off-heap direct memory. Direct memory is not managed by GC - allocation/release is your responsibility; leaked ByteBufs are never reclaimed and grow until the cap. Three compounding factors:
| Factor | Mechanism |
|---|---|
-XX:MaxDirectMemorySize not set explicitly | effective default = -Xmx; with a 1500Mi container limit and 1000MB heap, direct memory has little room |
| Filter caches request bodies with no size cap | a 50MB upload is read fully into direct memory; buffers re-retained rely on downstream consumption for release - exceptions/timeouts leak them |
| No fallback in the reactive chain | DataBufferUtils.join interrupted mid-way or downstream errors orphan the in-flight buffer; AccessLogFilter aggregates whole response bodies in memory |
Low traffic hides it; concurrency or a few large simultaneous requests blow it up.
Netty's ByteBuf defaults to PooledByteBufAllocator: memory is allocated off-heap (direct memory) and not managed by GC — allocation and release rely entirely on reference counting (refCnt):
retain increments, release decrements, and a single missed release leaks forever until MaxDirectMemorySize is hit.
Three compounding factors each have their own mechanism: with no explicit cap the default equals -Xmx (a K8s container with 1500Mi leaves little direct space after a 1000MB heap); a Filter caching request bodies with no size limit (reads a 50MB upload + exception paths never release → leak);
a reactive join interrupted by disconnect/error orphans intermediate buffers. Under low traffic, pooled buffer reuse hides the leak; it blows up once concurrency rises.
Deterministic takeaway: direct OOM is a "leak + cap" two-variable problem — first fix the leak (retain/release pairing, finally-release on exception paths), then tune the cap (-XX:MaxDirectMemorySize matched to container memory); jcmd <pid> VM.native_memory shows the direct allocation trend;
fixed diagnostic: OOM with heap not full + Netty in the stack = direct-memory problem, not an -Xmx problem.
Step by step
1. Confirm direct memory from the signature — Required
OutOfDirectMemoryError (Netty wrapper) or OutOfMemoryError: Direct buffer memory (JDK native), carrying used/max numbers - check whether max is roughly -Xmx (the default when the flag is unset).
2. Inspect JVM flags and container limits — Diagnose
# Is MaxDirectMemorySize set? (0 = defaults to -Xmx)
java -XX:+PrintFlagsFinal -version 2>&1 | grep MaxDirectMemorySize
# Compare with the container limit: heap + direct + metaspace + stacks must all fit
kubectl get deploy <name> -o yaml | grep -A2 resources
3. Find who eats the direct memory — Required
- Gateway: Netty I/O itself uses direct memory (normal); focus on custom Filters that cache request/response bodies into
byte[],DataBufferUtils.join, andretainwithout guaranteed release; - Enable leak detection during a low-traffic window:
-Dio.netty.leakDetectionLevel=paranoid- the logs will print the leak origin; - jstack/heap dumps do not help with direct memory; rely on code review + leak detection.
4. Fix — Required
- Add a size cap to request-body caching (e.g. 1MB), skip the cache above it and log a warn;
- Add
doFinally/onErrorResumeto the reactive chain to guarantee release; - Set
-XX:MaxDirectMemorySizeexplicitly, sized for gateway traffic (heap and direct memory separated, neither squeezes the other); - Recompute the K8s container limit (heap + direct + metaspace + stacks + OS overhead).
5. Verify — Required
Re-run the trigger path under load (large requests + concurrency): the direct-memory watermark stays flat; leak detection reports no new leaks.
How to confirm it works
The same trigger path no longer raises OutOfDirectMemoryError; direct-buffer watermarks (e.g. jcmd <pid> VM.native_memory) stay stable; health checks pass.
Other environments
| Environment | Notes |
|---|---|
| Gateway workloads (Spring Cloud Gateway / reactor-netty) | heavily use direct memory by design; always set MaxDirectMemorySize explicitly and manage ByteBuf release |
| Ordinary services | the default (= -Xmx) usually suffices, but aggregating large files/responses into memory can still blow up |
| Not verified | full Spring Cloud Gateway + custom Filter repro not run locally (no gateway env); the core mechanism (default cap = -Xmx, direct memory exhausts independently of a free heap) is verified |
Symptom
Gateway returns occasional 500s that escalate: io.netty.util.internal.OutOfDirectMemoryError: failed to allocate N byte(s) of direct memory (used: X, max: Y), service becomes unavailable, health checks fail; heap and GC logs stay normal.
Cause
Direct (off-heap) memory is not managed by GC: -XX:MaxDirectMemorySize defaults to -Xmx when unset; custom Filters cache request/response bodies without a size cap and rely on downstream consumption for release, so exceptions/timeouts leak ByteBufs until the cap is hit.
Solution
- Set
-XX:MaxDirectMemorySizeexplicitly (separate heap and direct memory) and raise the container limit accordingly; - Cap request/response body caching, skip above the limit;
- Add
doFinally/onErrorResumerelease guarantees to the reactive chain; - Enable
-Dio.netty.leakDetectionLevel=paranoidto locate leaks; - Verify the direct-memory watermark under load.
Verification Evidence
- Mechanism repro (2026-08-30, WSL Ubuntu, OpenJDK 17.0.18): 1) without MaxDirectMemorySize and
-Xmx64m, allocating 64MB of direct memory throwsOutOfMemoryError: Cannot reserve ... (allocated: 67108864, limit: 67108864)- behavior proves the default cap equals -Xmx; 2)-XX:MaxDirectMemorySize=64m -Xmx256m- the heap stays roomy but direct memory still OOMs at 64MB (direct memory is independent of the heap and not GC-managed); 3) explicit 256m -> 200MB allocated fine. local repro log 2026-08-30. - Not verified: full gateway Filter/ByteBuf leak repro (no Spring Cloud Gateway environment); Netty's
OutOfDirectMemoryErrorand the JDK'sDirect buffer memoryshare the same mechanism, Netty just wraps the exception.
Applicable tools (1)
This problem is linked from the tool pages below — reachable from either side.
jdkSource & evidence
← Coding Agents & Terminal domain | Problem Catalog Experience | Facing another issue? Go to the Helpdesk