Java Performance Tips
Practical performance tips for Java — measure first, avoid premature optimization, common micro-optimizations.
Java is fast enough for almost everything, but slow code still happens — usually from doing too much work, allocating too much memory, or picking the wrong data structure. The most important performance tip is not a trick at all: measure before you change anything. This chapter covers how to measure honestly, the handful of optimizations that pay off most often (string building, data-structure choice, avoiding needless allocation), and the gotchas that make naive "fast" code actually slow.
Measure first, optimize second
Guessing about performance is how you spend hours speeding up code that was never slow. Profile a real workload, find the hot path (the small fraction of code where most of the time is actually spent), and only then optimize it. For quick experiments, System.nanoTime() gives a wall-clock delta; for serious benchmarks use a tool like JMH (Java Microbenchmark Harness), which warms up the JIT and accounts for measurement noise.
long start = System.nanoTime();
doWork();
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
System.out.println("Took " + elapsedMs + " ms");Two rules go with this. First, avoid premature optimization — clear code that is fast enough beats clever code that is hard to read. Second, the JVM's JIT compiler optimizes hot code at runtime, so a method only reaches full speed after it has run many times; a single timed call tells you little.
Build strings with StringBuilder
Because strings are immutable, s += x inside a loop creates a brand-new string and copies every previous character on each iteration — that is O(n²) work. StringBuilder keeps a growable buffer and appends in place, turning the same job into O(n).
// Slow: a new String allocated every iteration
String csv = "";
for (String field : fields) {
csv += field + ",";
}
// Fast: one buffer, appended in place
StringBuilder sb = new StringBuilder();
for (String field : fields) {
sb.append(field).append(',');
}
String csv2 = sb.toString();A single a + b + c outside a loop is fine — the compiler already turns it into one StringBuilder (see string concatenation for what the compiler does). The problem is concatenation inside a loop, where each pass adds another full copy.
Pick the right data structure
The biggest wins usually come from algorithmic choices, not micro-tweaks. Looking up a value in an ArrayList scans every element (O(n)); a HashMap or HashSet does it in roughly constant time (O(1)). Choose the collection that matches how you actually access the data.
| Need | Use | Lookup cost |
|---|---|---|
| Index access, append at end | ArrayList | O(1) by index, O(n) by value |
| Key/value lookup | HashMap | O(1) average |
| Membership test, no duplicates | HashSet | O(1) average |
| Sorted keys | TreeMap | O(log n) |
| Frequent insert/remove at ends | ArrayDeque | O(1) at ends |
If you know the final size, pass it to the constructor: new ArrayList<>(10_000) or new HashMap<>(capacity). This avoids the repeated reallocation and copying that happens as a collection grows.
Avoid needless object creation
Every object allocated must later be collected, and garbage collection is not free. Reuse immutable values, prefer primitives over their boxed wrappers in tight loops, and don't create objects you immediately throw away.
// Autoboxing: every += boxes a new Integer
Long total = 0L;
for (int i = 0; i < n; i++) total += i; // slow, allocates boxes
// Primitive: no allocation at all
long sum = 0L;
for (int i = 0; i < n; i++) sum += i; // fastOther easy wins: cache compiled Pattern objects instead of calling String.matches() in a loop, reuse a DateTimeFormatter (it's thread-safe and immutable), and favor enhanced for over streams in the hottest inner loops where allocation matters.
What to take from the run:
Same result? trueprovesStringBuilderproduces exactly the same string as+=, so swapping it in changes only speed, never correctness.- The printed "x slower" ratio shows loop concatenation costs many times more than appending to a buffer, because each
+=copies the whole string so far. Both lists size 100000: trueconfirms a pre-sizedArrayListends up identical to a grown one — the constructor hint affects allocation, not contents.Pre-sized faster? trueshows that tellingArrayListits capacity up front avoids the repeated resize-and-copy steps.Map lookups found: 50000 in ... msdemonstrates that 50,000HashMaplookups finish in roughly a millisecond, the payoff of choosing O(1) access over an O(n) list scan.
Common pitfalls
A few mistakes turn "obviously faster" code into the opposite:
- Trusting a single timed run. The JIT hasn't warmed up yet, and the OS may have scheduled something else mid-measurement. Repeat the work thousands of times, or use JMH, before believing a number.
- Micro-optimizing cold code. A method that runs once at startup gains nothing from a tighter loop. Spend effort only on the hot path the profiler points to.
- String building with
+in a loop. The single most common avoidable slowdown — reach forStringBuilderwhenever you concatenate inside a loop. - Hidden autoboxing. A
List<Integer>,Map<Integer, Integer>, or a strayLongaccumulator boxes every value. In a tight numeric loop, prefer primitives and primitive arrays. - Optimizing before it works. Correct first, fast second. Clear code you can profile beats clever code you can't reason about.
Summary
- Measure before changing anything — profile a real workload and optimize only the hot path.
- Build strings with
StringBuilder, not+=, inside loops. - Match the collection to the access pattern:
HashMap/HashSetfor lookups,ArrayListfor indexed access; pre-size when the count is known. - Avoid needless allocation: prefer primitives, reuse immutable objects, and remember that every object adds garbage-collection work.