Skip to main content
Java · lesson 43 of 66
Open the Java page →
Java advanced Lesson 43 of 66

Java Performance Tuning

Measure before you optimise: JIT warm-up, allocation profiling, string building, boxing, presized collections, precompiled regex and a real JMH benchmark.

By SkillByExample · Updated

The rule of performance work is measure, change one thing, measure again. Wall-clock time is noisy, so most examples here measure something stable instead — bytes allocated, or a ratio with a wide margin — and print a verdict rather than raw numbers that change every run. Single-file examples run with the JDK 21 source launcher (java File.java); the JMH benchmark is a small Maven project.

JIT warm-up: the first runs are slow

The JVM interprets code until it is hot, then compiles it. Time each of 2,000 identical calls and compare the median of the first ten with the median of the last hundred.

import java.util.Arrays;

public class WarmUp {
    static double work(int n) {
        double s = 0;
        for (int i = 1; i <= n; i++) s += Math.sqrt(i) / i;
        return s;
    }

    static long median(long[] a, int from, int to) {
        long[] part = Arrays.copyOfRange(a, from, to);
        Arrays.sort(part);
        return part[part.length / 2];
    }

    public static void main(String[] args) {
        long[] times = new long[2_000];
        double sink = 0;
        for (int call = 0; call < times.length; call++) {
            long start = System.nanoTime();
            sink += work(1_000);
            times[call] = System.nanoTime() - start;
        }
        long early = median(times, 0, 10), late = median(times, 1_900, 2_000);
        System.out.println("first calls at least 3x slower than last? " + (early > 3 * late));
        System.out.println("(sink " + (sink > 0) + ")");
    }
}
$ java WarmUp.java
first calls at least 3x slower than last? true
(sink true)
$ java -Xint WarmUp.java
first calls at least 3x slower than last? false
(sink true)

With the JIT, the early calls run in the interpreter and the late ones run compiled native code. -Xint disables the JIT, so every call runs at the same, much slower, interpreted speed and there is no warm-up gap. Medians keep one slow outlier, such as a GC pause, from deciding the answer. Adding the result to sink and printing it stops the JIT from deleting work as dead code — the same job JMH’s Blackhole does.

Measuring allocation

Allocation is a stable, repeatable signal — and every allocated byte is future garbage-collector work.

import java.lang.management.ManagementFactory;

