Java JIT, GC and Memory on JDK 26, Measured

JIT warm-up 349→46 µs, -Xint 20× slower, escape analysis, four GCs compared from real -Xlog:gc lines, a G1 OOM trap and a static-map leak found with jcmd.

Series: Java lecture 6/6 (advanced). For developers who can write correct Java and now want to know what the JVM does with it: when code becomes fast, where memory goes, and why the same program behaves differently under another flag. After this lecture you can read -Xlog:gc output, pick a collector from measurements instead of folklore, find a leak with jcmd, and spot a benchmark that is lying to you.

Most "Java is slow" reports come from one of three places: measuring before the JIT has compiled anything, a collector chosen by habit, or memory that only grows. This lecture takes each one apart with small programs, and every number below comes from a run on the machine described next. Where our results surprised us (a heap that ran out at half its size, an attach that failed while a leak was dying) we kept the raw output.

Environment: OpenJDK 26 (Temurin 26+35), Apple Silicon Mac (M1 Pro, 8 cores, 16 GB), macOS. Default heap on this machine: 4 GB maximum, G1. Most timings are the median of 3 runs; the JIT warm-up runs show each batch separately because the warm-up is the point.

JIT warm-up measured: the first batches vs steady state

HotSpot starts every method in the bytecode interpreter, counts calls and loop iterations, and compiles hot methods in tiers: C1 with profiling first (tier 3), then C2 using that profile (tier 4). To see it, time the same small batch 2,000 times:

/** Time the same small batch 2,000 times and watch the JIT kick in. */
public class Warmup {
    static int collatzSteps(long n) {                // pure CPU, no allocation
        int steps = 0;
        while (n != 1) { n = (n & 1) == 0 ? n >>> 1 : 3 * n + 1; steps++; }
        return steps;
    }
    static long batch() {                            // 1,000 calls per batch
        long total = 0;
        for (long n = 1; n <= 1_000; n++) total += collatzSteps(n);
        return total;
    }
    public static void main(String[] args) {
        int[] show = {1, 2, 3, 5, 10, 20, 50, 100, 200, 500, 1000, 2000};
        long check = 0; int k = 0;
        long start = System.nanoTime();
        for (int b = 1; b <= 2000; b++) {
            long t0 = System.nanoTime();
            check += batch();
            long us = (System.nanoTime() - t0) / 1_000;
            if (k < show.length && b == show[k]) { System.out.printf("batch %4d: %,6d us%n", b, us); k++; }
        }
        System.out.printf("total %,d ms, checksum %d%n", (System.nanoTime() - start) / 1_000_000, check);
    }
}
Batch Default run 1 Run 2 Run 3 -Xint C1 only (-XX:TieredStopAtLevel=1)
1 334 µs 349 µs 373 µs 919 µs 258 µs
2 215 µs 227 µs 209 µs 900 µs 109 µs
5 82 µs 81 µs 79 µs 908 µs 142 µs
50 75 µs 74 µs 76 µs 926 µs 95 µs
200 47 µs 49 µs 47 µs 1,011 µs 76 µs
2000 46 µs 45 µs 49 µs 1,043 µs 76 µs
total 117 ms 119 ms 116 ms 1,940 ms 174 ms

Three plateaus are visible: the first batch at roughly 350 µs (part interpreted, part compiling), about 75 µs from batch 5, and 46 µs from about batch 200. Steady state is 7.6× faster than batch 1. -XX:+PrintCompilation shows exactly when each step happened (columns: ms since start, compile id, % = on-stack replacement, tier):

20    8       3       Warmup::collatzSteps (38 bytes)
20    9 %     4       Warmup::collatzSteps @ 2 (38 bytes)
36  116       4       Warmup::collatzSteps (38 bytes)
37    8       3       Warmup::collatzSteps (38 bytes)   made not entrant: not used
...
44  127 %     4       Warmup::batch @ 4 (29 bytes)
45  128       4       Warmup::batch (29 bytes)

