Java is fast. When a Java application is slow, the cause is almost never the language — it is a database query in a loop, the wrong collection type, or objects being created far more often than necessary. This guide covers how to find the real bottleneck and the changes that reliably pay, in the order worth trying them.
Measure before changing anything#
The JDK ships with a profiler. Turn it on and look at real data:
java -XX:StartFlightRecording=duration=60s,filename=app.jfr -jar app.jar
Open the resulting file with JDK Mission Control and read the hot methods, the allocation profile and the garbage collection pauses. Almost everyone is surprised by what they find, which is exactly the point.
For a quick timing check in code:
long start = System.nanoTime();
doTheWork();
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println("Took " + ms + " ms");
Collections: usually the biggest win#
// Membership test on a list: checks every element
List<String> names = new ArrayList<>(oneMillionNames);
if (names.contains(target)) { } // walks the list
// Same test on a set: one hash lookup
Set<String> nameSet = new HashSet<>(oneMillionNames);
if (nameSet.contains(target)) { } // effectively instant
A contains call inside a loop over a list is a quadratic algorithm hiding in plain sight. Converting the list to a HashSet once, before the loop, is frequently the single largest improvement available.
| You need | Use |
|---|---|
| Index access, iteration | ArrayList |
| Frequent insert or remove at the front | ArrayDeque |
| Membership tests | HashSet |
| Key lookup | HashMap |
| Sorted keys | TreeMap |
| Concurrent access | ConcurrentHashMap |
Size collections up front when you know roughly how many entries there will be. A growing ArrayList reallocates and copies repeatedly:
List<Order> orders = new ArrayList<>(expectedCount);
Map<String, Order> index = new HashMap<>(expectedCount * 4 / 3 + 1);
Strings#
// Creates a new String on every iteration
String result = "";
for (String part : parts) {
result += part; // quadratic in total length
}
// One buffer
StringBuilder builder = new StringBuilder();
for (String part : parts) {
builder.append(part);
}
String result = builder.toString();
// Or, for a simple join
String result = String.join(", ", parts);
Concatenation outside a loop is fine — the compiler turns it into a single efficient call. It is only the loop that hurts, and it hurts badly on long inputs.
// Compile a regex once, not per call
private static final Pattern DIGITS = Pattern.compile("\\d+");
// Rather than
if (input.matches("\\d+")) { } // recompiles the pattern every time
Object allocation#
Java allocation is cheap; allocating a million short-lived objects in a loop is not, because it forces frequent garbage collection.
// Boxing in a hot loop
Map<Integer, Integer> counts = new HashMap<>(); // every key and value is an object
// Primitive arrays where the keys are dense
int[] counts = new int[SIZE];
// Reuse rather than recreate
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd");
Watch the allocation profile in your recording. If one line is producing gigabytes of garbage, that is where to look — not at the method showing the most CPU time.
Streams versus loops#
// Stream
long count = orders.stream()
.filter(o -> o.getStatus() == PAID)
.count();
// Loop
long count = 0;
for (Order o : orders) {
if (o.getStatus() == PAID) count++;
}
The loop is somewhat faster, because streams add lambda dispatch and iterator machinery. On a collection of a few thousand items the difference is invisible; in a method called millions of times it is measurable. Write whichever is clearer, and only convert after profiling says so.
Parallel streams deserve more caution than they usually get:
list.parallelStream().map(this::expensive).toList();
They help only for genuinely CPU-bound work on large collections. For small collections or I/O-bound work the coordination overhead makes them slower, and they use the shared common pool, so one slow parallel stream can starve everything else in the application.
Database access#
In most business applications this dominates everything else on this page.
// N+1: one query, then one per row
List<Order> orders = orderRepo.findAll();
for (Order order : orders) {
order.getCustomer().getName(); // lazy load fires a query each time
}
Fetch the association in the original query instead, with a join fetch or an entity graph. Turn on SQL logging in development and count the queries on your slowest page — if the number is in the hundreds, nothing else here matters until that is fixed.
Also: batch inserts rather than looping over single ones, and use a connection pool. HikariCP is the default in Spring Boot and needs no tuning for most workloads.
Garbage collection#
java -Xms2g -Xmx2g -XX:+UseG1GC -jar app.jar
Two settings are worth touching, and most others are not:
- Heap size. Setting the initial and maximum heap to the same value avoids the cost of the heap growing at run time.
- Collector. G1 is the default and suits most applications. ZGC or Shenandoah are worth trying if you have large heaps and pause times matter more than throughput.
Things that do not matter#
- Declaring variables outside loops. The JIT handles this.
finalon locals for speed. It aids readability, not performance.- Manual loop unrolling. The JIT already does it, better.
- Calling the garbage collector by hand. It is a suggestion, and usually a harmful one.
- Micro-tuning a method called ten times. Optimise what runs ten million times.
Run a current JDK#
Each release brings real improvements for free — better collectors, better JIT, better string handling. Moving from an old long-term-support release to a current one often gives a measurable gain with no code change at all. Virtual threads, added recently, change the calculus for I/O-heavy servers considerably.
Questions people ask#
Is Java slower than C++?
Somewhat, and much less than people assume. The JIT compiles hot code to machine instructions and can optimise using information a static compiler does not have. For typical server work the difference is dominated by I/O.
Should I use primitives instead of wrapper types?
In hot numeric code, yes — boxing allocates. In ordinary application code the clarity of a wrapper type in a collection is usually worth more than the difference.
Does the JIT need warm-up?
Yes. Code is interpreted until it has run enough times to be worth compiling, so the first requests to a server are slower. This is also why naive benchmarks are misleading.
How much heap should I give it?
Enough that collections are not constant, and not so much that a single collection pauses for a long time. Start with what the application needs plus headroom, then watch the collection logs.
Where to go next#
- Java collections framework — choosing correctly in the first place.
- Java testing — so optimisation does not break behaviour.
- Concurrency and multithreading — when parallelism actually helps.