Skip to main content
Java advanced Lesson 43 of 58

Java Performance Tuning

Profile and optimize Java applications — JMH benchmarks, Java Flight Recorder, JIT compilation, heap analysis, and common performance pitfalls.

The Performance Workflow

The cardinal rule: profile first, optimise second. Most performance intuition is wrong.

Measure baseline → Profile → Find hotspot → Optimise → Measure again → Repeat

Never optimise code you have not measured. The JIT compiler already does an excellent job — the bottleneck is usually I/O, a bad algorithm, or an unexpected hotspot you would never guess.

JMH — Java Microbenchmark Harness

JMH is the standard tool for accurate microbenchmarks:

Setup (Maven)

<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.37</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.37</version>
    <scope>provided</scope>
</dependency>

Writing a Benchmark

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import java.util.concurrent.TimeUnit;
import java.util.*;

@BenchmarkMode(Mode.AverageTime)          // measure average time per op
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)                      // one state object per thread
@Warmup(iterations = 5, time = 1)        // 5 x 1-second warm-up rounds
@Measurement(iterations = 10, time = 1)  // 10 x 1-second measurement rounds
@Fork(2)                                  // run in 2 separate JVM processes
public class StringBenchmark {

    @Param({"10", "100", "1000"})
    private int size;

    private List<String> words;

    @Setup(Level.Trial)
    public void setup() {
        words = new ArrayList<>(size);
        for (int i = 0; i < size; i++) words.add("word" + i);
    }

    // Naive concatenation — creates many intermediate String objects
    @Benchmark
    public String concatPlus(Blackhole bh) {
        String result = "";
        for (String w : words) result += w;
        return result; // return prevents dead-code elimination
    }

    // StringBuilder — single buffer, much faster
    @Benchmark
    public String concatBuilder() {
        StringBuilder sb = new StringBuilder();
        for (String w : words) sb.append(w);
        return sb.toString();
    }

    // String.join — cleanest for joining with delimiter
    @Benchmark
    public String stringJoin() {
        return String.join("", words);
    }

    public static void main(String[] args) throws Exception {
        org.openjdk.jmh.Main.main(args);
    }
}

Run:

mvn clean package
java -jar target/benchmarks.jar StringBenchmark -rf json -rff results.json

Typical output:

Benchmark                  (size)  Mode  Cnt      Score      Error  Units
StringBenchmark.concatPlus     10  avgt   20    312.4 ±    5.2  ns/op
StringBenchmark.concatPlus    100  avgt   20  28453.2 ±  234.1  ns/op  ← quadratic!
StringBenchmark.concatBuilder  10  avgt   20    145.2 ±    2.1  ns/op
StringBenchmark.concatBuilder 100  avgt   20    891.3 ±   12.4  ns/op  ← linear

Blackhole — Preventing Dead-Code Elimination

@Benchmark
public void computeAndConsume(Blackhole bh) {
    // Without bh.consume(), the JIT may eliminate this entirely
    // (it sees the result is never used)
    double result = Math.sin(42.0);
    bh.consume(result); // tells JMH the result is "used"
}

Java Flight Recorder (JFR)

JFR is a low-overhead profiler built into the JVM (Java 11+):

# Start recording when launching the app (add to JVM flags)
java -XX:StartFlightRecording=duration=60s,filename=app.jfr,settings=profile MyApp

# Or attach to a running process
jcmd $(jps | grep MyApp | awk '{print $1}') JFR.start \
    name=my-recording duration=60s filename=app.jfr settings=profile

# Open the recording in JDK Mission Control
jmc app.jfr

Key views in JDK Mission Control:

  • Method Profiling — which methods consume the most CPU
  • Heap Usage — allocation rate and live heap size over time
  • GC Activity — pause times, frequency
  • Thread Activity — blocked time, monitor contention
  • I/O Operations — file and socket read/write latency

JFR Custom Events for Business Metrics

import jdk.jfr.*;

@Name("com.example.DatabaseQuery")
@Label("Database Query")
@Category({"Performance", "Database"})
@StackTrace(false)
public class DatabaseQueryEvent extends jdk.jfr.Event {

    @Label("SQL")
    @Description("The SQL query that was executed")
    public String sql;

    @Label("Row Count")
    public int rowCount;

    @Label("Cache Hit")
    public boolean cacheHit;
}

// Instrument your repository
public List<User> findAll() {
    var event = new DatabaseQueryEvent();
    event.begin();
    try {
        var results = jdbcTemplate.query(SQL, rowMapper);
        event.sql      = SQL;
        event.rowCount = results.size();
        event.cacheHit = false;
        return results;
    } finally {
        event.commit();
    }
}

Common Performance Pitfalls and Fixes

1. String Concatenation in Loops

// Bad — O(n^2) — each + creates a new String
String result = "";
for (String item : largeList) {
    result += item + ", "; // allocates a new String every iteration
}

// Good — O(n)
StringBuilder sb = new StringBuilder();
for (String item : largeList) sb.append(item).append(", ");
String result2 = sb.toString();

// Best for joining — clean and fast
String result3 = String.join(", ", largeList);

2. Autoboxing in Hot Paths

// Bad — boxes each int to Integer, then unboxes for sum
List<Integer> numbers = ...;
long sum = 0;
for (Integer n : numbers) sum += n; // unboxing on every iteration

