Chapter 4: Complexity, Memory and Performance Analysis
4.09Beginner7 topics16 min2 interactive animations

Practical performance

Theory predicts growth; measurement shows constants. This lesson covers benchmarking correctly, JIT warm-up, allocation and boxing costs, string concatenation, branch prediction and iteration locality.

Learning goals

  • Write a trustworthy micro-benchmark
  • Avoid the common Java performance traps
  • Explain branch prediction and locality effects

Prerequisites

Computer memory model
4.09.1

Benchmark harness

A reliable benchmark runs the code many times, discards warm-up runs, uses its result (so the JIT cannot delete it), repeats, and reports the median or minimum. For serious work use JMH, the OpenJDK Java Microbenchmark Harness. A plain System.nanoTime loop is a rough check.

Never compare single runs, and measure with realistic input sizes.

Java
1static long sink; // consuming the result stops dead-code elimination
2static long time(Runnable r, int reps) {
3 long best = Long.MAX_VALUE;
4 for (int i = 0; i < reps; i++) {
5 long t0 = System.nanoTime();
6 r.run();
7 best = Math.min(best, System.nanoTime() - t0);
8 }
9 return best;
10}
4.09.2

Warm-up and JIT

The JVM starts by interpreting bytecode and compiles frequently used methods to machine code (the JIT compiler), later recompiling with more optimisations. The first runs of a method are therefore slow, and code can also be deoptimised if its assumptions change. Benchmarks must run until the timings are stable.

This is why the first call of a method can be 10 to 100 times slower than the thousandth.

4.09.3

Allocation overhead

Creating objects is cheap in Java but not free: each allocation needs a header and later garbage collection. Allocating inside a loop that runs 10⁷ times (new arrays, small objects, iterator objects, boxed values) increases GC activity and cache misses. Hoist allocations out of loops and reuse buffers.

Measure before optimising: the JIT sometimes removes allocation entirely through escape analysis.

Java
1// allocates a new int[26] every call
2static boolean anagramSlow(String a, String b) { int[] f = new int[26]; /* ... */ return true; }
3// reuse one buffer: clear it instead of reallocating
4static final int[] F = new int[26];
5static void reset() { Arrays.fill(F, 0); }
4.09.4

Boxing

Boxing wraps a primitive in an object (int → Integer). Each boxed value costs a 16-byte object plus a reference, adds pointer-chasing and GC load, and is slower to add up. For hot loops and large collections prefer primitive arrays (int[]) or specialised structures.

The two sums below return the same value; the boxed one is typically several times slower for large n.

Java
1int n = 10_000_000;
2long a = 0; for (int i = 0; i < n; i++) a += i; // primitive: no allocation
3Long b = 0L; for (int i = 0; i < n; i++) b += i; // boxes a new Long on most iterations
4int[] prim = new int[n];
5List<Integer> boxed = new ArrayList<>(n); // about 5x the memory
4.09.5

String concatenation

Strings are immutable, so s += x creates a new string and copies all characters each time. Building a string of n characters this way costs O(n²). StringBuilder.append is amortised O(1) per character and builds the string in O(n) overall.

A single concatenation expression such as a + b + c is fine; the problem is concatenation inside a loop.

Java
1String s = "";
2for (int i = 0; i < 100_000; i++) s += i; // O(n^2): copies the whole string each time
3StringBuilder sb = new StringBuilder();
4for (int i = 0; i < 100_000; i++) sb.append(i); // O(n)
5String t = sb.toString();
4.09.6

Branch prediction

A CPU pipeline guesses which way each if will go and starts executing ahead. A correct guess is free; a wrong one throws away work and costs about 10 to 20 cycles. Predictable branches (always true, or following a pattern) are fast. Random ones, such as “is this random value above the median?”, are slow.

That is why summing only values above a threshold can be faster on sorted data than on the same data shuffled. The animation runs a 2-bit predictor over a pattern of your choice and counts mispredictions.

