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 -Xmx or -Xms is 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 -flags reads active HotSpot flags without restarting.
  • jstat -gcutil shows per-generation utilization percentages (S0, S1, Eden, Old Gen).
  • jcmd GC.heap_info gives live heap summary — regions used, RSS, metaspace footprint.
  • free -m provides 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 evacuation
  • G1 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_infototal 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.