public class AllocBoxing {
    static final com.sun.management.ThreadMXBean THREADS =
        (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();

    static Long boxedSum(int n)    { Long s = 0L; for (int i = 0; i < n; i++) s += i; return s; }
    static long primitiveSum(int n) { long s = 0; for (int i = 0; i < n; i++) s += i; return s; }

    public static void main(String[] args) {
        long before = THREADS.getCurrentThreadAllocatedBytes();
        Long a = boxedSum(10_000_000);
        long boxedBytes = THREADS.getCurrentThreadAllocatedBytes() - before;

        before = THREADS.getCurrentThreadAllocatedBytes();
        long b = primitiveSum(10_000_000);
        long primBytes = THREADS.getCurrentThreadAllocatedBytes() - before;

        System.out.println("same result? " + (a == b));
        System.out.println("boxed:     " + boxedBytes / 1_000_000 + " MB");
        System.out.println("primitive: " + primBytes / 1_000_000 + " MB");
    }
}
$ java AllocBoxing.java
same result? true
boxed:     239 MB
primitive: 0 MB

s += i on a Long unboxes, adds, and boxes a new Long — ten million small objects. The primitive loop allocates nothing. Escape analysis can sometimes remove boxing like this in hot compiled code, but only sometimes; a primitive is never at risk.

String concatenation in a loop

s += x on a String copies the whole string every time, which is quadratic.

import java.lang.management.ManagementFactory;

public class Concat {
    static final com.sun.management.ThreadMXBean THREADS =
        (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();

    static String plus(int n)    { String s = ""; for (int i = 0; i < n; i++) s += i % 10; return s; }

    static String builder(int n) {
        StringBuilder sb = new StringBuilder(n);
        for (int i = 0; i < n; i++) sb.append(i % 10);
        return sb.toString();
    }

    public static void main(String[] args) {
        long t0 = THREADS.getCurrentThreadAllocatedBytes();
        String a = plus(20_000);
        long t1 = THREADS.getCurrentThreadAllocatedBytes();
        String b = builder(20_000);
        long t2 = THREADS.getCurrentThreadAllocatedBytes();
        System.out.println("equal? " + a.equals(b) + ", length " + a.length());
        System.out.println("+= allocated over 100x more? " + ((t1 - t0) > 100 * (t2 - t1)));
    }
}
$ java Concat.java
equal? true, length 20000
+= allocated over 100x more? true

Each += allocates a new string as long as everything so far, so 20,000 appends copy about 200 million characters in total. StringBuilder appends into one growable buffer — presized here, so it never even grows. A single a + b + c expression is fine; the compiler already optimises that.

Presizing collections

An ArrayList that does not know its final size grows by about 1.5x and copies its array each time.

import java.lang.management.ManagementFactory;
import java.util.ArrayList;
import java.util.List;

public class Presize {
    static final com.sun.management.ThreadMXBean THREADS =
        (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();

    static long fill(List<Integer> list, int n) {
        long before = THREADS.getCurrentThreadAllocatedBytes();
        for (int i = 0; i < n; i++) list.add(i);
        return THREADS.getCurrentThreadAllocatedBytes() - before;
    }

    public static void main(String[] args) {
        int n = 1_000_000;
        long growing = fill(new ArrayList<>(), n);
        long sized = fill(new ArrayList<>(n), n);
        System.out.println("growing: " + growing / 1_000_000 + " MB");
        System.out.println("presized: " + sized / 1_000_000 + " MB");
    }
}
$ java Presize.java
growing: 30 MB
presized: 15 MB

Both lists box the same million Integers; most of the difference is the discarded backing arrays from every resize. For maps, HashMap.newHashMap(n) (Java 19+) computes a capacity that holds n entries without rehashing.

Precompiling regular expressions

String.matches compiles its pattern on every call. A static final Pattern compiles it once.

import java.lang.management.ManagementFactory;
import java.util.regex.Pattern;

public class Regex {
    static final com.sun.management.ThreadMXBean THREADS =
        (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
    static final Pattern EMAIL = Pattern.compile("[a-z0-9]+@[a-z]+\\.com");

    public static void main(String[] args) {
        String[] inputs = new String[100_000];
        for (int i = 0; i < inputs.length; i++) inputs[i] = (i % 4 == 0 ? "BAD" : "user") + i + "@x.com";

        long t0 = THREADS.getCurrentThreadAllocatedBytes();
        int slow = 0;
        for (String s : inputs) if (s.matches("[a-z0-9]+@[a-z]+\\.com")) slow++;
        long t1 = THREADS.getCurrentThreadAllocatedBytes();
        int fast = 0;
        for (String s : inputs) if (EMAIL.matcher(s).matches()) fast++;
        long t2 = THREADS.getCurrentThreadAllocatedBytes();

        System.out.println("matches: " + slow + " and " + fast);
        System.out.println("String.matches allocated over 3x more? " + ((t1 - t0) > 3 * (t2 - t1)));
    }
}
$ java Regex.java
matches: 75000 and 75000
String.matches allocated over 3x more? true

Same answers, but each String.matches call builds a fresh Pattern object graph before matching. The same applies to String.split with a multi-character regex and replaceAll in hot code.

A real benchmark with JMH

For timing, use the Java Microbenchmark Harness. It forks a clean JVM, warms up, and measures steady state. The project needs jmh-core and its annotation processor:

<!-- file: pom.xml -->
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>demo</groupId>
  <artifactId>bench</artifactId>
  <version>1.0</version>
  <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <jmh.version>1.37</jmh.version>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.openjdk.jmh</groupId>
      <artifactId>jmh-core</artifactId>
      <version>${jmh.version}</version>
    </dependency>
    <dependency>
      <groupId>org.openjdk.jmh</groupId>
      <artifactId>jmh-generator-annprocess</artifactId>
      <version>${jmh.version}</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>
</project>

The benchmark compares the two string-building methods from above; a main runs it through JMH’s API and prints only a verdict, since the scores themselves vary by machine.

// file: src/main/java/bench/ConcatBench.java
package bench;

import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.options.*;
import org.openjdk.jmh.results.RunResult;

@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class ConcatBench {
    @Param("2000") int n;

    @Benchmark public String plus() { String s = ""; for (int i = 0; i < n; i++) s += i % 10; return s; }

    @Benchmark public String builder() {
        StringBuilder sb = new StringBuilder(n);
        for (int i = 0; i < n; i++) sb.append(i % 10);
        return sb.toString();
    }

    public static void main(String[] args) throws Exception {
        Options opts = new OptionsBuilder().include(ConcatBench.class.getSimpleName())
            .forks(1).warmupIterations(2).warmupTime(TimeValue.seconds(1))
            .measurementIterations(3).measurementTime(TimeValue.seconds(1))
            .verbosity(VerboseMode.SILENT).build();
        double plus = 0, builder = 0;
        for (RunResult r : new Runner(opts).run()) {
            double score = r.getPrimaryResult().getScore();
            if (r.getParams().getBenchmark().endsWith("plus")) plus = score; else builder = score;
        }
        System.out.println("builder at least 10x the throughput of +=? " + (builder > 10 * plus));
    }
}
$ mvn -q compile dependency:build-classpath -Dmdep.outputFile=cp.txt
$ java -cp target/classes:$(cat cp.txt) bench.ConcatBench
builder at least 10x the throughput of +=? true

Returning the string from each @Benchmark method hands it to JMH, which consumes it so the JIT cannot delete the work. In normal use you build a shaded jar and run java -jar benchmarks.jar, which prints a table of scores with error margins; add -prof gc to see allocation per operation alongside the time.

Practice