Java
1long sum = 0;
2for (int x : data) if (x >= 128) sum += x; // same work, but much faster when data is sorted

Branch prediction: sorted vs unsorted data

Interactive · edit the input

Your input

Try

branch history (predicted / actual)

N/Tx=72
T/Nx=13
N/Tx=88
T/Nx=41
N/Tx=65
T/Nx=9
N/Tx=94
T/Nx=30
N/Tx=57
T/Nx=22
N/Tx=81
T/Nx=46

predictor state (0-3)

0strong NT
1weak NT
2weak T
3strong T
Setup

Modern CPUs start executing the next instructions before the comparison finishes, guessing the branch outcome. A wrong guess flushes the pipeline (about 10-20 cycles).

Step 1 / 14
Java
1long sum = 0;
2for (int x : data) {
3 if (x >= 50) { // the CPU guesses taken / not taken BEFORE it knows x
4 sum += x;
5 }
6}
7// 2-bit saturating counter: 0,1 predict not-taken; 2,3 predict taken; each outcome moves it one step

Live state

0 mispredictions

counter

1

No condition evaluated on this step
Execution trace (dry run table)Show 14 steps
StepLineStateWhat happens
1L3counter=1Modern CPUs start executing the next instructions before the comparison finishes, guessing the branch outcome. A wrong guess flushes the pipeline (about 10-20 cycles).
2L3x=72 predicted=not taken actual=taken counter=1→2 mispredictions=1The predictor guessed not taken but the branch was taken: a misprediction, and the pipeline is flushed.
3L3x=13 predicted=taken actual=not taken counter=2→1 mispredictions=2The predictor guessed taken but the branch was not taken: a misprediction, and the pipeline is flushed.
4L3x=88 predicted=not taken actual=taken counter=1→2 mispredictions=3The predictor guessed not taken but the branch was taken: a misprediction, and the pipeline is flushed.
5L3x=41 predicted=taken actual=not taken counter=2→1 mispredictions=4The predictor guessed taken but the branch was not taken: a misprediction, and the pipeline is flushed.
6L3x=65 predicted=not taken actual=taken counter=1→2 mispredictions=5The predictor guessed not taken but the branch was taken: a misprediction, and the pipeline is flushed.
7L3x=9 predicted=taken actual=not taken counter=2→1 mispredictions=6The predictor guessed taken but the branch was not taken: a misprediction, and the pipeline is flushed.
8L3x=94 predicted=not taken actual=taken counter=1→2 mispredictions=7The predictor guessed not taken but the branch was taken: a misprediction, and the pipeline is flushed.
9L3x=30 predicted=taken actual=not taken counter=2→1 mispredictions=8The predictor guessed taken but the branch was not taken: a misprediction, and the pipeline is flushed.
10L3x=57 predicted=not taken actual=taken counter=1→2 mispredictions=9The predictor guessed not taken but the branch was taken: a misprediction, and the pipeline is flushed.
11L3x=22 predicted=taken actual=not taken counter=2→1 mispredictions=10The predictor guessed taken but the branch was not taken: a misprediction, and the pipeline is flushed.
12L3x=81 predicted=not taken actual=taken counter=1→2 mispredictions=11The predictor guessed not taken but the branch was taken: a misprediction, and the pipeline is flushed.
13L3x=46 predicted=taken actual=not taken counter=2→1 mispredictions=12The predictor guessed taken but the branch was not taken: a misprediction, and the pipeline is flushed.
14L6branches=12 mispredictions=12 return=1212 mispredictions in 12 branches. Sorted data makes the outcomes come in long runs, which a predictor learns; random data defeats it. The same code can run several times faster on sorted input although the algorithm is identical.
4.09.7

Iteration locality

The order in which loops visit memory decides how many cache lines are loaded. Iterate the innermost loop over the last array index (columns of a row), walk linked structures only when needed and store related fields together. Blocking (tiling) a big matrix operation into small tiles that fit in cache is a standard optimisation.

