A systematic guide to reading Java Flight Recorder dumps and turning the data into faster startup, lower memory pressure, and higher throughput.
When your Java application takes too long to start or burns too much CPU under load, intuition is a poor compass. Guessing at bottlenecks — "maybe it's the database queries," "maybe we have too many objects to wire up" — leads to wasted effort. You need data.
A profiler is a tool that records what a
program is actually doing while it runs. Unlike logs, which tell you
only what your application chose to report, a profiler observes the JVM
itself — where CPU time goes, what objects get allocated, which methods
get compiled, where threads wait, and how memory changes over time. Java
Flight Recorder (JFR) is the JVM's built-in profiler, designed to run
with very low overhead (typically well under 1–2% with default settings,
and still modest with the fuller profile configuration).
It captures an extraordinary breadth of telemetry: CPU sampling,
allocation profiling, GC behavior, lock contention, I/O activity, class
loading, JIT compilation — all from a single recording file. A single .jfr file is a rich forensic trace of what your JVM did during the recording window.
But a JFR file is not a report. It's a dense, binary event log containing over a hundred built-in event types. The skill is knowing which events to extract, how to interpret them, and what optimization they point to.
This post walks through a manual, systematic methodology
for analyzing a JFR recording. You can follow along using the JDK's
built-in jfr CLI, JDK Mission Control (JMC), or an IDE profiler such as IntelliJ's. The goal is not just to run a tool, but to understand what you're looking at and what to do about it.
Before you can analyze anything, you need a good
recording. The default JFR configuration captures a minimal set of
events. For startup or performance analysis, use the profile setting, which adds allocation sampling and other detail on top of the default:
# Attach to a running JVM
jcmd <PID> JFR.start settings=profile name=startup-analysis
# Wait for the event you care about (startup, request burst, etc.)
# then stop and dump to disk:
jcmd <PID> JFR.stop name=startup-analysis filename=recording.jfrFor startup analysis specifically, pass JFR flags directly to the JVM so recording begins at launch:
java -XX:StartFlightRecording=settings=profile,filename=startup.jfr \
-jar my-application.jarKey principle: Record exactly the window you care about. A 30-second recording during startup is far more actionable than a 5-minute recording that mixes startup, steady-state, and idle periods. The metrics become averages over too many phases, and the signal washes out.
A note on warmup: during startup the JVM is doing several expensive things at once — loading and linking classes, compiling hot methods, allocating the objects that make up your object graph, and initializing caches and connection pools. All of that activity is concentrated into a short window, which is exactly why startup recordings look so different from steady-state recordings: heavier class-loading and compilation cost, a burst of allocation, and comparatively little of the request-handling activity you'd see later. Don't be surprised if a startup profile and a steady-state profile of the same application barely resemble each other — they're capturing different phases of the JVM's life, and both are "correct."
A JFR recording spans several distinct dimensions of JVM behavior. Each answers a different question about your application. Rather than jumping between them haphazardly, work through them in this order — each section's findings inform the next:
| # | Dimension | Key Question |
|---|---|---|
| 1 | CPU Hotspots | Where is CPU time actually being spent? |
| 2 | Memory Allocation | What objects are being created, and from where? |
| 3 | GC / Heap Timeline | How is the heap growing, and how much time is spent collecting? |
| 4 | GC Pause Times | How long are stop-the-world pauses, and which GC phases cause them? |
| 5 | Class Loading | How many classes are loaded, and is that excessive? |
| 6 | Thread Creation | How many threads are spawned, and by which subsystems? |
| 7 | Lock Contention | Where are threads blocking each other? |
| 8 | JIT Compilation | How much CPU goes into compiling bytecode, and which methods cost the most? |
| 9 | Socket / File I/O | How much I/O is happening, to what, and how long does it wait? |
| 10 | CPU Load Timeline | Is the JVM CPU-bound, I/O-bound, or uncontested? |
| 11 | Thread Idle / Sleep Time | Which threads are idle or waiting, and for how long? |
| 12 | Before/After Comparison | Did your optimization actually help? |
Let's walk through each dimension with concrete examples of what to extract, what patterns to look for, and what optimizations each pattern suggests.
JFR does sampling profiling, not instrumentation. Instead of measuring every single method call — which would slow the application down considerably and change the very behavior you're trying to observe — the JVM periodically interrupts each running thread and records what it's doing. If a method shows up in a large share of the samples, it probably consumed a significant amount of CPU time; a method that never shows up may still have run, just not enough (or not at the right moments) to be caught.
Data source: jdk.ExecutionSample
events. These are periodic thread samples that capture the full stack
trace of a running thread. The sampling interval depends on your
recording configuration and JDK version rather than a single fixed
number — check the settings file (e.g., profile.jfc) or the
recording metadata if you need the exact value for your run. Each
sample counts as one "vote" — a method appearing in 500 samples was
on-CPU for roughly 500 sampling periods, so treat the resulting time
estimate as approximate, not exact.
A limitation worth knowing: sampling profilers can't reliably measure very short-lived methods, because a method can start and finish entirely between two samples and never get caught. A method with only a handful of samples in a large recording isn't necessarily unimportant — but it also isn't necessarily important. Don't over-interpret single-digit sample counts either way; focus your attention on methods with enough samples that the pattern is statistically meaningful.
Using the JDK's jfr CLI:
# Text mode gives full stack traces in a readable format
jfr print --events jdk.ExecutionSample recording.jfrEach block looks like this:
jdk.ExecutionSample {
sampledThread = "main" (javaThreadId = 1)
state = "STATE_RUNNABLE"
stackTrace = [
java.lang.ClassLoader.defineClass1(ClassLoader.java:0) ← LEAF (hottest)
java.lang.ClassLoader.defineClass(ClassLoader.java:539)
java.security.SecureClassLoader.defineClass(...)
java.net.URLClassLoader.defineClass(...)
java.net.URLClassLoader$1.run(...)
...
]
}
A quick glossary, since the rest of this post leans on this terminology constantly. A stack trace is the chain of method calls active at a single point in time — each individual call in that chain is a stack frame. Using a simplified example:
main()
└── loadUsers()
└── parseJson()
└── StringBuilder.append()
The root frame is the entry point (main() here). The leaf frame is the innermost, most-recently-called method (StringBuilder.append() here) — it's the one that was actually executing, on the CPU, at the instant the sample was taken.
The leaf frame (index 0 in the JFR output) tells you what is burning CPU. The full stack tells you why — the call chain that led there. Both matter: the same leaf method can appear under very different stacks, and grouping by leaf-only can hide the fact that two unrelated code paths are converging on the same expensive call.
Pattern A: ClassLoader.defineClass1 dominates the leaf samples
This means the JVM is spending a large share of its CPU defining classes — reading bytes from JAR files, parsing the classfile format, verifying bytecode, and linking into the runtime. On a cold startup of any JVM application with a non-trivial classpath, this can be a substantial fraction of samples, though the exact share varies widely by application size and JAR count.
Optimization: Use Class Data Sharing (CDS). CDS creates a shared archive containing preprocessed class metadata from a prior run. On later JVM starts, many of those classes can be mapped directly from the archive instead of being repeatedly read from JARs, parsed, verified, and linked from scratch — the expensive work happens once, when the archive is built, rather than on every single startup:
java -XX:ArchiveClassesAtExit=app-cds.jsa -jar my-app.jar java -XX:SharedArchiveFile=app-cds.jsa -jar my-app.jarThis typically produces a meaningful reduction in class-loading CPU time. Treat any specific percentage as a rough expectation, not a guarantee — measure before/after on your own app (see Dimension 12).
Pattern B: Inflater.inflateBytes high in the stacks
This is JAR decompression overhead. Every JAR entry is typically compressed; reading a class means decompressing it first. The more JARs on your classpath, the more inflation CPU you pay.
Optimization: CDS helps here too — the shared archive stores classes in an already-processed form so they don't need to be decompressed and parsed again on every startup. Also consider trimming unused JARs from your classpath.
Pattern C: Classpath/annotation-scanning code appears prominently
If your application uses a dependency-injection or
component-scanning framework (Spring, Micronaut, Quarkus/Jakarta CDI,
Guice with classpath scanning, etc.), startup often involves walking the
classpath looking for annotated types, reading .class
files, and building object-graph or bean metadata from what it finds. In
stack traces this typically shows up as framework-internal
classpath-scanner or annotation-reader classes.
Optimization: Most DI frameworks offer a way to avoid runtime scanning — build-time indexing (e.g., Spring's context indexer generates a component index at compile time), ahead-of-time processing (Spring AOT, Micronaut and Quarkus both compute much of this at build time by design), or simply scoping the scan to a narrower set of packages instead of the whole classpath. Check your framework's documentation for its specific mechanism.
Pattern D: Constructor.newInstance or Method.invoke appear heavily
This is reflection-driven instantiation. DI containers, ORMs, and serialization libraries (Spring, Hibernate/JPA, Guice, Jackson, and others) commonly use reflection for object creation, proxy generation, and field/property access.
Optimization: Many frameworks offer reflection-reduced or reflection-free code paths for common cases (e.g., Spring AOT, Micronaut's compile-time DI, Quarkus's build-time processing). A more drastic option is GraalVM Native Image, which compiles your application ahead-of-time into a standalone native executable — this eliminates JIT compilation and most runtime class loading entirely, since nearly all of that work happens at build time instead of at every startup. The trade-off: build times are much longer, you generally give up the JIT's runtime, profile-guided optimizations (so peak steady-state throughput can be lower than a warmed-up JVM), and anything relying on dynamic behavior — reflection, dynamic proxies, resources loaded by name, JNI — needs to be explicitly declared in build-time configuration, since native image can't discover it at runtime the way the JVM can.
Pattern E: Deep framework call chains with the same root pattern across many leaf methods
Look at the full stack signatures, not just the leaf. If you see the same multi-frame prefix (e.g., DispatcherServlet → Interceptor → Controller → Service → Repository)
with different leaves, the framework dispatch itself is a non-trivial,
if normal, cost. Worth quantifying, rarely worth fighting.
Also check which threads accumulate samples:
# Group samples by thread name
jfr print --events jdk.ExecutionSample recording.jfr | \
grep "sampledThread" | sort | uniq -c | sort -rnmainthread dominates → Startup analysis: the main thread is doing the heavy lifting (object graph construction, class loading, initialization).http-nio-*(or equivalent) threads dominate → Steady-state analysis: your request-handling threads are the bottleneck.C2 CompilerThread*appears heavily → JIT compilation is consuming significant CPU (see Dimension 8).
Not every hot method deserves your attention. Before spending time on something you found in a CPU profile, ask:
- Is it on a path that actually matters? A method that's hot exclusively during a one-time startup sequence matters a lot less than one that's hot on every request.
- Does it consume a meaningful share of samples? A method with a handful of samples out of tens of thousands is noise, not signal (see the sampling-bias note above).
- Is it actually user-visible? Shaving milliseconds off something nobody waits on doesn't move any metric that matters.
- Is it unavoidable framework overhead? Plenty of methods show up as "hot" simply because the framework you chose does real, necessary work on every call. Fighting that is often lower-leverage than fixing something in your own code, or accepting it as the cost of the framework.
It's easy to spend an afternoon optimizing the third-hottest method in a profile only to discover it accounted for 2% of total time. Chase the few things that materially move the numbers you actually care about — not everything the profiler happens to surface.
Data source: jdk.ObjectAllocationSample events (available from JDK 16 onward). The JVM samples object allocations, recording the type, an estimated size (weight),
and the allocation-site stack trace. This is a statistical profile of
allocation throughput, not a heap dump — it doesn't tell you what's
still alive, only what's being created.
jfr print --events jdk.ObjectAllocationSample --json recording.jfrThe JSON output includes objectClass.name and weight (an estimate of bytes allocated) for each sample, plus stackTrace.frames showing the allocation site.
Pattern A: byte[] is a large share of allocated bytes
This is extremely common in JVM applications. Almost everything allocates byte[]:
reading class files from JARs, deserializing JSON/XML, reading files,
building response buffers, processing request bodies. The question is
not really whether byte[] shows up near the top, but from where.
To find the allocation sites:
# Filter allocation stacks to only byte[] allocations
jfr print --events jdk.ObjectAllocationSample --json recording.jfr | \
python3 -c "
import json, sys
from collections import Counter
data = json.load(sys.stdin)
stacks = Counter()
for e in data['recording']['events']:
v = e['values']
cls = v.get('objectClass', {}).get('name', '')
if 'byte' in str(cls):
frames = v.get('stackTrace', {}).get('frames', [])
sig = ' -> '.join(f['method']['type']['name'] + '.' + f['method']['name'] for f in frames[:4])
stacks[sig] += v.get('weight', 0)
for sig, w in stacks.most_common(20):
print(f'{w:>12,d} {sig}')
"Typical culprits and their fixes:
| Allocation site | Root cause | Fix |
|---|---|---|
Resource.getBytes → ClassLoader.defineClass |
Reading class bytes from JARs | CDS (pre-loaded classes) |
StreamDecoder.readBytes → SocketChannel.read |
Network I/O buffers | Defer or cache HTTP calls |
ByteArrayOutputStream.write → Jackson/JSON |
Serialization | Streaming/incremental serialization where feasible |
ASM class-reading internals → ClassReader.accept |
Bytecode parsing | Build-time/AOT processing where your framework supports it (reduces runtime parsing) |
Pattern B: Framework internals dominate — annotation metadata, reflective Field/Method wrappers, proxy-related objects
These are your framework's internal representation of your object graph, annotations, and reflective access points, produced because the framework is doing runtime annotation processing and reflection to wire things up.
Optimization: Where available, build-time/AOT processing (offered in some form by most major DI frameworks) pre-computes much of this metadata instead of building it at runtime. CDS complements this by archiving the resulting classes and reducing re-parsing on subsequent runs.
Pattern C: Domain objects (your entities/DTOs) dominate
Your own data objects are the allocation leaders. This is application-level churn — consider:
- Object pooling for frequently created/discarded objects (only where profiling justifies it — pooling has its own costs)
- Reducing unnecessary DTO copies
- Using primitive fields over boxed types where it matters
- Caching instead of re-creating
A quick clarification before going further, because it's a common source of confusion: allocation is not the same thing as memory usage. Allocation rate measures how much memory you create over time, regardless of how long any of it survives. Heap usage measures how much is currently retained. You can allocate 100MB of short-lived objects, immediately discard 99MB of it, and end up with only 1MB actually sitting in the heap — the allocation rate was 100MB, but the retained footprint was 1MB. Both numbers matter, but they answer different questions: allocation rate tells you how hard the GC has to work; heap usage tells you how large your working set actually is.
That connects directly to GC pressure: the amount of work the garbage collector has to do because the application keeps creating new objects. High allocation rates generally translate to high GC pressure — more frequent young-generation collections — even when the application's overall heap usage stays modest, because it's the rate of churn that drives collection frequency, not the retained size.
Putting the two together: allocation rate is a primary driver of young-generation GC frequency, so the more you allocate, the more often minor collections run. Reducing total allocation generally reduces GC pause time and frequency, though the relationship isn't perfectly linear — look at allocation hotspots first; GC improvements often follow.
Data sources:
jdk.GCHeapSummary— periodic snapshots of heap usagejdk.GCPhasePause— stop-the-world pause events, with phase-level detail
Not every GC-related event is enabled under every JFR
configuration — the default recording is intentionally minimal. If these
events come back empty, confirm you recorded with the profile settings template (or explicitly enabled the events you need), not the default one.
# Heap growth over time
jfr print --events jdk.GCHeapSummary --json recording.jfr
# GC pauses with phase breakdown
jfr print --events jdk.GCPhasePause --json recording.jfrPlot heapUsed over startTime. The shape tells a story:
- Rapid ramp-up then flat → Normal startup. The heap grows as beans, caches, and connection pools initialize, then stabilizes at steady-state.
- Sawtooth pattern → Healthy GC. The heap grows between collections, then drops.
- Ever-increasing with no drops → Possible memory leak or heap under sustained pressure. The heap grows because objects are outliving what you'd expect.
- High peak usage relative to
-Xmx→ You're close to anOutOfMemoryError. Either increase the heap or reduce allocation.
Aggregate then break down by phase:
Pause count: 45
Total pause time: 1,230 ms
Avg pause: 27.3 ms
Max pause: 180 ms
Pause % of recording: 4.1%
Rough rules of thumb (these vary by GC algorithm and workload, so treat as starting points, not thresholds): total pause time well under 5% of the recording, with only young-generation pauses, is generally healthy for most workloads. What's more concerning:
- Total pause time climbing well past 5–10% → GC is struggling. Check allocation hotspots first (Dimension 2); reducing allocation is usually the cheapest way to reduce GC time.
- Individual pauses well above your latency budget (e.g., over 100ms for a service targeting sub-200ms p95 latency) → At least one long stop-the-world event is impacting user-facing latency.
- Any full/old-generation GC pause appears (naming depends on the collector — G1's
Pause Full, or full/major cycles under other collectors) → The old generation is under pressure. This is typically the most expensive kind of collection. Investigate:- Long-lived objects that should be short-lived (caching too aggressively?)
- Objects prematurely promoted to old gen (young gen too small relative to allocation rate?)
- A genuine memory leak (unbounded cache, unclosed resources)
By-phase breakdown tells you which type of collection dominates:
| Phase | Indicates |
|---|---|
| Young-generation pauses dominate | Heavy short-lived allocation churn (normal) |
| Full/old-gen pauses appear | Old-gen pressure (investigate) |
| Remark/reference-processing phases are large | Large heap + many references to scan |
Optimization for young-gen dominance: Tune young generation sizing (e.g.,
-XX:NewRatio, or your collector's equivalent) so objects die young instead of being promoted, or reduce allocation (Dimension 2).Optimization for old-gen/full GCs: Increase old generation headroom, tune promotion thresholds, or fix the underlying leak.
Note: the exact event and phase names above depend on which GC you're running (G1 is the default in modern JDKs; ZGC and Shenandoah expose different, mostly-concurrent phase events). Confirm the phase names in your own recording rather than assuming G1's naming.
Data source: jdk.ClassLoadingStatistics — cumulative counters of loaded and unloaded classes.
jfr print --events jdk.ClassLoadingStatistics --json recording.jfrThe last event in the series gives the final tally:
Classes loaded (final): 18,423
Classes unloaded (final): 47
Class counts vary a lot by application, but as a rough sense of scale:
| Application type | Illustrative class count |
|---|---|
| Small app with a web server + a handful of libraries | Low thousands to ~15,000 |
| Typical app-server app with several framework integrations (web, DI, ORM, messaging, etc.) | Higher, often 15,000–25,000+ |
| Large monolith with many modules and dependencies | Can exceed 25,000–30,000 |
| Minimal, dependency-light app | A few thousand |
Every class you load costs:
- Startup time — parsing, verifying, and linking the bytecode
- Metaspace memory — class metadata is stored off-heap in Metaspace (typically on the order of a few hundred bytes to a few KB per class, depending on the class)
- JIT compilation — more classes means more code that could potentially be compiled
Treat any "X classes ≈ Y MB of Metaspace" estimate as a ballpark, not a precise conversion — actual metadata size per class varies with class complexity.
High loaded count relative to app size → You may have more dependencies on the classpath than you need. Audit with:
# Count classes in each JAR
jar tf your-app.jar | grep '\.class$' | awk -F/ '{print $1}' | sort | uniq -c | sort -rnOptimization: Trim unused dependencies. Convenience dependency bundles (framework "starters," fat client libraries, umbrella artifacts) often pull in far more transitive classes than you actually use. Depend on narrower artifacts where available, or exclude unused transitive dependencies explicitly. CDS also reduces the runtime cost of each class regardless of count.
Nonzero or growing unloaded count → In many applications a small number of unloaded classes is unremarkable. If the unloaded count keeps growing over the life of a long-running process, that can indicate a classloader leak — a classloader being created and discarded repeatedly, often from dynamic proxies, scripting engines, or redeployed contexts. Worth investigating if it trends upward over time rather than staying flat.
Data sources: jdk.ThreadStart and jdk.ThreadEnd events.
jfr print --events jdk.ThreadStart --json recording.jfr
jfr print --events jdk.ThreadEnd --json recording.jfrThread names typically follow conventions: http-nio-8080-exec-1, http-nio-8080-exec-2 are the same pool. Strip trailing numbers to group them:
| Pool prefix (typical examples) | What it represents |
|---|---|
http-nio-*-exec, qtp* (Jetty), undertow-* |
Web-server request-handling threads |
HikariPool-*, c3p0-*, or your driver/pool's naming |
Database connection pool threads |
scheduling-*, quartz-* |
Scheduled/cron task executor threads |
reactor-http-*, Netty event-loop names |
Reactive/async I/O event-loop threads |
C2 CompilerThread |
JIT compiler threads (normal) |
ForkJoinPool.commonPool-* |
Parallel stream / CompletableFuture workers |
Exact naming depends on your web server, connection pool, and scheduler libraries — the table above shows common examples, but the grouping principle (strip trailing numbers, bucket by prefix) applies regardless of which ones you're running.
- A few dozen threads is normal for a typical server-side app (a web-server thread pool + a database connection pool + a handful of schedulers + GC/JIT threads).
- Hundreds of threads → Check for thread leaks or an
oversized pool configuration. Each platform thread carries stack-space
and scheduling overhead. Look for executors that create threads but
never shut down, or pools configured with an unnecessarily high
maximumPoolSize. And the same caution applies in the other direction: if contention analysis (Dimension 7) points you toward increasing a pool size, verify the fix with a before/after measurement rather than just cranking the number up — an oversized pool can itself increase contention and context-switching overhead, trading one bottleneck for another. - One-off threads (names that don't group into pools) → Each one has creation overhead. Consider a shared executor if they're frequent.
A note on virtual threads (JDK 21+): everything above assumes platform threads — a platform thread maps one-to-one to an OS thread, which is why each one is relatively expensive to create and hold. A virtual thread is different: it's a lightweight, JVM-managed thread that gets multiplexed onto a much smaller pool of platform threads (called carrier threads), so the JVM can support enormous numbers of them cheaply.
If your application uses virtual threads, raw thread
counts stop being a reliable signal on their own — seeing thousands of
virtual threads is normal and by design, not a leak. The concerning
pattern shifts from "how many threads exist" to pinning (a virtual thread blocking its carrier thread instead of yielding it, often due to a synchronized block or certain native calls) and carrier-thread starvation (too few carrier threads available for the work being scheduled onto them). Those show up differently in a JFR recording — via jdk.VirtualThreadPinned events and general scheduling delay — rather than through simple thread-count totals.
Data sources:
jdk.JavaMonitorEnter— recorded when a thread blocks waiting to enter asynchronizedblock/method (intrinsic lock contention)jdk.ThreadPark— waits onjava.util.concurrentprimitives (ReentrantLock,CountDownLatch,Semaphore, etc.)
By default, JFR only records monitor-contention events above a threshold (commonly 20ms) — shorter contention won't show up unless you lower that threshold when starting the recording, at the cost of more overhead.
jfr print --events jdk.JavaMonitorEnter --json recording.jfr
jfr print --events jdk.ThreadPark --json recording.jfrFor each event, extract the contended/parked class name and the wait duration.
Worth keeping in mind: a jdk.ThreadPark event by itself is not automatically a problem. Parking is the normal, intended mechanism behind most java.util.concurrent
waiting — a thread sitting idle in an executor waiting for work, or a
scheduled task waiting for its next run, will generate park events
constantly without indicating anything wrong. What makes park time worth
investigating is the combination of where it's happening (the
stack trace) and how much of it there is relative to the work being done
— a thread parking while genuinely idle is fine; a thread parking while
it's supposed to be actively serving requests is the pattern to chase.
Monitor enter on java.lang.Class objects → Static synchronized methods or synchronized(MyClass.class)
blocks. Class-level locks are particularly bad because they serialize
all threads contending on that class, not just per-instance callers.
Monitor enter on Logger / logging internals → Synchronized logging. Consider an async logger (e.g., Log4j2's async loggers) if this shows up as significant contention.
Park on a connection-pool's internal lock (HikariCP, c3p0, or others) → Connection pool contention. If threads are parking waiting for a database connection, consider increasing the pool's max size (only after checking whether the DB and queries can actually support more concurrent connections) or investigate slow queries holding connections longer than necessary.
Park on CountDownLatch / CyclicBarrier with high counts
→ Thread coordination that's spending more time waiting than working.
Check if the parallelism is appropriate for your hardware.
Monitor enter events: 847 total wait: 342.1 ms
Thread park events: 2,103 total park: 1,892.6 ms
- Park time notably higher than monitor-enter time → Contention is more concentrated in
java.util.concurrentstructures thansynchronizedblocks — common in modern applications and frameworks that lean on the concurrency utilities rather than intrinsic locks. - Total wait a meaningful fraction of recording time (rough guide: worth a look above roughly 5%) → Contention is meaningful. Below 1–2%, it's usually not worth chasing — the wins will be marginal relative to the effort.
Data source: jdk.Compilation events. One event per compiled method, recording the method signature, compile level, and compilation duration.
jfr print --events jdk.Compilation --json recording.jfrModern JVMs don't compile everything the same way. Instead, tiered compilation runs code through progressively more sophisticated compilers as it proves itself "hot" enough to be worth the investment:
Interpreter → C1 → C2
(slow to run, (fast to compile, (expensive to compile,
no compile moderately most optimized,
delay) optimized) best steady-state speed)
New code starts in the interpreter, which runs slowly but has zero compilation delay. Code that gets called often is compiled by C1, a quick compiler that produces reasonably fast code without spending much time compiling. Code that's still hot after that gets recompiled by C2, a much more thorough optimizing compiler that takes longer to run but produces the fastest possible code, using the profiling data gathered along the way. This staged approach is why a JVM's startup performance and its long-running, steady-state performance are genuinely different regimes — a short-lived process may never get past C1 for most of its code, while a long-running server eventually gets its hot paths onto C2.
Broken down into JFR's numeric compile levels:
| Level | Name | Description |
|---|---|---|
| 0 | Interpreter | No compilation — interpreted bytecode |
| 1 | C1, no profiling | Simple C1 compile, fastest to produce |
| 2 | C1, limited profiling | C1 with basic invocation/loop counters |
| 3 | C1, full profiling | C1 with full profiling (gathers data used to decide on C2) |
| 4 | C2 | Full optimizing compiler — most expensive to produce, highest steady-state performance |
During startup, hot methods are typically compiled first at cheaper C1 levels, then re-compiled at level 4 once the JIT has gathered enough profiling data to justify it. This tiered approach favors faster ramp-up at the cost of some methods being compiled more than once.
Compilations: 2,847
Total compile time: 3,452 ms
Compile time % of recording: 11.5%
- Few or no level-4 compilations → Code isn't reaching full optimization. Peak throughput may suffer if the process runs long enough to matter.
- Heavy, sustained level-4 activity → The JIT is working hard. For startup-sensitive, short-lived, or CLI-style applications where peak throughput matters less than time-to-first-response, consider C1-only mode:
java -XX:TieredStopAtLevel=1 -jar my-app.jarThis disables C2 entirely. You give up C2's deeper optimizations (aggressive inlining, loop unrolling, escape analysis, etc.) in exchange for saving all C2 compilation CPU. Worth it for batch jobs, CLI tools, or anything where the process exits before C2 would have paid for itself — not a good default for a long-running, throughput-sensitive service.
Top compiled methods by time show which code paths trigger the most expensive compilations. Application methods appearing here (as opposed to framework code) are hot paths the JIT considers worth optimizing — probably worth a look regardless of your JIT tuning.
Data sources:
jdk.SocketRead/jdk.SocketWrite— network I/O with host, bytes, and durationjdk.FileRead/jdk.FileWrite— file I/O with path, bytes, and duration
jfr print --events jdk.SocketRead,jdk.SocketWrite --json recording.jfr
jfr print --events jdk.FileRead,jdk.FileWrite --json recording.jfrSocket I/O during startup — every network call during ApplicationRunner / @PostConstruct delays readiness. Look for:
- Database connection establishment (expected, but should happen once)
- HTTP calls to external services (configuration fetching, health checks, service discovery)
- Redis/Memcached connection setup (expected)
- Unexpected HTTP calls (e.g., XML schema validation fetching remote DTDs)
Optimization: Defer non-critical network calls to after the application is ready (
ApplicationReadyEvent). Cache responses that don't change between restarts. Use connection pooling to amortize handshake cost.
File I/O duration — the duration field on FileRead/FileWrite events captures the time the call took, including any blocking wait, not just CPU-side processing. High wait time on:
| Path pattern | Suggests |
|---|---|
*.jar |
Classpath scanning — CDS can help |
*.class |
Class loading from exploded directories — CDS or packaging as a jar can help |
application*.yml / *.properties |
Config file loading — normal, but check for excessive includes |
/etc/, /usr/ |
System-level file access — unusual, worth investigating |
| Database files | If using an embedded DB (e.g., H2), the DB files are being read directly |
Data source: jdk.CPULoad — periodic snapshots of JVM user CPU, JVM system CPU, and total machine CPU.
jfr print --events jdk.CPULoad --json recording.jfr| Metric | Meaning |
|---|---|
| JVM user% | CPU spent in JVM user-mode code (your app + framework + JIT) |
| JVM system% | CPU spent in kernel on behalf of the JVM (I/O syscalls, thread scheduling, some GC activity) |
| Machine total% | Total CPU utilization across all processes on the host |
Patterns:
- JVM user% + system% ≈ machine total% → The JVM is the dominant CPU consumer on the box. You're compute-bound — optimize CPU hotspots (Dimension 1).
- High system% relative to user% → The JVM is spending an unusual amount of time in kernel space. Points toward heavy I/O (Dimension 9) or heavy thread context switching (Dimension 6).
- Machine total% near 100% but JVM% is comparatively low → Something else on the host is consuming CPU. Your JVM isn't the bottleneck — look at co-located processes.
- Low JVM% and low machine% → The JVM is waiting on something external (I/O, locks, or genuinely idle). Check thread idle time (Dimension 11).
Data sources: jdk.ThreadSleep (explicit Thread.sleep() calls) and jdk.JavaMonitorWait (Object.wait()), alongside jdk.ThreadPark already covered in Dimension 7 for java.util.concurrent
waits. Together these three event types cover most of the ways a thread
can be voluntarily idle rather than blocked on I/O or a contended lock.
A note on terminology: some third-party profilers (notably async-profiler's wall-clock mode) offer a more general "wall clock" sampling approach that periodically samples every thread's state regardless of whether it's running, sleeping, or blocked — useful for a single unified idle-time view. Standard JFR doesn't have one combined event for this; you assemble the picture from the events above.
jfr print --events jdk.ThreadSleep,jdk.JavaMonitorWait,jdk.ThreadPark --json recording.jfrEach event's duration field tells you how long the thread was idle, and its stack trace shows where the thread entered that state.
main thread has high idle/sleep time during startup → The main thread is waiting on something. Check what the top frame is:
Socket.connector a related network call → Waiting for a network connectionFileInputStream.read→ Waiting for file I/OCountDownLatch.await→ Waiting for another thread to complete initializationThread.sleep→ Explicit sleep (sometimes intentional — e.g., a documented retry backoff — sometimes worth questioning if it's on the startup critical path)
Thread-pool threads dominate idle time → Those threads are spending most of their time waiting for work. Normal during idle periods for request-handling pools; if you're profiling a busy period and pool threads are still mostly idle, the bottleneck is upstream — not enough requests arriving, or work isn't being distributed to them.
Longest individual idle events — look at the top 10–15 by duration. These are your "long poles" — single events that dominate the timeline. Each is a candidate for targeted optimization:
Duration Thread Top Frame
8,234 ms main java.net.Socket.connect
4,102 ms main java.io.FileInputStream.read
1,892 ms main java.util.concurrent.CountDownLatch.await
In an example like this, the socket connect and file read alone could account for the majority of a short startup window — a strong signal that those two call sites are worth optimizing first.
The most rigorous way to validate an optimization is to compare two JFR recordings — one before the change, one after — captured under the same workload and conditions.
- Record Before: Capture a JFR recording of the baseline.
- Make exactly one change:
-XX:TieredStopAtLevel=1, or CDS, or AOT, or dependency trimming. - Record After: Capture a second recording with the same JFR settings, same duration, same workload.
- Compare across all dimensions: Don't cherry-pick. Check every section — an optimization that improves CPU but worsens allocation may be a net negative depending on your priorities.
High-signal metrics (deltas worth taking seriously, roughly >10%):
| Metric | Why it matters |
|---|---|
| Total allocation (bytes) | Directly drives GC pressure |
| GC pause count + total time | Directly impacts responsiveness |
| Classes loaded | Impacts startup time + Metaspace |
| JIT compile time | Impacts startup CPU |
| Thread idle/sleep time | Impacts latency / readiness |
Medium-signal metrics (look for larger deltas, roughly >30%, before drawing conclusions):
| Metric | Why it matters |
|---|---|
| CPU sample count | Rough proxy for total work done |
byte[] allocation specifically |
Primary GC driver for many apps |
| Lock contention wait time | Impacts throughput under concurrency |
| File I/O bytes + wait time | Impacts startup latency |
| Socket I/O bytes + wait time | Impacts network-dependent readiness |
Low-signal metrics (small deltas here are often sampling noise — pay attention mainly when something appears or disappears entirely):
- Small allocation-type deltas (on the order of a few MB or less, depending on total recording size)
- Individual hot methods with only a handful of samples in either recording
- Thread count changes of one or two
In the comparison, flag items that were significant in the Before recording but are essentially absent in After, and vice versa. These are often the clearest wins — not "we reduced this by 15%," but "this cost is gone."
Type Before After Delta
byte[] 48,123,456 0 -48,123,456 (-100%) ◀◀ ELIMINATED
TypeMappedAnnotations 12,456,789 892,123 -11,564,666 (-92.8%)
An eliminated byte[] allocation like that is
consistent with what CDS does for class-loading-related allocations —
the class bytes come from the shared archive instead of being read and
inflated at runtime. A large drop in annotation-metadata allocation is
consistent with AOT pre-computing that metadata instead of building it
at runtime. (As always, correlation from a before/after diff is a strong
hint, not proof — confirm against the change you actually made.)
Here's a workflow for approaching a .jfr file when the question is "why is this slow":
Read the recording metadata — JVM version, JVM arguments,
recording duration. A very short recording doesn't have enough samples
to be reliable. A recording with a small -Xmx explains why GC is thrashing. Know the context first.
Extract ExecutionSample events (text mode for stack traces). Identify the top leaf methods and top stack signatures. If ClassLoader.defineClass
dominates → CDS. If annotation scanning dominates → indexer/AOT. If
application code dominates → profile deeper into those methods.
Extract ObjectAllocationSample events (JSON mode for weights). Identify the top allocation types by bytes. For byte[],
drill into allocation-site stacks. Map each major allocation site to a
fix (CDS → class bytes, AOT → annotation metadata, caching → repeated
object creation).
Extract GCHeapSummary + GCPhasePause.
Plot the heap curve; look at pause statistics. If pause time is a large
share of the recording, return to Step 3 — the fix is usually reducing
allocation. If old-gen/full GCs appear, investigate separately.
Work through class loading, threads, locks, JIT, I/O, and idle time. Each may reveal a secondary bottleneck. The longest individual idle events often point to "long pole" optimizations — single changes that remove seconds of waiting.
Run every section side-by-side. Focus on eliminated items and large-magnitude deltas. Verify that improvements in one dimension didn't regress another.
To make the protocol concrete, here's an illustrative (simplified, representative-of-the-shape-not-exact-numbers) walkthrough of applying it to a slow-starting application.
Baseline: Startup takes 18 seconds.
Step 2 — CPU: ExecutionSample shows ClassLoader.defineClass1 accounting for roughly 45% of samples during the startup window — Pattern A from Dimension 1.
Step 3 — Allocation: ObjectAllocationSample shows byte[]
at roughly 38% of allocated bytes, with the allocation-site stacks
tracing back through class-file reading — consistent with the CPU
finding.
Step 4 — GC: 17 young-generation collections over the recording, unremarkable pause times, no full GCs. GC isn't the bottleneck here — the allocation churn is a symptom of class loading, not a separate problem.
Diagnosis: Both the CPU profile and the allocation profile point at the same root cause — class loading and parsing dominates startup.
Change made: Enable CDS (-XX:ArchiveClassesAtExit / -XX:SharedArchiveFile), one change, nothing else.
After: Startup takes 11 seconds.
Metric Before After Delta
Startup time 18.0 s 11.0 s -39%
CPU: ClassLoader 45% 14% -31 pts
Allocation: byte[] 38% 9% -29 pts
Minor GCs 17 8 -9
Step 6 — Verify: The CPU share, the allocation share, and the GC count all moved together in the direction the diagnosis predicted, and the change made (CDS) is a plausible mechanism for exactly that combination of effects — class-loading CPU down, class-byte allocation down, and fewer minor GCs because there's less churn to collect. That consistency across independent dimensions is what turns "we made a change and it got faster" into a reasonably confident causal story, rather than a coincidence.
Notice what this example does not do: it doesn't stop at "startup got faster," it checks that the mechanism (less class-loading work) is visible in more than one independent dimension of the data. A change that improves one number while an unrelated number gets worse is worth a second look before declaring victory.
A few patterns that undermine an otherwise sound analysis:
- Recording too long. A five-minute recording spanning startup, steady-state, and idle time averages all three together — none of the resulting numbers describe any single phase well. Record the specific window you care about.
- Comparing different workloads. A before/after comparison is only meaningful if both recordings saw the same requests, the same data, and the same duration. Comparing a quiet-period recording to a load-test recording tells you about the workload difference, not your change.
- Optimizing methods with only a handful of samples. Low sample counts are noise, not signal (see the sampling-bias note in Dimension 1) — chasing them wastes effort that could go toward something statistically meaningful.
- Looking only at CPU. A CPU-only view misses allocation-driven GC pressure, I/O waits, and lock contention — all of which can dominate wall-clock time without showing up as CPU samples at all.
- Ignoring allocation. Allocation rate drives GC pressure well before heap usage looks alarming; waiting for a heap problem to show up before checking allocation means you're diagnosing late.
- Ignoring idle time. A method that's cheap on CPU can still be the reason startup is slow, if it's blocking on I/O or a latch. CPU profiling alone won't surface that — Dimension 11 will.
- Comparing across different JVM versions or GC algorithms. Event names, phase names, and default behaviors can differ between JDK versions and collectors. A before/after comparison should hold the JVM version and GC algorithm constant unless that's specifically the change being tested.
A JFR recording is more than a performance trace — it's a map of how your JVM actually spends its time and resources, as opposed to how you assume it does. By systematically working through CPU, allocation, garbage collection, class loading, threads, synchronization, JIT compilation, and I/O, you replace guesswork with evidence.
The point isn't to chase every hotspot the profiler surfaces — plenty of what shows up is normal, unavoidable framework overhead or startup-only cost that will never matter in production. The point is to find the handful of bottlenecks that materially affect startup time, latency, or throughput; make one change at a time; and verify the result with another recording, checking that the numbers moved the way your diagnosis predicted before calling it done.