collatzSteps reached C2 (tier 4) after 36 ms, and the C1 version was thrown away ("made not entrant"). The second drop, around batch 100–200, matches the moment the caller batch() was compiled at tier 4 at 45 ms, which lets C2 inline collatzSteps into the loop. The C1 only column shows what C2 adds: C1 settled at 76 µs, C2 at 46 µs.

Flow diagram of tiered compilation. A method starts in the interpreter at about 910 microseconds per batch, the speed measured with -Xint. After a few thousand calls C1 compiles it with profiling, tier 3, and batches take about 75 microseconds. Using that profile C2 compiles it at tier 4 and batches settle at about 46 microseconds. If an assumption made by C2 breaks, the code is deoptimized and the method goes back to the interpreter.
The tiers one hot method went through on JDK 26, with the measured time per batch of 1,000 calls. -XX:+PrintCompilation showed the tier changes.

-Xint vs the default: what the JIT is worth

-Xint turns the JIT off. Every batch then costs about 900 µs, and the whole run took 1,940 ms instead of 117 ms: 16.6× slower in total, about 20× at steady state (910 µs vs 46 µs). The interpreter never improves, which is why its column is flat. -Xint is useful for one thing: if a bug disappears under -Xint, suspect a JIT-related issue (or a data race the compiler made visible). Never use it to make timings "fair".

Escape analysis, briefly

C2 checks whether an object can be seen outside the method that created it. If not, it can skip the allocation and keep the fields in registers ("scalar replacement"). A record that never leaves the loop:

record Point(double x, double y) {}

static double sumDistances(int n) {
    double s = 0;
    for (int i = 0; i < n; i++) {
        Point p = new Point(i, i + 1);            // never stored, never returned
        s += Math.sqrt(p.x() * p.x() + p.y() * p.y());
    }
    return s;
}

We counted bytes allocated by the thread with com.sun.management.ThreadMXBean.getThreadAllocatedBytes for 50 million iterations per round:

== default (EA on)
round 1:   57 ms, allocated      7 MB
round 3:   50 ms, allocated      0 MB
round 5:   49 ms, allocated      0 MB
== -XX:-DoEscapeAnalysis
round 1:  326 ms, allocated  1,525 MB
round 3:   80 ms, allocated  1,525 MB
round 5:  117 ms, allocated  1,525 MB

1,525 MB is exactly 50 million × 32 bytes, one Point per iteration. With escape analysis on, steady state allocated nothing. The practical lesson is not "turn it off"; it is that small, short-lived objects in hot loops are often free, so do not hand-inline records for speed without measuring. Escape analysis gives up when the object is stored in a field, passed to a method that is not inlined, or the method is too large to inline.

Four garbage collectors on the same program

An allocation-heavy program: 20 million byte[] of 16–495 bytes (seeded Random(42)), with 1 in 8 kept in a 200,000-slot ring so there is a live working set of roughly 50 MB.

Random rnd = new Random(42);
byte[][] live = new byte[200_000][];          // the long-lived working set
for (int i = 0; i < 20_000_000; i++) {
    byte[] b = new byte[16 + rnd.nextInt(480)];
    b[0] = (byte) i;
    if ((i & 7) == 0) live[rnd.nextInt(live.length)] = b;   // 1 in 8 survives a while
    checksum += b[0];
}

Run with java -XX:+Use<X>GC -Xmx1g -Xlog:gc:file=gc.log Churn. Real lines, trimmed:

G1        [0.874s][info][gc] GC(23) Pause Young (Normal) (G1 Evacuation Pause) 675M->207M(862M) 7.355ms
Serial    [1.218s][info][gc] GC(122) Pause Young (Allocation Failure) 606M->449M(622M) 17.816ms
Parallel  [0.779s][info][gc] GC(32) Pause Young (Allocation Failure) 341M->104M(342M) 5.028ms
ZGC       [0.275s][info][gc] GC(3) Minor Collection (Allocation Rate) 432M(42%)->226M(22%) 0.047s

