Java Performance: Stop Guessing, Start Measuring

Most Java performance work starts in the wrong place. An engineer identifies “the application is slow,” forms a hypothesis based on code familiarity, and makes changes based on intuition. Sometimes it works. More often, the actual bottleneck was elsewhere and the optimized code is now more complex with no performance benefit.

The methodology matters: measure, identify the bottleneck, optimize, measure again. In that order. Not “optimize the thing that looks suspicious, then measure.”

The Measurement-First Principle

Before changing anything for performance, establish a baseline and identify what you’re actually optimizing.

Questions that require data before you can answer them:

  • Is the application CPU-bound or I/O-bound?
  • Where is time being spent? (Which methods, which calls)
  • Is allocation rate high? If so, which code is allocating?
  • Which GC collector is being used? Is it causing pauses?
  • Are there contended locks causing thread stalls?

Each of these has different solutions. An application that’s CPU-bound in a JSON deserialization path needs a faster JSON library. An application that’s waiting on database queries needs query optimization or connection pool tuning. An application with high GC overhead needs allocation reduction. Addressing the wrong problem wastes time and sometimes makes things worse.

Java Flight Recorder (JFR)

JFR is built into the JVM (Java 11+) and produces low-overhead continuous profiling data. It records CPU samples, GC events, thread states, memory allocation, I/O events, and much more.

Starting a JFR recording:

# In production — low overhead, continuous recording
java -XX:StartFlightRecording=duration=60s,filename=app.jfr \
     -XX:FlightRecorderOptions=stackdepth=256 \
     -jar application.jar

# Or attach to a running JVM
jcmd <pid> JFR.start duration=60s filename=app.jfr settings=profile

Opening the recording in JDK Mission Control (JMC):

# Download from: https://adoptium.net/jmc
jmc

Key tabs in JMC:

  • Method Profiling: CPU hotspots by method
  • Allocations: which code is allocating the most objects
  • GC: pause times, GC cause, heap usage
  • Threads: thread states, lock contention
  • I/O: file and network I/O

async-profiler: Lower-Level CPU Profiling

JFR’s CPU profiler uses safepoint-based sampling, which can miss hotspots that occur between safepoints. async-profiler uses AsyncGetCallTrace + perf_events for accurate sampling at any point:

# Profile a running JVM for 30 seconds, produce flamegraph
./profiler.sh -d 30 -f flamegraph.html <pid>

# Profile CPU + allocation
./profiler.sh -e cpu,alloc -d 30 -f flamegraph.html <pid>

The flamegraph shows the call stack proportionally to the time spent in each path. Wide blocks at the bottom are hot paths. Tall narrow stacks indicate deep recursion.

Reading a flamegraph:

  • x-axis: stack depth (bottom = root, top = leaf methods)
  • Width: proportion of total CPU time
  • Find the widest blocks at the top — those are the hottest methods

GC Analysis

Garbage collection configuration determines pause characteristics. Modern Java applications typically use G1GC (default since Java 9) or ZGC (near-zero pause, Java 21+).

GC logging for analysis:

java -Xlog:gc*:file=gc.log:time,uptime,pid:filecount=5,filesize=20m \
     -jar application.jar

Key GC metrics:

  • Pause time: how long the application is stopped for GC
  • Throughput: fraction of time NOT spent in GC (target: >95%)
  • Heap usage: how much is used before and after GC
  • Allocation rate: MB/s being allocated

If you’re on Java 21+ and GC pauses are a problem, try ZGC:

java -XX:+UseZGC -jar application.jar

ZGC provides sub-millisecond GC pauses at the cost of somewhat higher throughput overhead. For latency-sensitive applications, it’s often worth it.

Common GC problems:

High allocation rate: creates garbage faster than GC can collect. Solution: find what’s allocating (JFR allocation view) and reduce unnecessary object creation.

// Allocates a new String on every call
public String buildKey(String prefix, String id) {
    return prefix + ":" + id;  // Creates intermediate StringBuilder + String
}

