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.

Syntaxjava -XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError -jar app.jar jcmd <pid> VM.native_memory summary # needs -XX:NativeMemoryTracking=summary

In 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.jar

The 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

SymptomCauseFix
java.lang.OutOfMemoryError: Java heap space in the logsThe heap is fullFind the leak or raise -Xmx
Pod restarted with OOMKilled, exit code 137, no Java errorThe process passed the container limitLower 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:+ExitOnOutOfMemoryError so a heap failure restarts the pod instead of leaving it half alive.
  • -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps on 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · java
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: true

Rejecting 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.

Example · java
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 bytes

Capping 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.

Example · java
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

  • Be the first to comment on this lesson.