The ZGC line is the trap: 0.047 s is the length of the whole concurrent cycle while the program kept running, not a pause. Plain -Xlog:gc does not print ZGC's pauses at all; add gc+phases:

[0.375s][info][gc,phases] GC(5) Y: Pause Mark Start (Major) 0.009ms
[0.386s][info][gc,phases] GC(5) Y: Concurrent Mark 10.114ms
[0.386s][info][gc,phases] GC(5) Y: Pause Mark End 0.008ms
[0.387s][info][gc,phases] GC(5) Y: Pause Relocate Start 0.006ms

Summing every "Pause" line (Python script over the logs, 3 runs each):

Collector Wall time (median) Pauses Total pause Max pause CPU user time
G1 (default) 990 ms 26 107 ms 10.8 ms 1.09 s
Serial 1,283 ms 125 591 ms 20.7 ms 1.18 s
Parallel 844 ms 35 151 ms 9.2 ms 1.36 s
ZGC 939 ms ~60 0.4 ms 0.02 ms 1.69 s

CPU user time comes from separate runs of the same program under /usr/bin/time -p without logging.

Bar chart of total GC pause time. Serial 591 milliseconds over 125 pauses, Parallel 151 milliseconds over 35 pauses, G1 107 milliseconds over 26 pauses, ZGC 0.4 milliseconds over about 60 very short pauses. ZGC paused least but used the most CPU time.
Total stop-the-world pause time from -Xlog:gc for the same allocation-heavy program with -Xmx1g, median of 3 runs. ZGC's figure is the sum of its Pause phases from gc+phases.

Reading it: Parallel finished first because it throws all 8 cores at each pause and does nothing concurrently. ZGC almost never stopped the application but burned 55% more CPU than G1 doing its work on other threads, which you pay for on a busy server. Serial used one GC thread and, with its small default young generation, collected 125 times. G1 sits in the middle on every column, which is why it is the default. G1 run-to-run spread was large (835–1,087 ms), so differences under about 15% here are noise.

Heap sizing, OutOfMemoryError and a G1 surprise

Fill the heap with 1 MB blocks under -Xmx64m:

List<byte[]> keep = new ArrayList<>();
while (true) {
    keep.add(new byte[1 << 20]);
    if (keep.size() % 16 == 0) System.out.println("holding " + keep.size() + " MB");
}
max heap = 64 MB
holding 16 MB
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at Oom.main(Oom.java:10)

It never printed "holding 32 MB". A 64 MB heap ran out after 31 1 MB arrays. The log explains it: Pause Young (Normal) (G1 Humongous Allocation). With this heap G1 picked 1 MB regions (-XX:+PrintFlagsFinal shows G1HeapRegionSize = 1048576). Any object of half a region or more is humongous and gets whole regions to itself, and new byte[1048576] is 1 MB of data plus a 16-byte header, so it needs two regions. We counted how many arrays fit before the error:

Array size G1 Serial
1,048,576 bytes (1 MB) 31 (31.0 MB) 59 (59.0 MB)
1,048,560 bytes (1 MB − 16) 62 (62.0 MB) 60 (60.0 MB)
524,288 bytes 62 (31.0 MB) 120 (60.0 MB)
262,144 bytes 189 (47.3 MB) 242 (60.5 MB)

Sixteen bytes smaller doubled what G1 could hold. Buffers of exactly a power of two that are large relative to the region size are the worst case. Fixes: a larger heap (the region size grows with it), -XX:G1HeapRegionSize=, or slightly smaller buffers.

Two more real outputs worth knowing. -XX:+ExitOnOutOfMemoryError prints Terminating due to java.lang.OutOfMemoryError: Java heap space and exits with status 3, which a supervisor can restart, instead of a half-dead process. And when the heap is so full that even the error handler cannot allocate, you get only this, with no stack trace:

Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"

Common mistake: a memory leak via a static map

The mistake: a static HashMap used as a cache with a new key for every request.

static final Map<String, Session> CACHE = new HashMap<>();          // the leak
...
cache.put("req-" + req, new Session("user" + (req % 100), new byte[1024]));

What actually happens under -Xmx256m:

requests=   50,000  entries=   50,001  heapUsed=  63 MB
requests=  100,000  entries=  100,001  heapUsed= 120 MB
requests=  150,000  entries=  150,001  heapUsed= 180 MB
requests=  200,000  entries=  200,001  heapUsed= 253 MB

Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"

While it ran, jstat and jcmd, both shipped in the JDK, showed it from outside:

$ jcmd -l
69893 Leak
$ jstat -gcutil <pid> 3000
  S0     S1     E      O      M     CCS    YGC     YGCT     FGC    FGCT  ...
     - 100.00   0.00  91.67  73.19  20.54      8     0.009     0     0.000
     - 100.00   0.00  98.34  73.26  20.54     37     0.037     0     0.000
     -      -  33.33  67.30  73.84  20.54     44     0.054     1     0.024
     - 100.00  12.50  99.99  73.84  20.54     55     0.086     1     0.024
$ jcmd <pid> GC.class_histogram
 num     #instances         #bytes  class name (module)
   1:        227556       79768448  [B (java.base@26)
   2:        155023        3720552  java.lang.String (java.base@26)
   3:         75104        2403328  java.util.HashMap$Node (java.base@26)
   4:         72451        1738824  Leak$Session

The O column (old generation, % full) climbs back to 99.99 after a full GC (FGC = 1): live data, not garbage. In the histogram the Leak$Session and HashMap$Node counts move together, which points straight at the map. jcmd <pid> GC.heap_info added 86 old (86M), 1 humongous (1M). One more trap: on an earlier, faster run, jcmd failed with AttachNotSupportedException: ... state is not ready to participate in attach handshake! because the JVM was stuck in back-to-back full GCs. Attach early, or start with -XX:+HeapDumpOnOutOfMemoryError.

The fix: give the cache a bound. An access-ordered LinkedHashMap is a 5-line LRU:

static final Map<String, Session> LRU = new LinkedHashMap<>(16, 0.75f, true) {
    @Override protected boolean removeEldestEntry(Map.Entry<String, Session> e) {
        return size() > 10_000;
    }
};

Same program, same heap, 80 seconds: 600,000 requests, entries= 10,000 on every line, heap between 17 and 53 MB. (It is not thread-safe; for shared caches wrap it or use a real cache library.)

Startup: java File.java vs a compiled class

java Hello.java compiles the source in memory on every launch. Medians of 20 launches each, timed from a Python script:

Command Median
java Hello.java (source launch) 291 ms
java -Xshare:off -cp cls Hello 64 ms
java -cp hello.jar Hello 43 ms
java -cp cls Hello 39 ms
java -XX:AOTCache=hello.aot -cp hello.jar Hello 35 ms
Bar chart of startup time. java Hello.java, which compiles in memory first, took 291 milliseconds. A compiled class with class data sharing turned off took 64 milliseconds. A jar took 43, a class directory 39, and a jar with an AOT cache 35 milliseconds.
Wall time to run a hello-world, median of 20 runs each, measured from a Python script that launches the process.

Source launch costs about 250 ms of compiling each time: fine for scripts and lessons, wrong for a CLI tool called in a loop. -Xshare:off disables the class data sharing archive the JDK ships with and adds about 25 ms. Our first AOT cache attempt failed with real errors:

$ java -XX:AOTCacheOutput=hello.aot -cp cls Hello
Error occurred during CDS dumping
Cannot have non-empty directory in paths

The AOT cache only accepts JARs on the class path. After packing a JAR with the JDK's jar tool, -XX:AOTCacheOutput wrote a 10 MB cache and startup dropped from 43 to 35 ms. Before that, because the first step had failed, -XX:AOTCache=hello.aot pointed at a file that did not exist: the JVM printed [error][aot] Unable to map shared spaces, ran the program anyway without the cache and exited with status 0, which a build script would not notice.

Honest benchmarking caveats (no JMH)

Every number above was measured with System.nanoTime() in plain loops, not JMH, which this setup did not include. That works only because the effects are large. What can go wrong with hand-written timing:

  • No warm-up: batch 1 was 7.6× slower than steady state. Always discard early rounds, and show them if warm-up matters to your users.
  • Dead code: if a result is not used, C2 may delete the work. Every program here prints a checksum.
  • Inlining changes the subject: the second speed-up came from compiling the caller. A micro-benchmark measures your method in one specific inlining context.
  • Noise: G1 wall time varied 835–1,087 ms between identical runs. Repeat, report medians, and distrust differences under ~15%.
  • Machine: an M1 Pro has 6 performance and 2 efficiency cores; the scheduler decides which you get.

For anything closer than about 2×, use JMH.

Decision table and version notes

Goal Do this Evidence above
Fair micro-timing warm up, print a checksum, repeat, take the median 349 → 46 µs
Short-lived CLI compiled JAR, consider an AOT cache 291 vs 35 ms
Throughput batch job Parallel GC shortest wall time, 844 ms
Latency-sensitive service ZGC, budget the extra CPU 0.02 ms max pause, +55% CPU
Not sure G1 (default) middle on every column
Large buffers on a small heap avoid exact power-of-two sizes ≥ half a region 31 vs 62 arrays
Suspected leak jstat -gcutil, then jcmd GC.class_histogram O back at 99.99% after full GC
  • Checked on JDK 26 only. ZGC is generational by default since JDK 23 (the Y:/O: lines above); the one-step -XX:AOTCacheOutput flag was added in JDK 25 and the AOT cache itself in JDK 24 (JEP 483). We did not run older JDKs to confirm these.
  • Source-file launch (java Hello.java) exists since JDK 11; multi-file source programs since JDK 22. Only the single-file form was used here.
  • The jfr command-line tool was not present in this JDK image; jcmd and jstat were.

This is the last Java lecture. Lecture 5 covered the thread side of the same runtime.


Previous: Lecture 5: virtual threads and concurrency · Back to the start: Lecture 1: types, strings and equality

Related: Python lecture 6: profiling and optimization · VB.NET lecture 6: VB.NET in .NET 10 and C# interop

FAQ

How long does JIT warm-up take in Java?

In our test a batch of 1,000 calls took 349 µs the first time, about 75 µs from batch 5 (C1-compiled) and 46 µs from about batch 200 (C2-compiled), all within 0.1 s. The same batch took about 910 µs with -Xint, which disables the JIT. Short benchmarks that do not warm up mostly measure the interpreter and compiler.

Which garbage collector should I use on JDK 26?

Keep the default G1 unless you have a measured reason. On an allocation-heavy test with -Xmx1g, Parallel had the shortest wall time (844 ms) and ZGC the shortest pauses (0.4 ms in total, 0.02 ms max) but used about 55% more CPU than G1. Serial paused for 591 ms in total across 125 collections.

Why did my Java program run out of memory at half of -Xmx?

With G1 on a small heap, objects of half a region or more are 'humongous' and take whole regions. With -Xmx64m the region size is 1 MB, so a byte[1048576] plus its 16-byte header needs two regions: only 31 such arrays fit. Arrays 16 bytes smaller fit 62. Serial GC fitted 59 of the 1 MB arrays.

  • #Java
  • #JVM
  • #JIT
  • #garbage collection
  • #memory leak
  • #jcmd
  • #JDK 26
  • #advanced

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