Compare row-order and column-order traversal on the same grid and read the hit and miss counts.

Cache lines: row-major vs column-major traversal

Interactive · edit the input

Your input

Try

memory (row-major, 4 ints per cache line)

0line 0
1line 0
2line 0
3line 0
4line 1
5line 1
6line 1
7line 1
8line 2
9line 2
10line 2
11line 2
12line 3
13line 3
14line 3
15line 3
16line 4
17line 4
18line 4
19line 4
20line 5
21line 5
22line 5
23line 5
24line 6
25line 6
26line 6
27line 6
28line 7
29line 7
30line 7
31line 7

cache (2 lines, LRU)

empty
Setup

A 4×8 matrix is stored row by row in one long run of memory. The CPU fetches memory in cache lines of 4 ints and keeps the 2 most recently used lines.

Step 1 / 34
Java
1int sumRowMajor(int[][] m) { // inner loop walks along a row
2 int sum = 0;
3 for (int r = 0; r < m.length; r++)
4 for (int c = 0; c < m[0].length; c++) sum += m[r][c];
5 return sum;
6}
7int sumColumnMajor(int[][] m) { // inner loop jumps down a column
8 int sum = 0;
9 for (int c = 0; c < m[0].length; c++)
10 for (int r = 0; r < m.length; r++) sum += m[r][c];
11 return sum;
12}

Live state

0 hits0 misses

rows

4

cols

8

lineSize

4

cache lines

2

