Java Module 16 – JVM internals, memory, GC and performance
The JVM in containers and key flags
How the JVM sizes its heap and threads from container limits, how to set them with MaxRAMPercentage or -Xmx, and how to check the flags it really uses.
What you will learn
- Explain how the JVM sizes its heap and threads from container limits
- Set the heap deliberately with -XX:MaxRAMPercentage or -Xmx
- Inspect effective flags with -XX:+PrintFlagsFinal and jcmd VM.flags
Before you start
What you’ll learn · Before you start
What you will learn
- Explain how the JVM sizes its heap and threads from container limits
- Set the heap deliberately with -XX:MaxRAMPercentage or -Xmx
- Inspect effective flags with -XX:+PrintFlagsFinal and jcmd VM.flags
Before you start
On this page
The JVM in containers sizes itself from what it detects: the memory it may use and the processors it may run on.
On Linux, its container support, on by default, reads the container’s memory and CPU limits from the kernel’s control
groups, so a JVM in a container with a 512 MB limit treats 512 MB as its physical memory. From that number it picks
its maximum heap, 25 % by default (-XX:MaxRAMPercentage), and from the processor count it picks its garbage
collector and its thread pools. A 512 MB container therefore gets a heap of only 128 MB unless you ask for more. The
key flags let you decide instead of the defaults: -XX:MaxRAMPercentage gives the heap a share of whatever limit the
container has, and -Xmx gives it a fixed size. This lesson measures the defaults and shows how to read the flags a
JVM actually runs with.
How a JVM in a container sizes itself
Text description of the diagram
The diagram runs from top to bottom.
- Container limits: the memory.max and cpu.max values of the container's control group.
- What the JVM detects, through its container support on Linux: the memory limit as its memory, and the CPU quota, rounded up, as its processors.
- From that view, ergonomics choose the heap: a maximum of 25 % of memory, 50 % if the memory is very small, and an initial size of 1/64.
- Then the collector on JDK 25: G1 with two or more processors and at least 1,792 MB of memory, Serial otherwise.
- Then the thread counts: the garbage collector's threads, the JIT compiler's threads and the common ForkJoinPool.
A dashed box with a dashed arrow to the JVM's view notes that your own flags override these defaults: -XX:MaxRAMPercentage, -Xmx, -XX:ActiveProcessorCount, -XX:+UseG1GC and the others.
Default heap sizes, measured
-XX:MaxRAM sets the memory that the heap arithmetic starts from, which is what a container’s memory limit does on
Linux. On this Mac, it lets one machine show the defaults for six container sizes:
import os
import re
import subprocess
from session import JDKS, run
# How the JVM sizes its heap from the memory it may use. -XX:MaxRAM sets that memory for the heap arithmetic, which
# is what a container's memory limit does on Linux; on this Mac it lets one machine show every size.
run("java -XX:MaxRAM=1g -XX:+PrintFlagsFinal -version | grep -E ' (MaxRAM|MaxRAMPercentage|InitialHeapSize|MaxHeapSize) '")
def flags(*options):
env = dict(os.environ, PATH=os.pathsep.join([str(JDKS["25"]), os.environ.get("PATH", "")]))
out = subprocess.run(["java", *options, "-XX:+PrintFlagsFinal", "-version"], env=env, capture_output=True, text=True).stdout
return {m.group(1): int(m.group(2)) for m in re.finditer(r" (MaxHeapSize|InitialHeapSize) += (\d+) ", out)}
print("# The same arithmetic for six memory sizes, in MiB (1 MiB = 1,048,576 bytes)")
print(f"{'memory':>8} {'initial heap':>13} {'max heap':>9} {'share':>6} {'max with MaxRAMPercentage=75':>29}")
for size, mib in [("256m", 256), ("512m", 512), ("1g", 1024), ("2g", 2048), ("4g", 4096), ("8g", 8192)]:
default = flags(f"-XX:MaxRAM={size}")
tuned = flags(f"-XX:MaxRAM={size}", "-XX:MaxRAMPercentage=75")
mx = default["MaxHeapSize"] / 2**20
print(f"{mib:>8} {default['InitialHeapSize'] / 2**20:>13.0f} {mx:>9.0f} {100 * mx / mib:>5.0f}% {tuned['MaxHeapSize'] / 2**20:>29.0f}") Output
# JDK in use: openjdk version "25.0.4.1" LTS
$ java -XX:MaxRAM=1g -XX:+PrintFlagsFinal -version | grep -E ' (MaxRAM|MaxRAMPercentage|InitialHeapSize|MaxHeapSize) '
openjdk version "25.0.4.1" LTS
OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode, sharing)
size_t InitialHeapSize = 16777216 {product} {ergonomic}
size_t MaxHeapSize = 268435456 {product} {ergonomic}
uint64_t MaxRAM = 1073741824 {pd product} {command line}
double MaxRAMPercentage = 25.000000 {product} {default}
# The same arithmetic for six memory sizes, in MiB (1 MiB = 1,048,576 bytes)
memory initial heap max heap share max with MaxRAMPercentage=75
256 8 128 50% 192
512 8 128 25% 384
1024 16 256 25% 768
2048 32 512 25% 1536
4096 64 1024 25% 3072
8192 128 2048 25% 6144
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 heap_session.py
The rule has three parts. The maximum heap is 25 % of the memory, the MaxRAMPercentage default. For small memory
the JVM gives the heap a larger share, up to MinRAMPercentage, 50 % by default, so that a heap of about 125 MB stays
possible; that is why 256 MB of memory got a 128 MB heap, half of it, and 512 MB got the same 128 MB, a quarter. The
initial heap is 1/64 of the memory, the documented 1.5625 % of InitialRAMPercentage, but never less than 8 MB here.
With -XX:MaxRAMPercentage=75, the maximum heap became three quarters of the memory at every size.
A quarter of the memory is a cautious default for a machine that runs many programs. A container usually runs one Java process, so a heap of 25 % leaves most of the limit unused, and the service runs with a quarter of the memory it pays for. The rest of the limit is still needed, though, for memory outside the heap, which is why a share between 50 % and 75 % is a sensible starting point, to be checked by measuring, and 100 % never is.
The two ways of setting the heap suit different deployments. A percentage follows the limit: the same image, started
in a container of 1 GB or of 4 GB, gets a heap that fits each one, so it is the better default for images that run
in containers of several sizes. A fixed -Xmx stays the same whatever the limit, which suits a service whose live
data you have measured, as long as the limit is changed together with it.
Key flags for the JVM in containers
The key flags, each in one sentence:
-XX:MaxRAMPercentage=75sets the maximum heap to 75 % of the container’s memory limit, whatever that limit is.-Xmx2gsets the maximum heap to exactly 2 GB, and wins over the percentage flags.-Xmssets the initial heap; equal to-Xmx, the heap never has to grow.-XX:ActiveProcessorCount=2makes the JVM size its thread pools for two processors.-Xsssets the stack size of each thread, 1 MB on Linux on x64 and 2 MB on Arm by default.-XX:MaxMetaspaceSizeand-XX:MaxDirectMemorySizecap two of the kinds of memory outside the heap.-XX:+ExitOnOutOfMemoryErrorends the JVM at its firstOutOfMemoryError, so the container restarts it.-XX:+UseSerialGCor-XX:+UseG1GCchooses the collector instead of the defaults.-XX:NativeMemoryTracking=summarymakes the JVM count the memory it uses outside the heap, by category.
Checking the flags a JVM runs with
What a JVM decided can be read in three ways: at start-up, from the system it detected, and from outside while it runs.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
/** A stand-in for a service: each line of standard input is a number of worker threads to start, which then wait. */
public class Service {
public static void main(String[] args) throws Exception {
var in = new java.io.BufferedReader(new java.io.InputStreamReader(System.in));
List<Thread> workers = new ArrayList<>();
CountDownLatch stop = new CountDownLatch(1);
for (String line; (line = in.readLine()) != null; ) {
int n = Integer.parseInt(line.trim());
for (int i = 0; i < n; i++) {
Thread t = Thread.ofPlatform().daemon().start(() -> {
try { stop.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
workers.add(t);
}
System.out.println(workers.size() + " worker threads started");
}
stop.countDown();
}
} from session import background, run
# Three ways to see the flags a JVM runs with: at start-up, from the system it detected, and from a running JVM.
run("""
java -XX:+PrintCommandLineFlags -version
java -XshowSettings:system -version
javac -d out Service.java
""")
print("# Terminal 1: a service started with two flags of its own")
service = background("java -Xmx256m -XX:+ExitOnOutOfMemoryError -cp out Service")
service.send("4")
print("# Terminal 2")
run("jcmd <pid> VM.flags", version=False)
print("# Terminal 1")
service.close() Output
# JDK in use: openjdk version "25.0.4.1" LTS
$ java -XX:+PrintCommandLineFlags -version
-XX:-AOTInvokeDynamicLinking -XX:-AOTRecordTraining -XX:-AOTReplayTraining -XX:ConcGCThreads=4 -XX:G1ConcRefinementThreads=14 -XX:InitialHeapSize=805306368 -XX:MarkStackSize=4194304 -XX:MaxHeapSize=12884901888 -XX:MinHeapSize=6815736 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedOops -XX:+UseG1GC
openjdk version "25.0.4.1" LTS
OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode, sharing)
$ java -XshowSettings:system -version
Operating System Metrics:
No metrics available for this platform
openjdk version "25.0.4.1" LTS
OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode, sharing)
$ javac -d out Service.java
# Terminal 1: a service started with two flags of its own
$ java -Xmx256m -XX:+ExitOnOutOfMemoryError -cp out Service
> 4
4 worker threads started
# Terminal 2
$ jcmd <pid> VM.flags
<pid>:
-XX:-AOTInvokeDynamicLinking -XX:-AOTRecordTraining -XX:-AOTReplayTraining -XX:CICompilerCount=12 -XX:ConcGCThreads=4 -XX:+ExitOnOutOfMemoryError -XX:G1ConcRefinementThreads=14 -XX:G1EagerReclaimRemSetThreshold=10 -XX:G1HeapRegionSize=1048576 -XX:G1RemSetArrayOfCardsEntries=10 -XX:G1RemSetHowlMaxNumBuckets=8 -XX:G1RemSetHowlNumBuckets=4 -XX:InitialHeapSize=268435456 -XX:MarkStackSize=4194304 -XX:MarkStackSizeMax=536870912 -XX:MaxHeapSize=268435456 -XX:MaxNewSize=160432128 -XX:MinHeapDeltaBytes=1048576 -XX:MinHeapSize=8388608 -XX:NonNMethodCodeHeapSize=7618560 -XX:NonProfiledCodeHeapSize=122028032 -XX:ProfiledCodeHeapSize=122028032 -XX:ReservedCodeCacheSize=251674624 -XX:+SegmentedCodeCache -XX:SoftMaxHeapSize=268435456 -XX:+UseCompressedOops -XX:+UseG1GC -XX:-UseNUMA -XX:-UseNUMAInterleaving
# Terminal 1
> (end of input)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 flags_session.py
-XX:+PrintCommandLineFlags prints the flags that were given and those that ergonomics chose, such as the collector
and the heap sizes. -XshowSettings:system is documented as Linux only: there it prints the container’s memory and
CPU limits as the JVM detected them, and on this Mac it printed that no metrics are available. jcmd <pid> VM.flags
asks a running JVM for every flag that differs from its default, the ones from the command line included, here
-XX:MaxHeapSize=268435456 from -Xmx256m and -XX:+ExitOnOutOfMemoryError. For one flag at a time,
-XX:+PrintFlagsFinal lists every flag with its value and where it came from: {default}, {ergonomic} or
{command line}. On Linux, -Xlog:os+container=trace adds a log line for every limit the JVM reads, which is the
quickest way to see why a limit seems to be ignored.
Check yourself
Check yourself
4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
A memory budget for a container, with numbers Pro: opens with a Pro, Premium or Ultimate pass
A container’s memory limit covers the whole process, and the heap is only part of it. This part measures the rest with Native Memory Tracking, builds budgets for three limits, and explains the exit status 137 that a container reports when the kernel ends a JVM.
In this part:
- Where the memory goes besides the heap, measured
- Budgets for 512 MB, 1 GB and 4 GB containers
- Exit status 137: the kernel, not the JVM
1 runnable example (Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
CPU limits, collector choice and thread counts, measured Pro: opens with a Pro, Premium or Ultimate pass
The processor count decides more than speed: it picks the collector and sizes every pool of threads in the JVM and in the libraries above it. This part measures those choices for one to eight processors.
In this part:
- One, two, four and eight processors
- Fractional CPU limits and what changes in JDK 27
2 runnable examples (Java, Python) · about 2 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Your turn: flags for three services, with a rubric Pro: opens with a Pro, Premium or Ultimate pass
Choosing flags is arithmetic with a few rules, and it can be checked. This part sets three services with their container limits, works out the flags for each, and gives a rubric for marking an answer.
In this part:
- Three services, their limits and the question
- A worked answer
- How to mark the answer
about 2 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
How does a JDK 25 JVM choose its maximum heap inside a Linux container with a 2 GB memory limit and no heap flags?
On Linux, the JVM's container support reads the container's memory limit and treats it as the machine's physical memory. Its ergonomics then set the maximum heap to MaxRAMPercentage of that memory, 25 % by default, so a 2 GB limit gives a 512 MB heap, and the initial heap to 1/64, 32 MB. For very small memory, the JVM allows up to MinRAMPercentage, 50 %, so that a heap of about 125 MB stays possible: a 256 MB container gets a 128 MB heap. To use more of the limit, set -XX:MaxRAMPercentage, for example 70, which follows whatever limit the container has, or a fixed -Xmx. java -XX:+PrintFlagsFinal -version | grep MaxHeapSize shows the result.
-
Mid to senior
A Java service in a container with a 1 GB limit is restarted several times a day with exit status 137, yet its GC logs show the heap never above 600 MB. What is happening, and how do you fix it?
-
Senior to staff
A service in a container limited to 1 CPU and 1 GB uses parallel streams and shows long garbage collection pauses on JDK 25. What did JVM ergonomics choose, and what would you change?
-
Mid to senior
Review this start command for a web service in a container with a 2 GB memory limit and 2 CPUs: java -Xmx2g -Xss8m -XX:+UseParallelGC -jar app.jar. What is wrong, and what would you write instead?
Key takeaways
- On Linux, the JVM in containers reads the memory and CPU limits and sizes itself from them.
- By default the heap gets 25 % of the memory (50 % for very small memory);
-XX:MaxRAMPercentageor-Xmxsets it deliberately. -XX:+PrintFlagsFinal,-XX:+PrintCommandLineFlagsandjcmd <pid> VM.flagsshow the values a JVM really uses.
References
- Garbage Collection Tuning Guide, JDK 25: Ergonomics (Oracle)
- The java Command (JDK 25 tool reference) (Oracle)
- The jcmd Command (JDK 25 tool reference) (Oracle)
- Troubleshooting Guide, JDK 25: Native Memory Tracking (Oracle)
- JEP 523: Make G1 the Default Garbage Collector in All Environments (OpenJDK)
- ForkJoinPool (Java SE 25 API), the common pool (Oracle)
- HotSpot source, JDK 25: cgroupUtil_linux.cpp, the processor count from a CPU quota (OpenJDK)
- Bash Reference Manual: Exit Status (GNU Project)
- Control Group v2 (Linux kernel documentation), memory.max and cpu.max (The Linux Kernel documentation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress