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.
-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.
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 |
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:AOTCacheOutputflag 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
jfrcommand-line tool was not present in this JDK image;jcmdandjstatwere.
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.