No condition evaluated on this step
Execution trace (dry run table)Show 34 steps
StepLineStateWhat happens
1L3rows=4 cols=8 lineSize=4 cache lines=2A 4×8 matrix is stored row by row in one long run of memory. The CPU fetches memory in cache lines of 4 ints and keeps the 2 most recently used lines.
2L4r=0 c=0 address=0 line=0 hits=0 misses=1m[0][0] is memory cell 0, on line 0, which is not cached: a miss. The CPU stalls while the whole line is loaded.
3L4r=0 c=1 address=1 line=0 hits=1 misses=1m[0][1] is memory cell 1, on line 0, which is already in the cache: a hit (fast).
4L4r=0 c=2 address=2 line=0 hits=2 misses=1m[0][2] is memory cell 2, on line 0, which is already in the cache: a hit (fast).
5L4r=0 c=3 address=3 line=0 hits=3 misses=1m[0][3] is memory cell 3, on line 0, which is already in the cache: a hit (fast).
6L4r=0 c=4 address=4 line=1 hits=3 misses=2m[0][4] is memory cell 4, on line 1, which is not cached: a miss. The CPU stalls while the whole line is loaded.
7L4r=0 c=5 address=5 line=1 hits=4 misses=2m[0][5] is memory cell 5, on line 1, which is already in the cache: a hit (fast).
8L4r=0 c=6 address=6 line=1 hits=5 misses=2m[0][6] is memory cell 6, on line 1, which is already in the cache: a hit (fast).
9L4r=0 c=7 address=7 line=1 hits=6 misses=2m[0][7] is memory cell 7, on line 1, which is already in the cache: a hit (fast).
10L4r=1 c=0 address=8 line=2 hits=6 misses=3m[1][0] is memory cell 8, on line 2, which is not cached: a miss. The CPU stalls while the whole line is loaded.
11L4r=1 c=1 address=9 line=2 hits=7 misses=3m[1][1] is memory cell 9, on line 2, which is already in the cache: a hit (fast).
12L4r=1 c=2 address=10 line=2 hits=8 misses=3m[1][2] is memory cell 10, on line 2, which is already in the cache: a hit (fast).
13L4r=1 c=3 address=11 line=2 hits=9 misses=3m[1][3] is memory cell 11, on line 2, which is already in the cache: a hit (fast).
14L4r=1 c=4 address=12 line=3 hits=9 misses=4m[1][4] is memory cell 12, on line 3, which is not cached: a miss. The CPU stalls while the whole line is loaded.
15L4r=1 c=5 address=13 line=3 hits=10 misses=4m[1][5] is memory cell 13, on line 3, which is already in the cache: a hit (fast).
16L4r=1 c=6 address=14 line=3 hits=11 misses=4m[1][6] is memory cell 14, on line 3, which is already in the cache: a hit (fast).
17L4r=1 c=7 address=15 line=3 hits=12 misses=4m[1][7] is memory cell 15, on line 3, which is already in the cache: a hit (fast).
18L4r=2 c=0 address=16 line=4 hits=12 misses=5m[2][0] is memory cell 16, on line 4, which is not cached: a miss. The CPU stalls while the whole line is loaded.
19L4r=2 c=1 address=17 line=4 hits=13 misses=5m[2][1] is memory cell 17, on line 4, which is already in the cache: a hit (fast).
20L4r=2 c=2 address=18 line=4 hits=14 misses=5m[2][2] is memory cell 18, on line 4, which is already in the cache: a hit (fast).
21L4r=2 c=3 address=19 line=4 hits=15 misses=5m[2][3] is memory cell 19, on line 4, which is already in the cache: a hit (fast).
22L4r=2 c=4 address=20 line=5 hits=15 misses=6m[2][4] is memory cell 20, on line 5, which is not cached: a miss. The CPU stalls while the whole line is loaded.
23L4r=2 c=5 address=21 line=5 hits=16 misses=6m[2][5] is memory cell 21, on line 5, which is already in the cache: a hit (fast).
24L4r=2 c=6 address=22 line=5 hits=17 misses=6m[2][6] is memory cell 22, on line 5, which is already in the cache: a hit (fast).
25L4r=2 c=7 address=23 line=5 hits=18 misses=6m[2][7] is memory cell 23, on line 5, which is already in the cache: a hit (fast).
26L4r=3 c=0 address=24 line=6 hits=18 misses=7m[3][0] is memory cell 24, on line 6, which is not cached: a miss. The CPU stalls while the whole line is loaded.
27L4r=3 c=1 address=25 line=6 hits=19 misses=7m[3][1] is memory cell 25, on line 6, which is already in the cache: a hit (fast).
28L4r=3 c=2 address=26 line=6 hits=20 misses=7m[3][2] is memory cell 26, on line 6, which is already in the cache: a hit (fast).
29L4r=3 c=3 address=27 line=6 hits=21 misses=7m[3][3] is memory cell 27, on line 6, which is already in the cache: a hit (fast).
30L4r=3 c=4 address=28 line=7 hits=21 misses=8m[3][4] is memory cell 28, on line 7, which is not cached: a miss. The CPU stalls while the whole line is loaded.
31L4r=3 c=5 address=29 line=7 hits=22 misses=8m[3][5] is memory cell 29, on line 7, which is already in the cache: a hit (fast).
32L4r=3 c=6 address=30 line=7 hits=23 misses=8m[3][6] is memory cell 30, on line 7, which is already in the cache: a hit (fast).
33L4r=3 c=7 address=31 line=7 hits=24 misses=8m[3][7] is memory cell 31, on line 7, which is already in the cache: a hit (fast).
34L5accesses=32 hits=24 misses=8 hit rate %=75 return=88 misss and 24 hits (75% hit rate). Both loops do identical arithmetic and are O(rows × cols), but walking along a row uses every int in a loaded line before moving on. Switch the order and compare.

Practice ladder

Now try it yourself

Easy

  • Why is one timing of a method not a benchmark?
  • Replace s += x in a loop with the right structure

Medium

  • Explain why boxed Integer lists use about five times the memory of int arrays
  • Predict whether sorted or shuffled data is faster for a thresholded sum, and why

Advanced

  • Write a JMH benchmark comparing row-major and column-major traversal for n = 2048

Key takeaways

  • Benchmark with warm-up, repetition and consumed results; use JMH for decisions.
  • Avoid boxing, repeated allocation and string concatenation in hot loops.
  • Predictable branches and sequential access are what hardware rewards.