  1. Rewrite this method so it allocates no Integer objects, and check the result is unchanged: static Integer count(List<String> words) { Integer c = 0; for (String w : words) if (w.length() > 3) c++; return c; }
Solution
import java.util.List;

public class CountWords {
    static Integer countBoxed(List<String> words) {
        Integer c = 0;
        for (String w : words) if (w.length() > 3) c++;
        return c;
    }

    static int count(List<String> words) {
        int c = 0;
        for (String w : words) if (w.length() > 3) c++;
        return c;
    }

    public static void main(String[] args) {
        List<String> words = List.of("a", "tree", "is", "green", "and", "tall");
        System.out.println(countBoxed(words) + " " + count(words));
    }
}
$ java CountWords.java
3 3

c++ on an Integer is an unbox, an add and a box; above 127 every increment can allocate. An int local costs nothing.

  1. Join 1 to 10 with commas using Collectors.joining instead of += in a loop, and check you get the same string.
Solution
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class Join {
    public static void main(String[] args) {
        String loop = "";
        for (int i = 1; i <= 10; i++) loop += (i > 1 ? "," : "") + i;
        String joined = IntStream.rangeClosed(1, 10).mapToObj(String::valueOf)
            .collect(Collectors.joining(","));
        System.out.println(joined);
        System.out.println(loop.equals(joined));
    }
}
$ java Join.java
1,2,3,4,5,6,7,8,9,10
true

joining uses a single StringBuilder internally, and also handles the separator logic for you.

  1. Using the allocation counter, show that HashMap.newHashMap(100_000) allocates less than new HashMap<>() when inserting 100,000 entries.
Solution
import java.lang.management.ManagementFactory;
import java.util.HashMap;
import java.util.Map;

public class MapSize {
    static final com.sun.management.ThreadMXBean THREADS =
        (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();

    static long fill(Map<Integer, Integer> map) {
        long before = THREADS.getCurrentThreadAllocatedBytes();
        for (int i = 0; i < 100_000; i++) map.put(i, i);
        return THREADS.getCurrentThreadAllocatedBytes() - before;
    }

    public static void main(String[] args) {
        long growing = fill(new HashMap<>());
        long sized = fill(HashMap.newHashMap(100_000));
        System.out.println("presized allocates less? " + (sized < growing));
    }
}
$ java MapSize.java
presized allocates less? true

A default HashMap starts at 16 buckets and doubles, rehashing every entry each time it passes the 0.75 load factor. newHashMap picks a table big enough from the start.

Frequently Asked Questions

Why should I use JMH instead of timing code with System.nanoTime()?
A hand-written timing loop mostly measures JVM warm-up: the first iterations run in the interpreter while the JIT compiles the code. The JIT can also delete work whose result is unused. JMH runs warm-up iterations, forks a fresh JVM, and feeds results to a Blackhole so the measured code cannot be optimised away.
What is JIT compilation and how does it affect performance?
The JVM starts by interpreting bytecode, counts how often each method and loop runs, and compiles hot code to native machine code with optimisations such as inlining and escape analysis. Code is therefore slow for its first thousands of calls and much faster afterwards, which is why startup and steady-state performance differ.
What is escape analysis?
Escape analysis is a JIT optimisation that checks whether an object can be seen outside the method that creates it. If it cannot escape, the JIT may replace the object with plain local values so it is never allocated on the heap. It works only in compiled code and only when the object's use is fully visible to the compiler.
What are the most common Java performance mistakes?
Building strings with + in loops, boxing primitives in hot loops, growing collections whose size is known in advance, recompiling the same regex on every call through String.matches, and issuing one database query per item. The biggest mistake is optimising without measuring first, because intuition about bottlenecks is usually wrong.
How can I measure how much memory a piece of Java code allocates?
On HotSpot, cast ManagementFactory.getThreadMXBean() to com.sun.management.ThreadMXBean and call getCurrentThreadAllocatedBytes() before and after the code. For whole applications, Java Flight Recorder and JMH's -prof gc profiler report allocation rates without changing the code.