skip to content
everything.performance.jvmInContainers

the JVM in containers

ergonomics, memory limits, and the flags that matter.

chapter
chapter 15.05
reading time
9 min read

inside a container the JVM reads its cgroup limits and sizes itself from them, not from the host. by default the max heap is 25% of the memory limit (MaxRAMPercentage), the CPU count is the CPU quota rounded up, and with fewer than 2 CPUs or less than about 1.8 GB it quietly switches from G1 to Serial.

the defaults are conservative on purpose. for a container that runs one JVM and nothing else, you almost always want -XX:MaxRAMPercentage set higher.

the picture

think of a hotel guest who reads the size of the building off the fire-escape plan and decides to spread out accordingly. that was the JVM in a container before java 10: it asked the operating system how much memory the machine had, got the host's answer (say 64 GB), picked a 16 GB heap inside a container allowed 2 GB, and then the kernel's OOM killer shot it. no OutOfMemoryError, no stack trace, just exit code 137.

java 10 (backported to 8u191) made the JVM read the container's cgroup limits instead; cgroup v2, the newer format most distributions use now, is supported since java 15 (and 11.0.16, 8u372). the flag is -XX:+UseContainerSupport, on by default, linux only. here's what the JVM sees in a container started with docker run -m 512m --cpus 2:

terminal
$ docker run --rm -m 512m --cpus 2 eclipse-temurin:25-jre java -XshowSettings:system -version
Operating System Metrics:
    Provider: cgroupv2
    Effective CPU Count: 2
    CPU Period: 100000us
    CPU Quota: 200000us
    CPU Shares: -1
    List of Effective Processors, 14 total:
    0 1 2 3 4 5 6 7 8 9 10 11 12 13
    Memory Limit: 512.00M
    Memory & Swap Limit: 1.00G

the host has 14 cores and the container could run on any of them, but the quota allows 2 CPUs' worth of time per period, so the JVM sizes itself for 2.

hover a stage.

hover a stage. how the limits get from docker (or kubernetes) into the JVM's defaults, on cgroup v2.

what it picks

every number below comes from the same image, eclipse-temurin:25-jre (jdk 25.0.4.1), run with different limits and read back with -XX:+PrintFlagsFinal:

-m =512 MB
--cpus =
MaxRAM% =
max heap
128 MB ✓ measured: 128 MB
CPUs the JVM sees
2 (quota rounded up)
garbage collector
Serial (under 1792 MB: not a "server class machine")
GC / JIT threads
ParallelGCThreads=0, CICompilerCount=2 (measured at 4 GB)

MaxRAMPercentage = 25: 25% of the 512 MB the container allows. the rest of the container has to hold metaspace, the code cache, thread stacks, GC structures and direct buffers.

drag the memory limit and change the CPUs. measured values are real container runs; others follow the same rule. the blue part is the most heap the JVM will use. try 128 MB with 75%: the percentage you asked for is ignored.

the rule, as HotSpot implements it, has three cases:

container memorymax heapwhy
under about 250 MBhalf of itMinRAMPercentage (50). confusingly named: it's the max heap for small machines
about 250 MB to 500 MB126 MBthe built-in default MaxHeapSize is a floor
above that25% of itMaxRAMPercentage (25)

and the CPU side: the quota is rounded up (--cpus 1.5 looks like 2 CPUs), a --cpuset-cpus 0-2 pin counts too (3 CPUs), but CPU shares are ignored: --cpu-shares 512 still showed all 14 cores. that matters on Kubernetes, where a CPU request becomes shares and only a limit becomes a quota. a pod with requests: cpu: 1 and no limit sizes its GC and JIT thread pools for every core on the node.

the GC choice follows the same numbers. the JVM only considers itself a "server class machine" with at least 2 CPUs and about 1.8 GB (-m 1792m got G1, -m 1g got Serial). below that you get the Serial collector: one GC thread, stop-the-world, which is the right call for a tiny box and a surprise if you expected G1's pause-time behaviour (choosing a collector).

