Performance Basics in Java
If you want to reach school faster, buying expensive running shoes helps a little. Not taking the long way round helps a lot more. Code is similar. Most programs don't need clever tricks to be fast; they need to avoid a handful of well-known slow paths that turn a millisecond job into a second-long one. In this lesson, you will learn those everyday performance rules (building strings, boxing, picking and sizing collections) with real timings measured on Java 25, and the most important rule of all: measure before you optimize.
What Are Performance Basics?
Performance basics in Java are a small set of habits that are used to avoid unnecessary work in ordinary code, without making it harder to read. They are not about tuning the JVM or rewriting algorithms. They are the kind of choices a reviewer points out in a pull request: "use a StringBuilder here", "this should be a Set".
The timings in this lesson were measured on an Apple Silicon laptop with Java 25, after warming up the code. Your numbers will differ, but the differences between the choices are what matter.
| Choice | Slow way | Fast way | Measured difference |
|---|---|---|---|
| Building a 20,000-character string in a loop | s += x | StringBuilder.append | 8.8 ms vs 0.07 ms (about 130 times) |
| Adding 10 million numbers | Long sum | long sum | 21 ms vs 2.3 ms (about 9 times) |
| 50,000 lookups in 50,000 items | List.contains | Set.contains | 500 ms vs 0.2 ms (thousands of times) |
| Adding 1 million items to a list | no initial size | new ArrayList<>(1_000_000) | 6.4 ms vs 5.6 ms (about 10 percent) |
Notice the last row: the famous "always set the capacity" tip gave the smallest gain. The big wins come from avoiding the wrong tool, not from fine-tuning the right one.
Rule 1: Use StringBuilder to Build Strings in Loops
A String is immutable. s += x inside a loop creates a new string every time and copies all the old characters into it, so the work grows with the square of the length. A StringBuilder grows one buffer in place.
public class RollCall {
public static void main(String[] args) {
String[] names = {"Navika", "Srihitha", "Advitha"};
StringBuilder line = new StringBuilder();
for (int i = 0; i < names.length; i++) {
if (i > 0) {
line.append(", ");
}
line.append(i + 1).append(". ").append(names[i]);
}
System.out.println(line);
System.out.println(String.join(" | ", names));
}
}1. Navika, 2. Srihitha, 3. Advitha
Navika | Srihitha | AdvithaExplanation: the loop appends to one builder and converts it to a string once, when printing. For simple joining, String.join (or Collectors.joining with streams) does the same job in one line.
Note that a single expression like "Total: " + total + " rupees" is fine. The compiler already optimizes concatenation within one statement. The problem is only += across loop iterations. See "String Concatenation in Java" for the details.
Rule 2: Avoid Boxing in Hot Loops
Long, Integer and Double are objects. When you do arithmetic on them, Java unboxes, computes, and boxes the result into a new object each time. In a loop that runs millions of times, that means millions of short-lived objects.
public class Totals {
public static void main(String[] args) {
long fast = 0; // primitive: no objects
for (int i = 1; i <= 1_000_000; i++) {
fast += i;
}
Long slow = 0L; // boxed: a new Long most steps
for (int i = 1; i <= 1_000_000; i++) {
slow += i;
}
System.out.println(fast);
System.out.println(slow);
}
}500000500000
500000500000Explanation: both loops print the same sum, but the second creates a new Long object on almost every step (only values from -128 to 127 are cached). With 10 million steps, the boxed version was about 9 times slower. A one-letter difference, Long versus long, is easy to miss in review.
The same applies to collections: a List<Integer> with a million items holds a million objects. If you only need numbers, an int[] or an IntStream is much lighter.
Rule 3: Pick the Right Collection
The biggest single performance mistake in everyday Java is using a List where you need fast lookups.
| You need to | Use | Lookup cost |
|---|---|---|
| Keep order and access by position | ArrayList | get(i) is instant; contains checks every item |
| Check "is this in the group?" | HashSet | contains is nearly instant at any size |
| Look up a value by key | HashMap | get is nearly instant at any size |
| Keep items sorted | TreeSet, TreeMap | Fast, grows slowly with size |
| Add and remove at both ends | ArrayDeque | Instant at both ends |
list.contains(x) looks at every element until it finds a match. Doing that inside a loop over another large list multiplies the work: in the measured test, 50,000 lookups took about half a second with a list and a fifth of a millisecond with a set.
Rule 4: Size Collections When You Know the Size
An ArrayList starts small and copies itself into a bigger array whenever it fills up. A HashMap rebuilds its table when it gets 75 percent full. If you know roughly how many items are coming, say so up front:
| Collection | Sized for about n items |
|---|---|
ArrayList | new ArrayList<>(n) |
HashMap (Java 19+) | HashMap.newHashMap(n) |
HashSet (Java 19+) | HashSet.newHashSet(n) |
StringBuilder | new StringBuilder(expectedLength) |
Note that new HashMap<>(n) sets the table size, not the number of entries it can hold before growing. Because of the 75 percent rule, it still resizes before it reaches n entries. HashMap.newHashMap(n) does the arithmetic for you.
As the table at the top shows, sizing is a small gain: about 10 percent for a million-item list, and too small to measure for the map in that test. Do it when the size is known and the code is hot; don't contort code for it.
Rule 5: Measure Before You Optimize
Donald Knuth's famous warning is that premature optimization is the root of all evil: time spent speeding up code that isn't slow is wasted, and it usually makes the code harder to read.
- Write clear code first.
- Measure with a profiler (IntelliJ IDEA's profiler, JDK Flight Recorder, VisualVM) to find where time actually goes.
- Fix the hot spot, usually a wrong collection, a loop inside a loop, or repeated work such as reading the same file or querying the database in a loop.
- Measure again to confirm the change helped.
For small, precise comparisons, use JMH, the benchmarking tool built by the OpenJDK team. A loop around System.nanoTime() is easily fooled by JIT warm-up and dead-code removal.
In Practice
| Seen in review | Suggest |
|---|---|
result += ... in a loop | StringBuilder or String.join |
Long total = 0L; used for arithmetic | long total = 0; |
list.contains(x) inside a loop | Build a HashSet once, then look up |
| Database query or file read inside a loop | Read once before the loop, or batch the work |
| Hand-tuned bit tricks in ordinary business code | Clear code; let the JIT do its job |
Common Mistakes
Optimizing without measuring. You may speed up code that runs once a day and miss the loop that runs a million times.
Trusting a quick nanoTime test. The first runs include class loading and interpretation; the JIT may also remove code whose result isn't used.
Making code unreadable for a tiny gain. A 2 percent improvement isn't worth confusing every future reader.
Ignoring algorithm choice. No amount of tuning makes a nested loop over two large lists fast. Fix the data structure first.
Practice
- Easy: In
Totals.java, change both loops to run 100 million times. Time the program with each loop commented out in turn, and compare. - Harder: Write a program with a list of 100,000 student IDs and a second list of 10,000 IDs to check. Count how many of the second list appear in the first, once with
List.containsand once after copying the first list into aHashSet. Print both counts (they must match) and both times.
Key Takeaways
Was this tutorial helpful?