Where Java Spends Its Time

Article · 14:00

Four things account for most Java performance problems: building strings in a loop, boxing in a hot path, the wrong collection, and measuring the JIT warm-up instead of the code. The first three are avoidable; the fourth is why your first measurement is wrong.

Where Java Spends Its Time

Four things account for most Java performance problems: building strings in a loop, boxing in a hot path, the wrong collection, and measuring the JIT's warm-up instead of the code. The first three are avoidable; the fourth is the reason most benchmarks are wrong.

public class Strings {
    public static void main(String[] args) {
        int n = 40_000;
        int checksum = 0;                     // used, so neither loop is removable

        long start = System.nanoTime();
        String concatenated = "";
        for (int i = 0; i < n; i++) concatenated += 'x';
        long plusEquals = System.nanoTime() - start;
        checksum += concatenated.length();

        start = System.nanoTime();
        var builder = new StringBuilder();
        for (int i = 0; i < n; i++) builder.append('x');
        String built = builder.toString();
        long stringBuilder = System.nanoTime() - start;
        checksum += built.length();

        System.out.println("+=            " + plusEquals / 1_000_000 + "ms");
        System.out.println("StringBuilder " + stringBuilder / 1_000_000 + "ms");
        System.out.println("builder is faster: " + (stringBuilder < plusEquals));
        System.out.println("both built " + checksum / 2 + " characters");
    }
}

String is immutable, so s += "x" allocates a new string and copies everything so far: the loop is quadratic. StringBuilder appends into one array that grows geometrically. (A + between two literals or in a single expression is compiled to exactly that already - it is only the loop that is the problem.)

Boxing in a hot path

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

        long start = System.nanoTime();
        long primitive = 0;
        for (int i = 0; i < n; i++) primitive += i;
        long fast = System.nanoTime() - start;

        start = System.nanoTime();
        Long boxed = 0L;                       // every += allocates a new Long
        for (int i = 0; i < n; i++) boxed += i;
        long slow = System.nanoTime() - start;

        System.out.println("long  " + fast / 1_000_000 + "ms  -> " + primitive);
        System.out.println("Long  " + slow / 1_000_000 + "ms  -> " + boxed);
        System.out.println("same answer: " + (primitive == boxed));
    }
}

The same effect hides inside collections: a List<Integer> boxes every element, and IntStream exists to let you avoid it. Use mapToInt before a numeric reduction rather than map and a Stream<Integer>.

The collection is the algorithm

import java.util.*;

public class Structures {
    public static void main(String[] args) {
        int n = 20_000;
        List<Integer> haystack = new ArrayList<>();
        for (int i = 0; i < n; i++) haystack.add(i);

        long start = System.nanoTime();
        int foundByScan = 0;
        for (int i = 0; i < n; i++) if (haystack.contains(i)) foundByScan++;
        long scan = System.nanoTime() - start;

        Set<Integer> set = new HashSet<>(haystack);
        start = System.nanoTime();
        int foundByHash = 0;
        for (int i = 0; i < n; i++) if (set.contains(i)) foundByHash++;
        long hash = System.nanoTime() - start;

        System.out.println("List.contains " + scan / 1_000_000 + "ms");
        System.out.println("Set.contains  " + hash / 1_000_000 + "ms");
        System.out.println("same answer: " + (foundByScan == foundByHash));

        // And if the size is known, say so: an ArrayList that has to grow
        // copies its array each time it doubles.
        start = System.nanoTime();
        List<Integer> grows = new ArrayList<>();
        for (int i = 0; i < 1_000_000; i++) grows.add(i);
        long growing = System.nanoTime() - start;

        start = System.nanoTime();
        List<Integer> sized = new ArrayList<>(1_000_000);
        for (int i = 0; i < 1_000_000; i++) sized.add(i);
        long presized = System.nanoTime() - start;

        System.out.println("growing " + growing / 1_000_000 + "ms, presized "
                + presized / 1_000_000 + "ms");
    }
}

List.contains is a linear scan; HashSet.contains is a hash lookup. Picking the right structure is worth more than any amount of tuning inside the loop.

Why your first measurement is wrong

Java starts by interpreting bytecode and compiles the hot parts as it goes. The first thousand iterations of anything are therefore slower than the millionth, sometimes by an order of magnitude.

public class Warmup {
    public static void main(String[] args) {
        for (int round = 1; round <= 5; round++) {
            long start = System.nanoTime();
            long total = 0;
            for (int i = 0; i < 5_000_000; i++) total += work(i);
            System.out.println("round " + round + ": " + (System.nanoTime() - start) / 1_000_000
                    + "ms (" + total + ")");
        }
        System.out.println("the same loop, five times - and the first is not like the last");
    }

    static long work(int i) {
        return (i % 7) * 3L;
    }
}

So: warm up before timing, run several rounds, use the later ones, and never benchmark a loop whose result is unused - the JIT is entitled to notice and delete it. For anything you will act on, use JMH (org.openjdk.jmh), which handles warm-up, dead-code elimination and statistics properly.

Measure the program, not your guess

Before optimising anything, find out where the time actually goes. The JDK ships the tools: jcmd <pid> Thread.print for a thread dump, Java Flight Recorder (java -XX:StartFlightRecording=...) for a low-overhead profile of a real run, and -Xlog:gc if you suspect the collector.

Most "slow Java" turns out to be one of: an N+1 query, a lock everything queues behind, an unbounded cache causing collections, or an algorithm that is quadratic in the size of the input. None of those are found by staring at the code, and none are fixed by micro-tuning.

What to take away

  • StringBuilder in a loop; += on strings is quadratic.
  • Boxing allocates - use primitives and IntStream/mapToInt in hot paths.
  • HashSet/HashMap for membership; presize an ArrayList when you know the size.
  • The JIT needs warm-up: the first measurement is never the steady state.
  • Never benchmark a result you do not use; use JMH for anything that matters.
  • Profile before optimising - it is usually a query, a lock, or an algorithm.