Establishing a JVM Performance Baseline with JDK Diagnostics
Every JVM application deserves a baseline — a snapshot of how the process actually behaves under its default configuration, measured with the same tools you’d use in production when something goes sideways. Without one, “tuning” is just guesswork.
This post walks through setting up that baseline using only core JDK utilities (no APM agent required) and correlating them with OS-level metrics.
The code
The application uses the JVM’s own Management API to report its baselines internally, then allocates ~40 MB of heap to generate measurable GC activity. This mirrors what a real service does: allocate objects, trigger GC, keep running.
import java.lang.management.*;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
public class BaselineMonitor {
public static void main(String[] args) throws Exception {
printJvmBaselines();
System.out.println("--- Generating heap pressure ---");
CopyOnWriteArrayList<byte[]> holders = new CopyOnWriteArrayList<>();
for (int round = 0; round < 5; round++) {
byte[] chunk = new byte[8_000_000]; // ~8 MB
for (int i = 0; i < chunk.length; i += 4096)
chunk[i] = (byte) (round + 1);
holders.add(chunk);
Thread.sleep(200);
}
System.out.println("Allocation complete.");
// Window for diagnostics tools to attach
Thread.sleep(10_000);
System.out.println("--- Post-GC JVM state ---");
printJvmBaselines();
}
private static void printJvmBaselines() {
MemoryMXBean memBean = ManagementFactory.getMemoryMXBean();
java.lang.management.MemoryUsage heap = memBean.getHeapMemoryUsage();
System.out.printf(" Heap max: %,10d bytes (%.2f MB)%n",
heap.getMax(), heap.getMax() / 1_048_576.0);
System.out.printf(" Heap init: %,10d bytes (%.2f MB)%n",
heap.getInit(), heap.getInit() / 1_048_576.0);
List<GarbageCollectorMXBean> gcBeans = ManagementFactory.getGarbageCollectorMXBeans();
System.out.printf(" GC collectors: %d%n", gcBeans.size());
for (GarbageCollectorMXBean gc : gcBeans) {
System.out.printf(" %-20s count=%8d time=%7d ms%n",
gc.getName(), gc.getCollectionCount(), gc.getCollectionTime());
}
long[] threadIds = ManagementFactory.getThreadMXBean().getAllThreadIds();
System.out.printf(" Total threads: %d%n", threadIds.length);
System.out.printf(" CPU (Avail): %d%n", Runtime.getRuntime().availableProcessors());
CompilationMXBean comp = ManagementFactory.getCompilationMXBean();
if (comp != null)
System.out.printf(" Compilation: %s (%d ms)%n",
comp.getName(), comp.getTotalCompilationTime());
}
}
Key design points:
- No
-Xmxor-Xmsis passed. The JVM sizes the heap automatically from available physical RAM. - Each 8 MB allocation touches every page (offset 4096) so the OS actually commits physical memory — without touching, the heap numbers would be phantom allocations.
- The 10-second
sleep()after allocation gives you a window to attach jcmd and jstat from another terminal, exactly how this works in production: the app runs and you poke it externally.
For external diagnostics (run on a separate terminal against the running JVM):
jinfo -flagsreads active HotSpot flags without restarting.jstat -gcutilshows per-generation utilization percentages (S0, S1, Eden, Old Gen).jcmd GC.heap_infogives live heap summary — regions used, RSS, metaspace footprint.free -mprovides the OS-level view of host memory pressure.
Running it
Here is what that full flow produced on this run:
=== Compiling BaselineMonitor.java ===
Compilation complete.
=== Starting JVM (prints internal baselines + waits 10s) ===
Capturing external diagnostics simultaneously...
=== JVM Baseline Report ===
Heap max: 32,178,700,288 bytes (30688.00 MB)
Heap init: 2,046,820,352 bytes (1952.00 MB)
GC collectors: 3
G1 Young Generation count= 0 time= 0 ms
G1 Concurrent GC count= 0 time= 0 ms
G1 Old Generation count= 0 time= 0 ms
Total threads: 6
CPU (Avail): 10
Compilation: HotSpot 64-Bit Tiered Compilers (22 ms)
=== End Report ===
--- Generating heap pressure ---
Allocation complete.
--- jinfo flags ---
-XX:CICompilerCount=4 -XX:ConcGCThreads=2 ...
-XX:MaxHeapSize=32178700288 -XX:+UseG1GC
--- jstat -gcutil ---
S0 S1 E O M CCS YGC YGCT FGC FGCT CGC CGCT GCT
- - 33.33 0.00 - - 0 0.000 0 0.000 0 0.000 0.000
--- jcmd GC.heap_info ---
garbage-first heap total 2015232K, used 53450K [0x82000000, 0x80000000)
region size 16384K, 4 young (65536K), 0 survivors (0K)
Metaspace used 1414K, committed 1600K, reserved 1114112K
eeOS: free memory ---
total used free shared buff/cache available
Mem: 124607 72155 10111 1138 44515 52452
Swap: 16383 6977 9406
Let me walk through what each piece tells you.
Heap sizing is automatic — and it surprised me
With zero explicit heap flags, the JVM chose a maximum heap of ~30 GB (32,178,700,288 bytes) on a machine with 124 GB total RAM. That’s roughly one-quarter of available memory.
This is HotSpot’s default behavior in JDK 21: the JVM heuristically allocates about 25% of physical RAM as max heap when no -Xmx is given. On our run, that came out to 30 GB — not exactly 1/4 of MemTotal (127 GB) but closer to 1/4 of available memory at startup.
This matters for deployment: if you containerize this app and the container has a 4 GB limit, max heap will drop to ~1 GB. Always set -Xmx explicitly in production so it doesn’t silently depend on host RAM.
No GC happened — because no GC was needed
After allocating 40 MB across five rounds, jstat -gcutil shows Eden at 33.33% used and Old Gen at 0%. Critically, YGC (young gen collections) is still zero.
This is where the intuitive model breaks: you might expect 40 MB of allocations to trigger a young gen GC cycle. But because the JVM’s heap was initialized at ~1.9 GB and could grow up to ~30 GB, there was plenty of room — the Eden space simply expanded rather than triggering collection.
jcmd GC.heap_info confirms this: the heap has 2,015,232 KB total with only 53,450 KB used (~52 MB). The four young regions (65,536 KB) absorbed everything without needing to evacuate anything.
Lesson: jstat -gcutil showing Eden filling up is a leading indicator — once it crosses 70-80%, the next allocation will trigger a young gen collection. At 33%, the collector hasn’t fired yet.
G1GC has three “collectors” in OpenJDK 21
The internal report shows three GarbageCollectorMXBean entries:
G1 Young Generation— handles short-lived object evacuationG1 Concurrent GC— background marking (runs as a daemon thread)G1 Old Generation— collects promoted objects when old gen fills up
This is specific to OpenJDK 21’s G1GC implementation. Not all JDK versions split these into separate beans; earlier JDKs showed just “G1 Young Gen” and “G1 Old Gen.” The concurrent collector bean is new.
Compilation warmup is real but bounded
The CompilationMXBean reports 22 ms of HotSpot C1+C2 compiler time for 1,057 loaded classes. In a real service this is the cost of JIT compiling every method that reaches invocation count threshold — typically during startup and under initial traffic.
The -XX:CICompilerCount=4 flag (visible in jinfo -flags) means four background compiler threads. For large codebases, increasing this can reduce warmup latency at the cost of CPU overhead.
The JVM used ~52 MB; the host had 124 GB — but the limit matters more
free -m shows the OS has 52 GB available (after buff/cache and other processes). The JVM itself only touched ~52 MB during this run’s allocation phase. If you’re capacity-planning a deployment, use jcmd GC.heap_info — total heap (2015232K ≈ 1.96 GB) — as the baseline RSS figure, not just used heap.
Takeaway
A JVM performance baseline is three things measured together: what flags the JVM chose by default (jinfo), how much heap/GC activity it actually has under load (jstat + jcmd GC.heap_info), and whether the OS has room to spare (free). Get these before you touch -Xmx, -XX:MaxGCPauseMillis, or any other tuning flag — otherwise you’re optimizing blind.
Once you have this baseline, tuning flags becomes a comparison exercise — before and after — rather than a guessing game.