Skip to content
Happy Programming Guide
Start learning
Java

Java Performance Optimization

Where Java applications actually spend their time, how to profile before you guess, and the fixes that matter: collections, strings, streams and garbage collection.

A laptop and a miniature of a motorbike in an office desk

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:

Terminal
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:

JAVA
long start = System.nanoTime();
doTheWork();
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println("Took " + ms + " ms");

Collections: usually the biggest win#

JAVA
// 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:

JAVA
List<Order> orders = new ArrayList<>(expectedCount);
Map<String, Order> index = new HashMap<>(expectedCount * 4 / 3 + 1);

Strings#

JAVA
// 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.

JAVA
// 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.

JAVA
// 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];
JAVA
// 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#

JAVA
// 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:

JAVA
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.

JAVA
// 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#

Terminal
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.
  • final on 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#

The Java collections framework, and which to chooseRead next

Keep reading

Java

Java Not Working in VS Code

No IntelliSense, "cannot find symbol" on everything, or the run button does nothing? VS Code needs a JDK it can find and…

4 min read

Java

What’s New in Java 25 LTS

Java 25 is the current long-term release. Flexible constructors, compact source files, module imports and scoped values are final; here is what…

4 min read

Java

Java Input and Output (I/O)

Reading and writing files in Java: the modern Files and Path API, buffered streams for large files, reading keyboard input, and try-with-resources.

4 min read

Keep going — pick your next guide

The fastest way to improve is to read one guide, then build the thing it describes. Start with the basics, or jump straight to a project.

Ask a question or share what worked

Your email address will not be published. Required fields are marked *