Memory in Containers
Size the heap for a container limit, account for the memory outside the heap, and tell a Java OutOfMemoryError from a kernel OOM kill.
java -XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError -jar app.jar
jcmd <pid> VM.native_memory summary # needs -XX:NativeMemoryTracking=summaryIn a container the JVM reads the cgroup memory and CPU limits and sizes itself to them instead of the host. This container support is on by default on Linux. What catches people out are the defaults it then applies, and the memory that is not heap.
The default heap is small
Without -Xmx, the maximum heap is 25% of the container's memory limit (MaxRAMPercentage=25). A service in a 2 GB container gets a 512 MB heap and leaves most of its memory unused. Set the heap explicitly, either as a fixed size or as a percentage that follows the limit:
java -XX:MaxRAMPercentage=75 -jar app.jar
# or a fixed size
java -Xmx1500m -jar app.jarThe percentage form keeps working when someone changes the limit in the deployment. 75% is a common starting point for containers of 2 GB or more; in a 1 GB container the roughly fixed costs below take a bigger share, so 50 to 65% is safer. MinRAMPercentage, despite its name, is not a minimum heap; it only applies on machines with very little memory.
Memory that is not heap
The container limit covers the whole process, and the heap is only part of it:
- Metaspace: class metadata. Large frameworks use 100 MB or more.
- Thread stacks: up to 1 MB reserved per platform thread, though only the pages actually touched count. Virtual threads keep their stacks on the heap.
- Code cache: compiled JIT code, up to 240 MB reserved by default.
- Direct buffers: off-heap memory used by NIO, Netty and many HTTP and database drivers.
- GC data structures and the JVM's own native memory.
Native Memory Tracking shows the breakdown: start with -XX:NativeMemoryTracking=summary and run jcmd <pid> VM.native_memory summary.
Two different out-of-memory failures
| Symptom | Cause | Fix |
|---|---|---|
java.lang.OutOfMemoryError: Java heap space in the logs | The heap is full | Find the leak or raise -Xmx |
Pod restarted with OOMKilled, exit code 137, no Java error | The process passed the container limit | Lower the heap percentage or raise the limit |
An OOM kill gives no stack trace and no heap dump, because the kernel ends the process with SIGKILL. If you see it, the heap plus everything else is larger than the limit.
Production flags
-XX:+ExitOnOutOfMemoryErrorso a heap failure restarts the pod instead of leaving it half alive.-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumpson a volume, to find the leak afterwards.- Set the collector explicitly; with one CPU or under about 1792 MB the JVM picks Serial.
- Bound memory per request: limit body sizes and the number of concurrent heavy jobs.
Example
When to use it
- Set -XX:MaxRAMPercentage=75 on a service in a 2 GB pod that was using only a 512 MB heap by default.
- Diagnose pods restarted with OOMKilled by comparing Native Memory Tracking output with the container limit.
- Limit upload sizes and concurrent exports in an API so a burst of large requests cannot exhaust the heap.
More examples
Measuring memory outside the heap
Direct buffers live outside the heap, so -Xmx does not cover them but the container limit does. The same BufferPoolMXBean figures are worth exporting as metrics when a service uses NIO or a network library that pools direct memory.
import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;
import java.nio.ByteBuffer;
static BufferPoolMXBean directPool() {
for (BufferPoolMXBean pool : ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) {
if (pool.getName().equals("direct")) {
return pool;
}
}
throw new IllegalStateException("no direct buffer pool");
}
void main() {
BufferPoolMXBean pool = directPool();
long before = pool.getTotalCapacity();
ByteBuffer buffer = ByteBuffer.allocateDirect(4 * 1024 * 1024);
long added = pool.getTotalCapacity() - before;
IO.println("direct memory added: " + added / (1024 * 1024) + " MB");
IO.println("buffer is direct: " + buffer.isDirect());
}
// Output:
// direct memory added: 4 MB
// buffer is direct: trueRejecting oversized request bodies
readAllBytes would happily load a 2 GB upload into the heap. Reading at most one byte past the limit caps the memory a single request can take, and the handler can answer 413 Content Too Large.
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
static byte[] readLimited(InputStream in, int limit) throws IOException {
byte[] data = in.readNBytes(limit + 1); // never reads more than limit + 1 bytes
if (data.length > limit) {
throw new IOException("body larger than " + limit + " bytes");
}
return data;
}
void main() {
int limit = 1_024;
for (int size : new int[] {200, 1_024, 50_000}) {
InputStream body = new ByteArrayInputStream(new byte[size]);
try {
IO.println("accepted " + readLimited(body, limit).length + " bytes");
} catch (IOException e) {
IO.println("413: " + e.getMessage());
}
}
}
// Output:
// accepted 200 bytes
// accepted 1024 bytes
// 413: body larger than 1024 bytesCapping concurrent memory-heavy jobs
If each export holds 200 MB while it runs, two slots bound that work to 400 MB whatever the traffic. In a real handler, release the permit in a finally block so a failed export does not leak a slot.
import java.util.concurrent.Semaphore;
static final Semaphore EXPORT_SLOTS = new Semaphore(2);
static String startExport(String user) {
if (!EXPORT_SLOTS.tryAcquire()) {
return user + ": 503, try again later";
}
return user + ": export started";
}
static void finishExport() {
EXPORT_SLOTS.release();
}
void main() {
IO.println(startExport("ana"));
IO.println(startExport("ben"));
IO.println(startExport("cara"));
finishExport();
IO.println(startExport("cara"));
}
// Output:
// ana: export started
// ben: export started
// cara: 503, try again later
// cara: export started
Discussion