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
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.
1static long sink; // consuming the result stops dead-code elimination2static long time(Runnable r, int reps) {3long best = Long.MAX_VALUE;4for (int i = 0; i < reps; i++) {5long t0 = System.nanoTime();6r.run();7best = Math.min(best, System.nanoTime() - t0);8}9return best;10}
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.
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.
1// allocates a new int[26] every call2static boolean anagramSlow(String a, String b) { int[] f = new int[26]; /* ... */ return true; }3// reuse one buffer: clear it instead of reallocating4static final int[] F = new int[26];5static void reset() { Arrays.fill(F, 0); }
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.
1int n = 10_000_000;2long a = 0; for (int i = 0; i < n; i++) a += i; // primitive: no allocation3Long b = 0L; for (int i = 0; i < n; i++) b += i; // boxes a new Long on most iterations4int[] prim = new int[n];5List<Integer> boxed = new ArrayList<>(n); // about 5x the memory
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.
1String s = "";2for (int i = 0; i < 100_000; i++) s += i; // O(n^2): copies the whole string each time3StringBuilder sb = new StringBuilder();4for (int i = 0; i < 100_000; i++) sb.append(i); // O(n)5String t = sb.toString();
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.
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 inputbranch history (predicted / actual)
predictor state (0-3)
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).
1long sum = 0;2for (int x : data) {3if (x >= 50) { // the CPU guesses taken / not taken BEFORE it knows x4sum += 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
counter
1
Execution trace (dry run table)Show 14 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L3 | counter=1 | 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). |
| 2 | L3 | x=72 predicted=not taken actual=taken counter=1→2 mispredictions=1 | The predictor guessed not taken but the branch was taken: a misprediction, and the pipeline is flushed. |
| 3 | L3 | x=13 predicted=taken actual=not taken counter=2→1 mispredictions=2 | The predictor guessed taken but the branch was not taken: a misprediction, and the pipeline is flushed. |
| 4 | L3 | x=88 predicted=not taken actual=taken counter=1→2 mispredictions=3 | The predictor guessed not taken but the branch was taken: a misprediction, and the pipeline is flushed. |
| 5 | L3 | x=41 predicted=taken actual=not taken counter=2→1 mispredictions=4 | The predictor guessed taken but the branch was not taken: a misprediction, and the pipeline is flushed. |
| 6 | L3 | x=65 predicted=not taken actual=taken counter=1→2 mispredictions=5 | The predictor guessed not taken but the branch was taken: a misprediction, and the pipeline is flushed. |
| 7 | L3 | x=9 predicted=taken actual=not taken counter=2→1 mispredictions=6 | The predictor guessed taken but the branch was not taken: a misprediction, and the pipeline is flushed. |
| 8 | L3 | x=94 predicted=not taken actual=taken counter=1→2 mispredictions=7 | The predictor guessed not taken but the branch was taken: a misprediction, and the pipeline is flushed. |
| 9 | L3 | x=30 predicted=taken actual=not taken counter=2→1 mispredictions=8 | The predictor guessed taken but the branch was not taken: a misprediction, and the pipeline is flushed. |
| 10 | L3 | x=57 predicted=not taken actual=taken counter=1→2 mispredictions=9 | The predictor guessed not taken but the branch was taken: a misprediction, and the pipeline is flushed. |
| 11 | L3 | x=22 predicted=taken actual=not taken counter=2→1 mispredictions=10 | The predictor guessed taken but the branch was not taken: a misprediction, and the pipeline is flushed. |
| 12 | L3 | x=81 predicted=not taken actual=taken counter=1→2 mispredictions=11 | The predictor guessed not taken but the branch was taken: a misprediction, and the pipeline is flushed. |
| 13 | L3 | x=46 predicted=taken actual=not taken counter=2→1 mispredictions=12 | The predictor guessed taken but the branch was not taken: a misprediction, and the pipeline is flushed. |
| 14 | L6 | branches=12 mispredictions=12 return=12 | 12 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. |
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 inputmemory (row-major, 4 ints per cache line)
cache (2 lines, LRU)
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.
1int sumRowMajor(int[][] m) { // inner loop walks along a row2int sum = 0;3for (int r = 0; r < m.length; r++)4for (int c = 0; c < m[0].length; c++) sum += m[r][c];5return sum;6}7int sumColumnMajor(int[][] m) { // inner loop jumps down a column8int sum = 0;9for (int c = 0; c < m[0].length; c++)10for (int r = 0; r < m.length; r++) sum += m[r][c];11return sum;12}
Live state
rows
4
cols
8
lineSize
4
cache lines
2
Execution trace (dry run table)Show 34 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L3 | rows=4 cols=8 lineSize=4 cache lines=2 | 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. |
| 2 | L4 | r=0 c=0 address=0 line=0 hits=0 misses=1 | m[0][0] is memory cell 0, on line 0, which is not cached: a miss. The CPU stalls while the whole line is loaded. |
| 3 | L4 | r=0 c=1 address=1 line=0 hits=1 misses=1 | m[0][1] is memory cell 1, on line 0, which is already in the cache: a hit (fast). |
| 4 | L4 | r=0 c=2 address=2 line=0 hits=2 misses=1 | m[0][2] is memory cell 2, on line 0, which is already in the cache: a hit (fast). |
| 5 | L4 | r=0 c=3 address=3 line=0 hits=3 misses=1 | m[0][3] is memory cell 3, on line 0, which is already in the cache: a hit (fast). |
| 6 | L4 | r=0 c=4 address=4 line=1 hits=3 misses=2 | m[0][4] is memory cell 4, on line 1, which is not cached: a miss. The CPU stalls while the whole line is loaded. |
| 7 | L4 | r=0 c=5 address=5 line=1 hits=4 misses=2 | m[0][5] is memory cell 5, on line 1, which is already in the cache: a hit (fast). |
| 8 | L4 | r=0 c=6 address=6 line=1 hits=5 misses=2 | m[0][6] is memory cell 6, on line 1, which is already in the cache: a hit (fast). |
| 9 | L4 | r=0 c=7 address=7 line=1 hits=6 misses=2 | m[0][7] is memory cell 7, on line 1, which is already in the cache: a hit (fast). |
| 10 | L4 | r=1 c=0 address=8 line=2 hits=6 misses=3 | m[1][0] is memory cell 8, on line 2, which is not cached: a miss. The CPU stalls while the whole line is loaded. |
| 11 | L4 | r=1 c=1 address=9 line=2 hits=7 misses=3 | m[1][1] is memory cell 9, on line 2, which is already in the cache: a hit (fast). |
| 12 | L4 | r=1 c=2 address=10 line=2 hits=8 misses=3 | m[1][2] is memory cell 10, on line 2, which is already in the cache: a hit (fast). |
| 13 | L4 | r=1 c=3 address=11 line=2 hits=9 misses=3 | m[1][3] is memory cell 11, on line 2, which is already in the cache: a hit (fast). |
| 14 | L4 | r=1 c=4 address=12 line=3 hits=9 misses=4 | m[1][4] is memory cell 12, on line 3, which is not cached: a miss. The CPU stalls while the whole line is loaded. |
| 15 | L4 | r=1 c=5 address=13 line=3 hits=10 misses=4 | m[1][5] is memory cell 13, on line 3, which is already in the cache: a hit (fast). |
| 16 | L4 | r=1 c=6 address=14 line=3 hits=11 misses=4 | m[1][6] is memory cell 14, on line 3, which is already in the cache: a hit (fast). |
| 17 | L4 | r=1 c=7 address=15 line=3 hits=12 misses=4 | m[1][7] is memory cell 15, on line 3, which is already in the cache: a hit (fast). |
| 18 | L4 | r=2 c=0 address=16 line=4 hits=12 misses=5 | m[2][0] is memory cell 16, on line 4, which is not cached: a miss. The CPU stalls while the whole line is loaded. |
| 19 | L4 | r=2 c=1 address=17 line=4 hits=13 misses=5 | m[2][1] is memory cell 17, on line 4, which is already in the cache: a hit (fast). |
| 20 | L4 | r=2 c=2 address=18 line=4 hits=14 misses=5 | m[2][2] is memory cell 18, on line 4, which is already in the cache: a hit (fast). |
| 21 | L4 | r=2 c=3 address=19 line=4 hits=15 misses=5 | m[2][3] is memory cell 19, on line 4, which is already in the cache: a hit (fast). |
| 22 | L4 | r=2 c=4 address=20 line=5 hits=15 misses=6 | m[2][4] is memory cell 20, on line 5, which is not cached: a miss. The CPU stalls while the whole line is loaded. |
| 23 | L4 | r=2 c=5 address=21 line=5 hits=16 misses=6 | m[2][5] is memory cell 21, on line 5, which is already in the cache: a hit (fast). |
| 24 | L4 | r=2 c=6 address=22 line=5 hits=17 misses=6 | m[2][6] is memory cell 22, on line 5, which is already in the cache: a hit (fast). |
| 25 | L4 | r=2 c=7 address=23 line=5 hits=18 misses=6 | m[2][7] is memory cell 23, on line 5, which is already in the cache: a hit (fast). |
| 26 | L4 | r=3 c=0 address=24 line=6 hits=18 misses=7 | m[3][0] is memory cell 24, on line 6, which is not cached: a miss. The CPU stalls while the whole line is loaded. |
| 27 | L4 | r=3 c=1 address=25 line=6 hits=19 misses=7 | m[3][1] is memory cell 25, on line 6, which is already in the cache: a hit (fast). |
| 28 | L4 | r=3 c=2 address=26 line=6 hits=20 misses=7 | m[3][2] is memory cell 26, on line 6, which is already in the cache: a hit (fast). |
| 29 | L4 | r=3 c=3 address=27 line=6 hits=21 misses=7 | m[3][3] is memory cell 27, on line 6, which is already in the cache: a hit (fast). |
| 30 | L4 | r=3 c=4 address=28 line=7 hits=21 misses=8 | m[3][4] is memory cell 28, on line 7, which is not cached: a miss. The CPU stalls while the whole line is loaded. |
| 31 | L4 | r=3 c=5 address=29 line=7 hits=22 misses=8 | m[3][5] is memory cell 29, on line 7, which is already in the cache: a hit (fast). |
| 32 | L4 | r=3 c=6 address=30 line=7 hits=23 misses=8 | m[3][6] is memory cell 30, on line 7, which is already in the cache: a hit (fast). |
| 33 | L4 | r=3 c=7 address=31 line=7 hits=24 misses=8 | m[3][7] is memory cell 31, on line 7, which is already in the cache: a hit (fast). |
| 34 | L5 | accesses=32 hits=24 misses=8 hit rate %=75 return=8 | 8 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 += xin 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.