Skip to content

10 · Performance Profiling & Optimization

Before optimizing anything, you need to know where time and memory are actually being spent — guessing usually leads to effort spent on code that was never the bottleneck. This module covers the JDK's built-in diagnostic tools and a handful of well-known optimization techniques.

Built-in diagnostic tools

Tool Purpose
jcmd General-purpose diagnostic command tool (GC, thread dumps, flags, JFR control)
jstack (or jcmd <pid> Thread.print) Dumps all thread stack traces — useful for diagnosing deadlocks/hangs
jmap (or jcmd <pid> GC.heap_info / GC.class_histogram) Heap usage summaries, class instance counts
VisualVM Free GUI tool bundled separately; live CPU/memory graphs, profiler, heap dumps
JFR (Java Flight Recorder) Low-overhead, production-safe event recorder built into the JVM
# Take a thread dump of a running JVM to check for deadlocks or stuck threads
jcmd <pid> Thread.print

# Show a histogram of live object counts on the heap, sorted by size
jcmd <pid> GC.class_histogram

Java Flight Recorder

JFR records detailed runtime events (GC pauses, method sampling, allocation profiles, lock contention) with low overhead — safe to enable even in production.

java -XX:StartFlightRecording=duration=60s,filename=recording.jfr -jar myapp.jar

The resulting recording.jfr file can be opened in VisualVM or the standalone JDK Mission Control tool for a detailed, visual breakdown of where time went.

Manual benchmarking with System.nanoTime()

For a quick sanity check, timing code by hand is fine:

public class ManualBenchmark {
    public static void main(String[] args) {
        int n = 1_000_000;

        long start = System.nanoTime();
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < n; i++) {
            sb.append(i);
        }
        long elapsedMs = (System.nanoTime() - start) / 1_000_000;

        System.out.println("StringBuilder: " + elapsedMs + " ms, length=" + sb.length());
    }
}

Why microbenchmarks are tricky

Naively timing a single run like the example above is easy to get wrong:

  • JIT warm-up — the JVM interprets bytecode at first, then compiles "hot" methods to optimized native code after enough invocations. The first few thousand iterations of a loop can run far slower than the steady state, skewing results measured only once.
  • Dead code elimination — the JIT can notice a computed value is never used and optimize the whole computation away, measuring nothing.
  • Garbage collection interference — a GC pause happening to land inside your timed region adds noise unrelated to the code being measured.
  • No statistical rigor — a single measurement can't distinguish a real effect from run-to-run system noise (other processes, CPU frequency scaling, etc.).
public class WarmupDemo {
    public static void main(String[] args) {
        int n = 10_000_000;

        // "Cold" timing -- includes interpretation and JIT compilation overhead
        long t1 = time(() -> sumSquares(n));
        // "Warm" timing -- JIT has likely already compiled sumSquares by now
        long t2 = time(() -> sumSquares(n));

        System.out.println("First run:  " + t1 + " ms");
        System.out.println("Second run: " + t2 + " ms");
        // The second run is typically noticeably faster -- the same code,
        // just measured after the JIT has kicked in.
    }

    static long sumSquares(int n) {
        long sum = 0;
        for (int i = 0; i < n; i++) sum += (long) i * i;
        return sum;
    }

    static long time(Runnable task) {
        long start = System.nanoTime();
        task.run();
        return (System.nanoTime() - start) / 1_000_000;
    }
}

For real, trustworthy microbenchmarks, use JMH (Java Microbenchmark Harness) — a purpose-built tool from the OpenJDK team that handles warm-up iterations, dead-code elimination pitfalls, and statistical reporting automatically. Manual nanoTime() timing is fine for rough, order-of-magnitude checks; JMH is the right tool once a decision actually depends on the number.

Optimization technique: StringBuilder vs. concatenation in loops

public class ConcatBenchmark {
    public static void main(String[] args) {
        int n = 50_000;

        long start = System.nanoTime();
        String result = "";
        for (int i = 0; i < n; i++) {
            result += i;   // each += creates a brand-new String -- O(n^2) overall
        }
        long slow = (System.nanoTime() - start) / 1_000_000;

        start = System.nanoTime();
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < n; i++) {
            sb.append(i);   // mutates an internal buffer -- O(n) overall
        }
        String fast = sb.toString();
        long fastMs = (System.nanoTime() - start) / 1_000_000;

        System.out.println("String +=      : " + slow + " ms");
        System.out.println("StringBuilder  : " + fastMs + " ms");
        // StringBuilder is typically an order of magnitude faster at this size
    }
}

Each String is immutable, so result += i allocates an entirely new String and copies the old contents every iteration. StringBuilder grows an internal, mutable buffer instead.

Optimization technique: avoiding unnecessary allocation

// Wasteful -- creates a new Integer wrapper via autoboxing every loop iteration
long sum = 0;
for (Integer i = 0; i < 1_000_000; i++) {
    sum += i;
}

// Better -- primitive int avoids boxing entirely
long sumFast = 0;
for (int i = 0; i < 1_000_000; i++) {
    sumFast += i;
}

Optimization technique: choosing the right collection

Need Prefer Avoid
Frequent lookups by key HashMap Linear scan over a List
Frequent insert/remove at both ends ArrayDeque LinkedList (worse cache locality)
Index-based random access ArrayList LinkedList (O(n) get)
Uniqueness with no ordering need HashSet Manually checking list.contains() before adding

Picking the wrong collection for the access pattern is one of the most common avoidable sources of quadratic behavior in otherwise-correct code — e.g. calling list.contains(x) inside a loop over a large ArrayList turns an O(n) task into O(n²), whereas the same check against a HashSet stays O(n) overall.

How It Actually Works

HotSpot's name is literal: the interpreter runs bytecode directly at first, while a background thread counts method invocations and loop back-edges; once a method crosses a threshold it's compiled by the C1 (client) compiler for fast, lightly-optimized native code, and if it stays hot, later recompiled by C2 (server) with aggressive optimizations — inlining, escape analysis, loop unrolling — informed by actual runtime profile data (branch frequencies, observed types at call sites) that a purely static compiler could never have. This is why a Java benchmark's first few thousand iterations are typically much slower than its steady state, and why microbenchmarks without a JIT warm-up phase (JMH exists specifically to handle this correctly) produce misleading numbers.

Escape analysis lets C2 prove an object never leaves the current method/thread and, when it can, allocate it on the stack (or eliminate the allocation entirely, "scalar replacement") instead of the heap — real, load-bearing optimization, not a JIT curiosity, and one reason "avoid allocation" advice is less absolute in Java than in non-JIT'd languages.

A profiler like async-profiler samples real stack traces using safepoint-biased or (better) AsyncGetCallTrace/perf-event-based sampling to avoid the classic safepoint bias where naive JVM profilers only ever see threads paused at safepoints, systematically under-sampling code that runs between safepoint polls.

Exercise

Write a benchmark comparing ArrayList.contains() against HashSet.contains() when checking membership of 10,000 lookups against a collection of 20,000 integers. Populate both collections with the same data, time each set of 10,000 contains() calls with System.nanoTime(), and print the elapsed milliseconds for each — then write a one-sentence comment explaining the result in terms of each collection's underlying data structure.