quick check
a container with -m 128m runs java -XX:MaxRAMPercentage=75. what's the max heap?

the heap is not the whole JVM

the container limit applies to the whole process, and the heap is only part of it. the rest:

metaspace
class metadata. grows with the number of loaded classes; frameworks load tens of thousands.
code cache
JIT-compiled machine code. reserved up to 240 MB by default, committed as it fills.
thread stacks
up to 1 MB each by default (-Xss), committed as they're touched. 200 platform threads can mean a lot.
GC structures
remembered sets, mark bitmaps. G1's can be a few percent of the heap.
direct buffers and native
NIO direct ByteBuffers, the FFM API's off-heap segments, JNI libraries, the C allocator's own overhead.

native memory tracking shows the split. the order-processing app from the flight recorder chapter, in a 512 MB container, with -XX:MaxRAMPercentage=75 and -XX:NativeMemoryTracking=summary:

Java heap383.9 MB
code cache8.3 MB
GC1.3 MB
symbols1.1 MB
metaspace0.9 MB
thread stacks0.8 MB
total committed418.2 MB
committed memory, in MB, from -XX:+PrintNMTStatistics at exit. the heap filled its whole 384 MB (the app allocates a lot). everything else came to about 34 MB for this tiny app; a real framework app with thousands of classes and hundreds of threads needs far more.

so "75% heap" left 128 MB for everything else, and this app used about 34 MB of it. that headroom is what decides whether a container lives. here's what happens without it:

terminal
$ docker run --rm -m 256m --memory-swap 256m eclipse-temurin:25-jre java -Xmx400m -Xms400m -XX:+AlwaysPreTouch -version
$ echo $?
137

nothing printed. -XX:+AlwaysPreTouch makes the JVM touch every page of its 400 MB heap at startup, the kernel noticed the container had passed 256 MB, and killed the process. exit code 137 is 128 + 9: SIGKILL. the same thing happens slowly in production when native memory creeps up, which is why OOMKilled in kubectl and OutOfMemoryError in your logs are two completely different problems (native memory).

the flags that matter

terminal
java -XX:MaxRAMPercentage=75 \
     -XX:+ExitOnOutOfMemoryError \
     -Xlog:gc*:file=/tmp/gc.log:time,uptime:filecount=5,filesize=10m \
     -jar app.jar
  • -XX:MaxRAMPercentage: the heap as a share of the container limit. 60 to 80 for a single-process container is common; leave room for the list above. it scales automatically when someone changes the pod's memory, which a fixed -Xmx doesn't.
  • -XX:ActiveProcessorCount=n: override the CPU count the JVM sizes thread pools, GC threads and JIT threads for. useful when there's no quota.
  • -XX:+ExitOnOutOfMemoryError: die on the first OutOfMemoryError and let the orchestrator restart the container, instead of limping on in a broken state.
  • -Xlog:os+container=debug prints how the JVM read the limits, and jfr view container-configuration shows the same in a recording.
one level deeper:startup in a container+

containers are often small and restarted often, so startup costs show up more there. a 1-CPU container gets Serial GC and only 2 JIT compiler threads (CICompilerCount=2 at 1 or 2 CPUs, 3 at 4, 4 at 8, all measured), so warmup takes longer. class data sharing and the AOT cache cut startup and warmup time and work fine in images (AOT and Leyden). and jlink can build a runtime with only the modules you use, making the image smaller (jlink).

keylines
  • the JVM reads cgroup limits: memory limit, CPU quota, cpuset. CPU shares (k8s requests) are ignored.
  • default max heap: 25% of the limit, 50% under about 250 MB, never less than 126 MB above that.
  • under 2 CPUs or about 1.8 GB you get Serial GC, not G1.
  • the container limit covers the whole process. leave headroom for metaspace, code cache, threads, buffers.
  • exit code 137 with no OutOfMemoryError means the kernel killed you, not the JVM.