Guessing at performance problems wastes time; measuring with the JDK's own tools finds the actual cause quickly.
The first rule of performance work is measure, don't guess. Define the target (p99 latency, throughput, memory), reproduce the problem under realistic load, then profile.
The JDK ships excellent tools:
- JDK Flight Recorder (JFR) — a low-overhead event recorder built into the JVM, safe to run in production. Start it with
-XX:StartFlightRecording=duration=60s,filename=rec.jfr, or attach to a running process withjcmd <pid> JFR.start. It records CPU samples, allocations, GC pauses, lock contention, I/O and thread states. - JDK Mission Control (JMC) — the GUI for analysing
.jfrfiles; thejfrCLI (jfr print,jfr summary) works too. - jcmd — the Swiss army knife:
Thread.print(thread dump),GC.heap_info,GC.heap_dump,VM.flags,VM.native_memory. - External profilers such as async-profiler produce flame graphs of CPU and allocation.
For small pieces of code, use JMH (Java Microbenchmark Harness). It handles warm-up, forks fresh JVMs, and prevents the JIT from eliminating or hoisting the code under test — pitfalls that make hand-written timing loops meaningless.
Typical findings and fixes:
- Allocation pressure — boxing in hot loops, needless copies and string concatenation in loops drive GC. Use primitives, reuse buffers, presize collections.
- Lock contention — JFR's monitor events point at hot locks; reduce the scope, use concurrent structures, or avoid sharing.
- I/O waits — batch calls, add caching or connection pooling, or use virtual threads to overlap waiting.
- GC — check GC logs for pause times and frequency before reaching for flags; often the fix is allocating less or sizing the heap correctly.
Change one thing at a time, re-measure, and keep the benchmark so regressions are caught later.
Example
import java.util.ArrayList;
import java.util.List;
// Record: java -XX:StartFlightRecording=duration=30s,filename=rec.jfr Profile.java
// Inspect: jfr summary rec.jfr | jfr print --events jdk.ObjectAllocationSample rec.jfr
// Live: jcmd <pid> Thread.print | jcmd <pid> GC.heap_info (or open rec.jfr in JMC)
public class Profile {
static long slow(int n) { // boxes every value: heavy allocation
List<Long> values = new ArrayList<>();
for (long i = 0; i < n; i++) values.add(i * i);
long sum = 0;
for (Long v : values) sum += v;
return sum;
}
static long fast(int n) { // primitives only: no allocation
long sum = 0;
for (long i = 0; i < n; i++) sum += i * i;
return sum;
}
public static void main(String[] args) {
for (int i = 0; i < 50; i++) if (slow(2_000_000) != fast(2_000_000)) throw new AssertionError();
System.out.println("done; the JFR allocation samples point at slow()");
}
}Remember
- Measure first: reproduce, profile, change one thing, re-measure
- JFR + JMC and jcmd are built in and safe for production diagnostics
- Use JMH for microbenchmarks — hand-written timing loops lie
Watch out for
- Tuning JVM flags before profiling, when the real problem is allocation or contention in application code.