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
StringBuilderin a loop;+=on strings is quadratic.- Boxing allocates - use primitives and
IntStream/mapToIntin hot paths. HashSet/HashMapfor membership; presize anArrayListwhen 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.