// Good — use primitive streams
long sum2 = numbers.stream().mapToLong(Integer::longValue).sum();

// Better — avoid boxing altogether with int arrays
int[] arr = ...;
long sum3 = 0;
for (int n : arr) sum3 += n;

3. Unsized Collections

// Bad — ArrayList starts at capacity 10, resizes (copies) repeatedly
List<String> list = new ArrayList<>();
for (int i = 0; i < 100_000; i++) list.add("item " + i);

// Good — pre-size to avoid resizing
List<String> list2 = new ArrayList<>(100_000);
for (int i = 0; i < 100_000; i++) list2.add("item " + i);

// Same for HashMap — default load factor 0.75
// If you expect ~1000 entries: capacity = 1000 / 0.75 ≈ 1334
Map<String, Object> map = new HashMap<>(1334);

4. Repeated Regex Compilation

// Bad — compiles the pattern on every call
public boolean isValidEmail(String email) {
    return email.matches("[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}");
    // String.matches() recompiles the Pattern every time!
}

// Good — compile once as a constant
private static final Pattern EMAIL =
    Pattern.compile("^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$");

public boolean isValidEmail(String email) {
    return EMAIL.matcher(email).matches();
}

5. N+1 Query Problem

// Bad — 1 query for orders + N queries for customers
List<Order> orders = orderRepo.findAll();          // 1 query
for (Order o : orders) {
    Customer c = customerRepo.findById(o.customerId()); // N queries!
    System.out.println(c.name() + ": " + o.total());
}

// Good — single join query or batch fetch
List<OrderWithCustomer> results = orderRepo.findAllWithCustomer(); // 1 query

JIT Compilation Insights

# Print JIT compilation decisions
java -XX:+PrintCompilation MyApp

# Print inlining decisions
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining MyApp

# Force JIT on a method (useful for testing)
java -XX:CompileOnly=com/example/MyClass::hotMethod MyApp

# Disable JIT (run interpreted — reveals true algorithmic cost)
java -Xint MyApp
// Help the JIT: keep methods small and focused
// The JIT inlines methods up to ~35 bytecodes by default
// Large methods are not inlined

// Hint: use -XX:MaxInlineSize=50 to allow slightly larger inlines

// Escape analysis — allocate on stack if object does not escape
public long sumRange(int n) {
    // Point does not escape this method — JIT may stack-allocate it
    var p = new java.awt.Point(0, 0);
    long sum = 0;
    for (int i = 0; i < n; i++) {
        p.x = i;
        p.y = i * 2;
        sum += p.x + p.y;
    }
    return sum;
}

Heap Analysis with jmap and MAT

# Dump heap of a running process
jmap -dump:format=b,file=heap.hprof <pid>

# Live objects only (triggers GC first — safer for analysis)
jmap -dump:live,format=b,file=heap-live.hprof <pid>

# Quick histogram — top object types by count and size
jmap -histo:live <pid> | head -30

# Analyse with Eclipse Memory Analyser (MAT):
# 1. Open heap.hprof in MAT
# 2. Run "Leak Suspects Report"
# 3. Check "Dominator Tree" for the largest retained objects
# 4. Use OQL: SELECT * FROM java.util.HashMap WHERE size() > 10000

Profiling Checklist

Before optimising, gather data:

  1. CPU profile — JFR Method Profiling or async-profiler: find which methods use the most CPU
  2. Allocation profile — JFR Allocation Profiling: find which code paths allocate the most objects
  3. GC loggc.log with -Xlog:gc*: measure pause frequency and duration
  4. Thread dumpjstack <pid>: find blocked or waiting threads (lock contention)
  5. Heap histogramjmap -histo: find unexpected object accumulation

Only after identifying the actual bottleneck should you write a JMH benchmark and try an optimisation.

Frequently Asked Questions

Why should I use JMH instead of System.nanoTime() for benchmarks?
JMH handles JVM warm-up (JIT compilation), dead-code elimination, constant folding, and other pitfalls that make naive benchmarks wildly inaccurate. A simple System.nanoTime() loop often measures the JIT compiler's work on the first iteration, not the steady-state performance. JMH runs multiple warm-up iterations before measuring and uses blackholes to prevent dead-code elimination.
What is JIT compilation and how does it affect performance?
The JVM initially interprets bytecode. The JIT (Just-In-Time) compiler watches which methods run frequently ('hot methods') and compiles them to native machine code with aggressive optimisations — inlining, loop unrolling, escape analysis. Code runs slowly at startup (interpreting) and fast after warm-up (native). This is why Java benchmarks must warm up before measuring.
What is escape analysis?
Escape analysis is a JIT optimisation where the JVM determines whether an object 'escapes' the method that creates it (e.g., stored in a field, returned, or passed to another thread). If it does not escape, the JVM can allocate it on the stack instead of the heap — eliminating GC pressure entirely.
What are the most common Java performance mistakes?
1) String concatenation in loops (use StringBuilder). 2) Creating objects in hot paths (object pooling or value types). 3) Using boxed types (Integer, Double) where primitives work. 4) Excessive synchronisation on uncontended paths. 5) Not sizing collections (triggers repeated resizing). 6) Not profiling before optimising — guessing where the bottleneck is is almost always wrong.