JVM in a container: heap sizing, GC choice, async-profiler
What it is
The JVM was designed to own a machine. In a container it owns a cgroup, and three of its defaults are computed from what it believes the machine is:
Default max heap: 1/4 of "available memory"
Default GC: chosen from "available processors" and memory
Default thread pools: ForkJoinPool.commonPool, GC threads, JIT compiler
threads, all sized from "available processors"
Modern JVMs (10+, and backported to 8u191) read cgroup limits, so
Runtime.availableProcessors() returns a value derived from the CPU limit and
maxMemory() from the memory limit. That fixed the catastrophic version of the problem.
What remains is subtler and still costs money: heap is a fraction of the limit, not the
limit, so a container with a 4 GB limit gets a 1 GB heap by default and 3 GB sits unused;
and the CPU-count derivation is a ceil of the quota, so a limit of 1.5 CPUs reports 2 and a
limit of 500m reports 1, which changes the GC selection.
What this is confused with: "the JVM is container-aware now, so it is fine." Container awareness means it reads the limits. It does not mean the defaults derived from those limits suit your workload, and the gap between a 25 percent heap default and a sensible 70 percent is most of the memory you are paying for.
The problem it solves
Before 8u191, the JVM read /proc/meminfo and /proc/cpuinfo, which report the host, not
the cgroup:
Container limit: 2 GB
Host memory: 256 GB
JVM default max heap: 64 GB <- 1/4 of the HOST
Result: the JVM grows past 2 GB and is OOMKilled with
no OutOfMemoryError, because the kernel killed it
before the JVM noticed
Exit 137 with no OutOfMemoryError in the logs is the signature, and it is still seen on
Java 8 images that predate the backport.
The current problems, on a container-aware JVM:
Wasted memory. MaxRAMPercentage defaults to 25, so three quarters of a container's
memory limit is unavailable to the heap. Some of that is legitimately needed for non-heap
usage; 75 percent is far more than needed.
Non-heap memory that nobody counts. The container limit must cover heap plus metaspace
plus code cache plus thread stacks plus direct buffers plus GC structures plus the JVM
itself. A team that sets -Xmx equal to the container limit will be OOMKilled, and the
error will be the kernel's rather than the JVM's, so it looks like a leak.
GC selection by machine class. The JVM picks SerialGC below roughly 2 CPUs and 1792 MB, and G1 above it. A container limited to 1 CPU gets SerialGC, which stops the world for every collection, and that is a surprising choice for a service.
Mechanics
Sizing the container, not just the heap
Container memory limit
├── Heap -Xmx / MaxRAMPercentage
├── Metaspace class metadata; grows with classes loaded
├── Code cache JIT-compiled code (default max 240 MB)
├── Thread stacks threads x -Xss (default 1 MB on 64-bit)
├── Direct byte buffers NIO, Netty; -XX:MaxDirectMemorySize
├── GC overhead card tables, remembered sets: ~5-10% of heap for G1
├── Compressed class space ~1 GB reserved, much less committed
└── JVM itself + malloc ~50-100 MB
A worked sizing for a 4 GB container:
Heap (-Xmx): 2560 MB (62.5%)
Metaspace: 256 MB
Code cache: 240 MB
Thread stacks (200 x 1MB): 200 MB
Direct buffers: 256 MB
GC overhead (~8% of heap): 205 MB
JVM + malloc: 100 MB
─────────
3817 MB -> fits in 4096 with headroom
Native Memory Tracking is how you replace those estimates with measurements:
$ java -XX:NativeMemoryTracking=summary ...
$ jcmd 1 VM.native_memory summary
Total: reserved=5242880KB, committed=3891200KB
- Java Heap (reserved=2621440KB, committed=2621440KB)
- Class (reserved=1114112KB, committed= 81920KB)
- Thread (reserved= 206848KB, committed= 206848KB)
(thread #201)
- Code (reserved= 253952KB, committed= 122880KB)
- GC (reserved= 198656KB, committed= 198656KB)
Thread (thread #201) at 206 MB is where runaway thread counts show up, and it is the
single most common source of "the heap is fine and the container is OOMKilled." See the
memory page's worked example, where 812 threads was 812 MB.
Set the percentage, not the absolute value:
# Portable across container sizes; survives someone changing the limit.
-XX:MaxRAMPercentage=65.0
-XX:InitialRAMPercentage=65.0 # avoid heap resizing during warmup
-XX:MinRAMPercentage=65.0 # applies BELOW 96MB; badly named
MinRAMPercentage does not mean a minimum heap. It is the percentage used when the
container has less than about 96 MB, which is one of the worse names in the JVM. Setting
Initial equal to Max avoids heap growth during startup, which matters because heap
resizing during warmup causes GC pauses at exactly the wrong time.
The CPU count derivation, and why 1.5 is a bad limit
availableProcessors() = ceil(cpu.max quota / period)
CPU limit 500m -> ceil(0.5) = 1
CPU limit 1 -> 1
CPU limit 1.5 -> ceil(1.5) = 2
CPU limit 2 -> 2
Fractional CPU limits round up, so the JVM sizes its thread pools for 2 CPUs while the cgroup permits 1.5, and every pool is over-provisioned relative to the quota. Combined with CFS throttling (see CPU limits), that produces GC threads competing for a quota they can exhaust in a fraction of a period.
# If you must use a fractional limit, tell the JVM the truth:
-XX:ActiveProcessorCount=2
# And size the pools explicitly rather than letting them derive:
-XX:ParallelGCThreads=2
-XX:ConcGCThreads=1
-Djava.util.concurrent.ForkJoinPool.common.parallelism=2
ForkJoinPool.commonPool is the one people forget, and it is what parallel streams use.
A parallel stream on a container that reports 8 CPUs creates 7 worker threads regardless of
the quota.
GC selection
Default selection:
< 2 CPUs OR < 1792 MB -> SerialGC
otherwise -> G1GC
| GC | Pause target | Heap range | Right for |
|---|---|---|---|
| SerialGC | Full STW, proportional to heap | < 500 MB | Tiny containers, batch |
| ParallelGC | STW, parallel | any | Throughput batch work |
| G1GC | ~200 ms default target | 2 GB to 100+ GB | The general default |
| ZGC | < 1 ms, heap-size independent | 8 GB to 16 TB | Latency-critical, large heap |
| ShenandoahGC | < 10 ms | 4 GB+ | Latency-critical, smaller heaps |
# Latency-sensitive service, generational ZGC (JDK 21+):
-XX:+UseZGC -XX:+ZGenerational
-XX:MaxRAMPercentage=70
# Throughput batch job:
-XX:+UseParallelGC -XX:MaxRAMPercentage=80
# The general case:
-XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:MaxRAMPercentage=65
Generational ZGC (JDK 21) is the change that made ZGC a default candidate. Non-generational ZGC had sub-millisecond pauses and needed substantially more heap headroom and more CPU, because it scanned the whole heap on every cycle. The generational version collects young objects separately, which cuts both.
The trade to state plainly: ZGC and Shenandoah spend CPU to avoid pauses. Concurrent collection means GC work happens alongside application threads, so throughput is typically 5 to 15 percent lower than ParallelGC. For a batch job that is a pure loss; for a service with a latency SLO it is the entire point.
Profiling: async-profiler
jstack and most JVM profilers use AsyncGetCallTrace at safepoints, which biases samples
toward code that reaches a safepoint quickly and misses long-running loops entirely. That is
the safepoint bias problem, and it means a profiler can confidently point at the wrong
method.
async-profiler samples with perf_events and does not require a safepoint:
# CPU profile, 30 seconds, flame graph output.
./profiler.sh -d 30 -e cpu -f /tmp/cpu.html 1
# ALLOCATION profile: which call sites allocate, which drives GC pressure.
./profiler.sh -d 30 -e alloc -f /tmp/alloc.html 1
# Lock contention.
./profiler.sh -d 30 -e lock -f /tmp/lock.html 1
# Wall clock, which is what you want for latency: it samples ALL threads
# including blocked ones, so time waiting on I/O is visible.
./profiler.sh -d 30 -e wall -t -f /tmp/wall.html 1
-e wall is the mode people do not know about and usually need. CPU profiling shows
where CPU is spent, and a service whose latency problem is waiting on a database shows almost
nothing in a CPU profile. Wall-clock profiling shows where time is spent, including blocked
threads, which is what a latency investigation needs.
Running it in a container requires two things:
securityContext:
capabilities:
add: ["SYS_ADMIN"] # for perf_events; OR set the sysctl below
# Preferred: no capability needed, set on the node.
sysctl -w kernel.perf_event_paranoid=1
sysctl -w kernel.kptr_restrict=0
Adding SYS_ADMIN to profile is a bad trade given the container security page's argument;
the node sysctl is the right approach, and it is why profiling access is usually a platform
decision rather than an application one.
JDK Flight Recorder is the always-on alternative, at roughly 1 percent overhead:
-XX:StartFlightRecording=settings=profile,filename=/tmp/rec.jfr,maxsize=200M
JFR for continuous low-overhead recording, async-profiler for a deep dive, is the pairing that works.
A worked example: 6 GB containers running a 1.5 GB heap
A payments platform, 60 Java services, Kubernetes, JDK 17.
Baseline:
container memory limit: 6 GB (uniform, copied across all services)
JVM flags: -Xms512m -Xmx4g <- absolute, and inconsistent
with the limit
actual heap used (p99): 1.4 GB
GC: G1 (default)
p99 latency (flagship): 340 ms
GC pause p99: 180 ms
OOMKills: ~40/month
nodes: 88
Three separate problems, and the OOM kills were the entry point.
Problem 1: -Xmx4g in a 6 GB container with unmeasured non-heap usage.
$ jcmd 1 VM.native_memory summary
Total: committed=5734400KB
- Java Heap (committed=4194304KB) # 4.0 GB
- Class (committed= 204800KB)
- Thread (committed= 614400KB) # 600 MB: 600 threads
- Code (committed= 245760KB)
- GC (committed= 335872KB)
- Other (committed= 139264KB)
5.73 GB committed against a 6 GB limit, so any spike in direct buffers or thread count
crossed it. The kernel killed the process; the JVM never saw an OutOfMemoryError, so the
logs showed a clean shutdown followed by a restart.
-> the 600 threads were a Tomcat maxThreads of 200 plus three separate
HTTP client pools each defaulting to 200
Problem 2: the heap was four times larger than needed. p99 heap usage was 1.4 GB against a
4 GB -Xmx, and the 6 GB container limit was uniform across 60 services regardless of what
they did.
Problem 3: G1 with a 4 GB heap and a 180 ms pause p99 on a service with a 200 ms latency
SLO. G1's default MaxGCPauseMillis is 200, so it was meeting its own target and that target
consumed the entire budget.
The changes:
# 1. Percentage-based, so it tracks whatever the limit becomes.
-XX:MaxRAMPercentage=70
-XX:InitialRAMPercentage=70
# 2. Bound the non-heap explicitly rather than discovering it.
-XX:MaxMetaspaceSize=256m
-XX:ReservedCodeCacheSize=240m
-XX:MaxDirectMemorySize=256m
-Xss512k # 200 threads x 512k = 100MB, not 200MB
# 3. Generational ZGC for the latency-critical services.
-XX:+UseZGC -XX:+ZGenerational
// 4. Bound the thread pools, which was the actual OOM cause.
server.tomcat.threads.max=64 // was 200
// and one shared HTTP client pool instead of three:
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
cm.setMaxTotal(200);
cm.setDefaultMaxPerRoute(32);
And the sizing done per service rather than uniformly:
# Per service, from 14 days of JFR and container metrics.
heap_needed = p99(jvm_memory_used_bytes{area="heap"}) * 1.5
nonheap = measured_from_nmt()
limit = round_up(heap_needed / 0.70 + nonheap_headroom)
before (uniform) after (measured)
service A 6 GB 2 GB
service B 6 GB 3 GB
service C 6 GB 8 GB <- one service was UNDER-provisioned
...
mean 6 GB 2.9 GB
One service was under-provisioned, and the uniform limit had hidden it: it was OOM-killed regularly and the team had assumed it leaked.
Results:
before after
container limit (mean) 6 GB 2.9 GB
heap (mean) 4 GB 2.0 GB (70% of limit)
committed vs limit 96% 74%
OOMKills ~40/mo 0
threads (flagship) 600 96
GC pause p99 180 ms 0.8 ms (ZGC)
p99 latency (flagship) 340 ms 121 ms (-64%)
throughput baseline -6% (ZGC's CPU cost)
nodes 88 51 (-42%)
Forty-two percent fewer nodes and a 64 percent p99 reduction, and throughput dropped 6 percent, which is the ZGC trade paid deliberately.
The profiling finding, which came after. With GC pauses gone, the remaining p99 was investigated with async-profiler in wall-clock mode:
./profiler.sh -d 60 -e wall -t -f /tmp/wall.html 1
Wall-clock profile, p99 requests:
47% socketRead0 <- waiting on a downstream service
18% ObjectMapper.readValue <- JSON deserialisation
11% regex Pattern.matcher <- a validation regex compiled PER REQUEST
9% ...
A CPU profile of the same service showed Pattern.compile at 34 percent and almost
nothing about the socket read, because a blocked thread uses no CPU. The CPU profile would
have sent them to optimise JSON parsing; the wall-clock profile showed that half the time was
waiting on a downstream call.
// The regex fix was still worth it: 11% of wall time, and trivial.
private static final Pattern VALID = Pattern.compile("^[A-Z]{2}\\d{6}$"); // static
p99 latency: 121 ms -> 96 ms
Final:
before after
p99 latency 340 ms 96 ms (-72%)
GC pause p99 180 ms 0.8 ms
OOMKills ~40/mo 0
nodes 88 51
container limit (mean) 6 GB 2.9 GB
The transferable findings are two. MaxRAMPercentage rather than -Xmx, because it
tracks the limit and cannot drift out of sync with it, and because the default of 25 percent
is far below what a service should use. And wall-clock profiling rather than CPU profiling
for latency work, because a service waiting on I/O shows almost nothing in a CPU profile and
the CPU profile will confidently point somewhere else.
Production evidence
Container awareness landed in JDK 10 (JDK-8146115) and was backported to 8u191. Before
that the JVM read host /proc, and the "OOMKilled with no OutOfMemoryError" signature is the
canonical symptom on older images. UseContainerSupport is on by default and can be disabled,
which is occasionally the cause of a regression on a JVM upgrade.
Generational ZGC shipped in JDK 21 (JEP 439) and is the change that made ZGC viable as a general default: the non-generational version required substantially more headroom and CPU because it scanned the whole heap every cycle.
async-profiler is widely used precisely because it avoids the safepoint bias in
AsyncGetCallTrace-based profilers. Its wall-clock mode is the feature that distinguishes it
for latency work, and the safepoint-bias problem was documented by Nitsan Wakart and others.
JDK Flight Recorder was open-sourced in JDK 11 and is designed for always-on production recording at roughly 1 percent overhead, which is why the JFR-plus-async-profiler pairing has become standard.
Spring Boot's and Micronaut's container guidance both recommend MaxRAMPercentage over
-Xmx, and Paketo and Google's Java buildpacks set it automatically along with a computed
thread-stack budget, which is an implicit acknowledgement that the defaults do not suit
containers.
kernel.perf_event_paranoid being the blocker for profiling in containers is documented
by async-profiler, and the recommendation to set the node sysctl rather than granting
SYS_ADMIN is theirs.
The debate
-Xmx or MaxRAMPercentage? Percentage, essentially always. It tracks the container
limit, so a limit change does not silently leave the heap wrong, and it survives the same
image running in differently-sized containers. The default of 25 percent is the problem,
not the mechanism: 65 to 75 percent is a reasonable range for a service whose non-heap usage
you have measured with Native Memory Tracking.
Which GC? G1 as the default, ParallelGC for batch throughput, generational ZGC when you have a latency SLO that GC pauses would consume. The trade is explicit: concurrent collectors spend 5 to 15 percent throughput to remove pauses, so for a batch job it is a pure loss and for a latency-sensitive service it is the entire point. SerialGC is what you get by accident on small containers and is rarely what you want for a service.
Is a container-aware JVM enough? No, and this is the position worth holding. It reads the limits, which prevents the catastrophic version. The defaults derived from those limits (25 percent heap, GC chosen by machine class, thread pools sized by a rounded-up CPU count) still need attention, and the gap between a 25 percent default and a measured 70 percent is most of the memory bill.
Should you use fractional CPU limits? Prefer not to, because availableProcessors()
rounds up, so a 1.5-CPU limit reports 2 and every derived pool is sized for capacity the
cgroup will not grant. If you must, set ActiveProcessorCount explicitly. And the broader
argument from the requests and limits page applies: for most
services, do not set a CPU limit at all.
CPU profile or wall-clock profile? Wall clock for latency, CPU for throughput and cost. The distinction is that a blocked thread consumes no CPU, so a service whose p99 is dominated by a slow downstream call shows almost nothing relevant in a CPU profile. In the worked example the CPU profile pointed at regex compilation at 34 percent while the wall-clock profile showed 47 percent waiting on a socket, and only one of those was the latency problem.
Is always-on profiling worth it? JFR at roughly 1 percent overhead, yes, and it is the
difference between investigating an incident with data and reproducing it first. The
constraint is usually the security posture rather than the overhead: async-profiler needs
perf_events, which means a node sysctl or a capability, and granting SYS_ADMIN to profile
is a worse trade than setting kernel.perf_event_paranoid on the node.
Follow-up Q&A
"Why was the JVM OOMKilled with no OutOfMemoryError?"
Because the kernel killed the process before the JVM reached its own heap limit. That happens
when the container limit must cover heap plus metaspace plus code cache plus thread stacks
plus direct buffers plus GC structures, and only the heap was budgeted. On pre-8u191 JVMs it
also happens because the JVM read host /proc and sized its heap at a quarter of the host's
memory. The diagnostic is jcmd VM.native_memory summary with Native Memory Tracking enabled,
and the usual culprit is the thread count.
"Why MaxRAMPercentage instead of -Xmx?"
Because it tracks the container limit, so changing the limit cannot leave the heap silently wrong, and the same image works in differently-sized containers. The default of 25 percent is the thing to change: it leaves three quarters of the limit unavailable to the heap, which is far more headroom than non-heap usage needs. Measure non-heap with NMT and set the percentage from that, typically 65 to 75.
"How does the JVM decide how many CPUs it has, and why does it matter?"
ceil(quota / period) from the cgroup, so a 1.5-CPU limit reports 2 and a 500m limit reports
- It matters because GC thread count, JIT compiler threads and
ForkJoinPool.commonPool.parallelismall derive from it, so fractional limits over-provision every pool relative to what the cgroup will grant, and combined with CFS throttling the GC threads can exhaust the quota in a fraction of a period. SetActiveProcessorCountexplicitly if you use fractional limits.
"Which garbage collector would you choose?"
G1 as the general default. ParallelGC for batch work where throughput is the only thing that matters. Generational ZGC (JDK 21+) when there is a latency SLO that GC pauses would eat: in one case it took GC pause p99 from 180 ms to 0.8 ms on a service with a 200 ms budget. The cost is explicit, 5 to 15 percent throughput, because concurrent collection does GC work alongside application threads. For batch that is a pure loss; for a latency-sensitive service it is the point.
"CPU profile or wall-clock profile?"
Wall clock for latency work. A blocked thread consumes no CPU, so a service whose p99 is
dominated by waiting on a downstream call shows almost nothing relevant in a CPU profile. In
one case the CPU profile put regex compilation at 34 percent and the wall-clock profile showed
47 percent in socketRead0, waiting on a downstream service. Both findings were real; only
one was the latency problem, and the CPU profile would have sent the team to optimise the
wrong thing.
"How do you profile a JVM in a container?"
async-profiler needs perf_events, which means either SYS_ADMIN on the container or
kernel.perf_event_paranoid=1 on the node. The node sysctl is the right answer, because
granting SYS_ADMIN undoes the capability hardening for the sake of a profile. Pair it with
JFR running continuously at about 1 percent overhead, so an incident can be investigated from
recorded data rather than reproduced first.
Common misconceptions
"Modern JVMs are container-aware, so the defaults are fine." They read the limits, which prevents the catastrophic failure. The defaults derived from those limits (25 percent heap, GC by machine class, pools sized from a rounded-up CPU count) still need setting.
"Set -Xmx to the container limit." The limit must also cover metaspace, code cache,
thread stacks, direct buffers and GC structures. Setting the heap to the limit guarantees a
kernel OOM kill, and it will not produce an OutOfMemoryError.
"MinRAMPercentage sets a minimum heap." It is the percentage used when the container has
less than about 96 MB. InitialRAMPercentage is the starting heap size.
"ZGC is strictly better." It trades 5 to 15 percent throughput for sub-millisecond pauses. For batch work that is a loss with no benefit.
"A CPU profile shows where the time goes." It shows where CPU goes. Time spent blocked on I/O is invisible in it, which for a latency investigation is usually most of the time.
Interview delivery note
Say this verbatim: "The container limit has to cover heap plus metaspace plus code cache
plus thread stacks plus direct buffers plus GC structures, so -Xmx equal to the limit is a
guaranteed kernel OOM kill with no OutOfMemoryError in the logs. I use MaxRAMPercentage
around 70 after measuring non-heap with Native Memory Tracking, because the default of 25
leaves three quarters of the memory you are paying for unused." The failure signature and
the corrected default with its justification.
The senior-versus-staff separator is wall-clock profiling for latency. A senior engineer
profiles the service and optimises what the profile shows. A staff engineer knows that a
blocked thread uses no CPU, so a CPU profile of a service waiting on a downstream call will
confidently point somewhere else, and uses -e wall instead: in one case the CPU profile said
regex compilation at 34 percent and the wall-clock profile said 47 percent waiting on a
socket. Both were real; only one was the latency.
The second signal is jcmd VM.native_memory summary as the first step for an OOM kill,
specifically to check the thread count. "Six hundred threads at 1 MB each is 600 MB of stacks
that nobody budgeted" is a five-second diagnosis for a failure that otherwise looks like a
leak.
Further reading
- JDK-8146115 and the
UseContainerSupportdocumentation, for what container awareness does and does not do. - JEP 439 (Generational ZGC), for the design and the reason non-generational ZGC needed more headroom.
- async-profiler's documentation, particularly the wall-clock mode and the
perf_event_paranoidrequirement. - The Native Memory Tracking documentation (
jcmd VM.native_memory), for measuring the non-heap budget rather than estimating it.