// Potentially avoidable if called frequently
// Profile first to confirm it's actually hot

Object retention: objects living longer than expected, filling the old generation. Identify with JFR heap view or heap dump analysis.

Fragmentation (G1): with G1GC, humongous allocations (objects >50% of region size) can cause fragmentation. Profile with JFR and look for humongous allocation events.

Lock Contention

Synchronized code under high concurrency creates contention — threads waiting for locks. JFR captures this:

JFR Event: jdk.JavaMonitorWait
  Stack trace: ... 
    UserService.updateCache(UserService.java:134)
  Duration: 45ms
  Blocked count: 847 times

847 times at 45ms each is 38 seconds of blocked time. That’s the problem.

Solutions depending on the pattern:

  • Replace synchronized with ReentrantLock (supports tryLock with timeout)
  • Use concurrent data structures (ConcurrentHashMap instead of synchronized HashMap)
  • Reduce lock scope (hold the lock for less time)
  • Use lock-free algorithms where appropriate
  • Use virtual threads (they yield when blocked, reducing contention effects)

Database Query Performance

For applications that spend significant time in database calls, the JVM isn’t the bottleneck — the database is. JFR won’t show this directly (it’ll show time spent in JDBC blocked on I/O), but it confirms the bottleneck is I/O-bound.

Spring Boot with p6spy or datasource-proxy for query logging:

spring:
  datasource:
    url: jdbc:p6spy:postgresql://localhost:5432/mydb
    driver-class-name: com.p6spy.engine.spy.P6SpyDriver

In spy.properties:

logMessageFormat=com.p6spy.engine.spy.appender.MultiLineFormat
appender=com.p6spy.engine.spy.appender.Slf4JLogger

This logs every query with execution time. Queries taking >100ms are candidates for optimization.

For EXPLAIN ANALYZE:

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.*, ol.* FROM orders o 
JOIN order_lines ol ON o.id = ol.order_id
WHERE o.customer_id = $1 AND o.status = $2;

Common findings:

  • Sequential scan on a large table (missing index)
  • N+1 queries (JPA loading related entities one at a time)
  • Large result sets being filtered in application code rather than database

Memory Analysis

For heap memory issues, start with a heap dump:

# Trigger from command line
jcmd <pid> GC.heap_dump /tmp/heapdump.hprof

# Or configure automatic dump on OOM
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof \
     -jar application.jar

Analyze with Eclipse Memory Analyzer (MAT):

  • Dominator tree: objects retaining the most memory
  • Leak suspects: potential memory leaks
  • Histogram: count and size of objects by class

Common findings:

  • Caches without eviction policies growing indefinitely
  • Event listeners holding references preventing GC
  • String deduplication opportunities (many equal String objects)
  • Large collections in session scope

The Performance Investigation Workflow

  1. Establish symptoms: “p99 latency is 500ms, target is 200ms under load test of 500 req/s”

  2. Profile under realistic load: run the load test while collecting JFR + async-profiler data

  3. Identify the bottleneck: CPU, memory allocation, GC, I/O, lock contention?

  4. Hypothesis: “The payment service serializes 5KB JSON payloads with Jackson, called 500 times/second — this is the hot path”

  5. Targeted optimization: use Jackson streaming API, pre-configure ObjectMapper, add @JsonIgnore to unused fields

  6. Measure again: “p99 is now 220ms — close but still above target”

  7. Repeat: find the next bottleneck

Optimization is iterative. The first fix rarely solves everything. After each optimization, profile again — the bottleneck has shifted.

What Not to Optimize

Micro-optimizations that don’t appear on profiler output are usually irrelevant:

  • String.format() vs + concatenation in non-hot paths
  • Iterator vs for-loop
  • Checked vs unchecked exceptions

The JIT compiler is good. Trust it on things that don’t appear in profiles. Focus optimization effort on the things that do.