Command Palette

Search for a command to run...

PHASE 14Advanced ~34 min· topic 6 of 6

Topic 14.6

Everyday Performance Pitfalls

In one line

Most slow Java code is slow for a handful of ordinary reasons: the wrong algorithm or collection, needless allocation and boxing, string building in loops, exceptions and logging in hot paths, lock contention, and I/O done one byte or one query at a time. Measure first, fix the biggest cost, and measure again.

Think of it like this

A delivery driver who is slow. A faster engine won't help much if he drives back to the depot after every parcel (one query per item), checks every house on the street to find number 42 (linear search), rewrites his whole route list every time he adds one address (string concatenation in a loop), or waits at a single loading door shared by twenty drivers (lock contention). Fix the route before tuning the engine; that's what this topic is about.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Bottleneck
The single slowest part of a system, which limits how fast the whole thing can go.
Profiler
A tool that measures where a program spends its time or allocates its memory.
Big-O
A way to describe how the work grows as the input grows: O(n) doubles when the input doubles, O(n²) quadruples.
Autoboxing
Java automatically wrapping a primitive like int in an object like Integer, which costs an allocation outside the small cache.
Allocation rate
How many bytes of new objects a program creates per second. Higher rates mean more frequent garbage collections.
Lock contention
Many threads waiting for the same lock, so they run one at a time instead of in parallel.
N+1 queries
Running one query to get a list, then one more query per item in it, instead of fetching everything in one or two queries.
Latency percentile (p99)
The time within which 99% of requests finish. It shows the slow tail that averages hide.

Step by step

01Find the bottleneck before changing code

A request that takes 800 ms usually spends it in a few places. Profile it (JFR with settings=profile, or async-profiler for a flame graph) and add timing around remote calls. Fix the widest bar first. A 10x speed-up of code that takes 1% of the time saves 0.9%.

Find the bottleneck before changing codediagram
Rendering diagram…

02Algorithmic traps that look innocent

These lines all look harmless, and all become quadratic as data grows: if (!list.contains(x)) list.add(x) in a loop; for (int i = 0; i < linked.size(); i++) linked.get(i); list.remove(0) in a loop on an ArrayList; s += piece in a loop; str.substring(...) repeated on a growing string. With 100 items nobody notices; with 1,000,000 the request times out.

The second runnable example counts equals calls to make the cost visible: 4,000,000 for the list approach versus 2,000 for the set.

03Boxing in hot loops

A wrapper type in a loop variable is the classic hidden cost. The boxed version below allocates a new Long for almost every addition (only values from -128 to 127 are cached), which the JIT can sometimes but not reliably remove.

Main.javawhole filejava
Long slow = 0L;                       // boxed: each += unboxes, adds, and boxes a new Long
for (int i = 0; i < 10_000_000; i++) slow += i;

long fast = 0L;                       // primitive: stays in a register
for (int i = 0; i < 10_000_000; i++) fast += i;

long viaStream = IntStream.range(0, 10_000_000).asLongStream().sum();   // no boxing either

04Regex, formatting and logging in hot paths

String.matches(regex) compiles the regex every call; String.split has a fast path for single-character delimiters that aren't regex metacharacters, but other patterns compile too. Keep a static final Pattern. Prefer concatenation or StringBuilder over String.format in code run millions of times. With logging, log.debug("cart " + cart) builds the string (and calls cart.toString()) even when debug is off; log.debug("cart {}", cart) doesn't.

Main.javawhole filejava
// slow: compiles the pattern on every call
boolean ok = input.matches("[A-Z]{3}-\\d{4}");

// fast: compile once, reuse (Pattern is immutable and thread-safe; Matcher is not)
private static final Pattern CODE = Pattern.compile("[A-Z]{3}-\\d{4}");
boolean ok2 = CODE.matcher(input).matches();

05Exceptions are for exceptional cases

Most of an exception's cost is fillInStackTrace, which walks the stack. Code like try { Integer.parseInt(s); return true; } catch (NumberFormatException e) { return false; } run on mostly invalid input creates an exception per call. Check first (a loop over characters, or a precompiled pattern) and keep exceptions for real errors. In the rare case where a library must throw often, an exception class can skip the stack trace with the four-argument Throwable constructor (writableStackTrace = false).

06Contention: one lock for everybody

A synchronized method on a shared service object, a Hashtable, Collections.synchronizedMap, or one AtomicLong updated by every thread turns parallel work into a queue. Thread dumps show many threads BLOCKED on the same monitor (Topic 14.5); JFR shows jdk.JavaMonitorEnter events. Fixes: ConcurrentHashMap (and its merge/compute methods), LongAdder for counters, shorter critical sections, or no sharing at all (per-thread state merged at the end).

07I/O: batch, buffer and bound

The N+1 pattern: load 100 orders, then run one query per order for its items, 101 round trips. With 2 ms per round trip that's 200 ms of pure waiting. Fetch items for all orders in one query (WHERE order_id IN (...) or a join), or let the ORM batch-fetch. The same applies to HTTP calls (batch endpoints, concurrent calls with timeouts) and files (buffered streams, Files.readAllLines for small files, streaming for large ones).

I/O: batch, buffer and bounddiagram
Rendering diagram…

Try it yourself

  1. 1

    Double the input

    In "String += in a loop", change n to 2000, then 4000. Predict both numbers before running: the += count should roughly quadruple each time (about n² chars), the builder count roughly double. That pattern, not any single timing, is what tells you an algorithm is quadratic.

  2. 2

    Make the hash useless

    In "The hidden O(n²)", change hashCode() to return 42;. Predict the set's equals count, then run. (It explodes: every entry lands in the same bucket, so HashMap falls back to comparing with equals; it treeifies large buckets but needs Comparable keys to do it well, Topic 9.4.) A bad hashCode is a performance bug.

  3. 3

    Profile a real hot spot

    On your own JDK, wrap the += loop of the first example in a method, set n to 100,000, and run it with java -XX:StartFlightRecording=duration=30s,filename=s.jfr Main.java. Run jfr print --events jdk.ObjectAllocationSample s.jfr | head -30 and find the allocations from string concatenation.

Code & diagrams

String += in a loop vs StringBuilder New tab

Both build the same 2,000-character string. += copies about n²/2 characters; the builder copies about 2n. Double n and the first number quadruples while the second roughly doubles.

Sign in to run this example in your browser.

Expected output

same text:           true
length:              2000
chars copied (+=):   1001000
chars copied (sb):   4272
The hidden O(n²): List.contains in a loop Java 16+ New tab

Counting work instead of timing it gives a deterministic, honest comparison. The set calls equals only when hash codes match, which here means only for real duplicates. LinkedHashSet keeps first-seen order, so the result is the same list.

Sign in to run this example in your browser.

Expected output

unique (list): 2000, equals calls: 4000000
unique (set):  2000, equals calls: 2000
same order:    true
The cost of not presizing collections New tab

A model that follows the JDK's growth rules (Topics 9.2 and 9.4). Growth is amortised O(1), so this matters for very large or very frequently built collections, not for small ones. HashMap.newHashMap(n) (Java 19) does the load-factor arithmetic for you.

Sign in to run this example in your browser.

Expected output

ArrayList, default:  29 grows, 2430972 elements copied
ArrayList, presized: 0 grows, 0 elements copied (new ArrayList<>(n))
HashMap, default:    17 resizes, 1572869 entries rehashed, table 2097152
HashMap, presized:   capacity for 1000000 entries must be >= 1333334
Pitfall fixes at a glance (fragments)java
// 1. Membership tests: Set, not List
Set<String> seen = new HashSet<>(expected);          // O(1) contains

// 2. Primitives in hot paths
long total = 0;                                       // not Long
int sum = orders.stream().mapToInt(Order::qty).sum(); // not map(...).reduce(Integer::sum)

// 3. Precompiled regex and parameterised logging
private static final Pattern SKU = Pattern.compile("[A-Z]{3}-\\d{4}");
log.debug("cart {} has {} items", cartId, items.size());

// 4. Concurrent counters without a single hot lock
LongAdder hits = new LongAdder();                     // hits.increment(); hits.sum();
ConcurrentHashMap<String, LongAdder> perUser = new ConcurrentHashMap<>();
perUser.computeIfAbsent(user, k -> new LongAdder()).increment();

// 5. Buffered I/O and batched queries
try (BufferedWriter out = Files.newBufferedWriter(path)) { /* many writes, few syscalls */ }
// SELECT ... FROM items WHERE order_id IN (:ids)     -- one query instead of N

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Exceptions as control flow

Validate a million mostly invalid strings with try { Integer.parseInt(s); } catch (NumberFormatException e) { ... } and profile it.

terminal
$ jfr print --events jdk.ExecutionSample rec.jfr | grep -m3 'line:' # sample output
── what you'll see ──
java.lang.Throwable.fillInStackTrace(int)
java.lang.Throwable.fillInStackTrace() line: 798
java.lang.Throwable.<init>(String) line: 272

Break #2

A synchronized cache shared by every request

Put a synchronized get method on a cache used by 200 request threads, with a slow computation inside on a miss.

terminal
$ jcmd 4242 Thread.print | grep -c 'waiting to lock <0x0000000711c3a2b8>' # sample output
── what you'll see ──
187

Myth vs fact

Myth

Streams are always slower than loops.

Fact

For most code the difference is small after JIT compilation. Boxing (Stream<Integer> instead of IntStream) and parallel() misuse matter far more than the stream itself.

Myth

String concatenation with + is always bad.

Fact

A single expression is compiled efficiently (invokedynamic since Java 9). The problem is += inside a loop, which copies the growing string again and again.

Myth

Object pooling makes Java faster.

Fact

Allocation and young-generation collection of short-lived objects are very cheap. Pooling ordinary objects adds complexity, contention and old-generation pressure; pool only expensive resources such as connections and threads.

Myth

Performance tuning means JVM flags.

Fact

Flags rarely beat fixing an algorithm, an N+1 query or a hot lock. Tune flags after the code and the I/O pattern are right, and measure every change.

Interview problem

The problem

An export endpoint that times out

GET /reports/export builds a CSV of the last month's orders. It took 2 seconds a year ago and now takes 45 seconds and sometimes times out. Orders grew from 20,000 to 200,000 per month. Find and fix the problem.

You're given

  • Code loads orders, then for each order loads its customer and items via the ORM
  • Rows are appended to a String with +=
  • Duplicate customer emails are removed with a List and contains()
  • The response is written after the whole CSV is built

The interviewer follows up

01

What if the export still takes too long for an HTTP request?

02

Would parallelStream help?

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    False sharing: two frequently written fields of different threads sitting in the same 64-byte cache line make CPU cores fight over that line. LongAdder and the JDK's own concurrent classes pad their hot cells (internally with @jdk.internal.vm.annotation.Contended); in your own code, separate hot per-thread counters or use LongAdder.

  • ▸

    Memory layout matters: an int[] is contiguous and CPU-prefetch-friendly, while a List<Integer> is an array of pointers to objects scattered over the heap. For big numeric data, arrays of primitives can be several times faster purely from cache behaviour.

  • ▸

    Tail latency (p99, p99.9) is often caused by GC pauses, lock convoys, JIT deoptimisation storms or retries, not by average CPU cost. Optimise for the percentile your users feel, and look at JFR's pause and contention events, not only averages.

  • ▸

    Coordinated omission: load-testing tools that wait for each response before sending the next request under-report latency during stalls. Use tools that send at a fixed rate (such as wrk2 or Gatling's open model) when measuring latency.

Remember this

  1. 1

    Measure before you optimise. Intuition about what is slow is usually wrong, and the JIT (Topic 14.4) makes micro-level guesses even less reliable. Use a profiler (JFR or async-profiler, Topic 14.5) to find where time actually goes, JMH for micro-benchmarks, and production metrics (latency percentiles, GC time, allocation rate) to confirm a fix helped. The goal is the biggest cost, which is very often I/O or an algorithm, not a clever micro-trick.

  2. 2

    Algorithms and data structures dominate. list.contains(x) inside a loop is O(n²); a HashSet makes it O(n). LinkedList.get(i) in a loop is O(n²). Removing from the front of an ArrayList shifts every element; use ArrayDeque. Sorting inside a loop, or recomputing the same value on every iteration, hides behind tidy-looking code. The DSA course (/dsa) and Topic 9.13 (choosing collections) are performance topics as much as correctness ones.

  3. 3

    Allocation and boxing. Allocation is cheap but not free: it raises the allocation rate and so the GC frequency (Topic 14.3). The common waste is autoboxing: Long total = 0L; total += x; creates a new Long on almost every addition; Map<Integer, Integer> stores 16-byte objects instead of 4-byte ints; Stream<Integer> boxes where IntStream wouldn't. Use primitives in hot loops, IntStream/LongStream, and arrays or primitive-collection libraries for big numeric data. Presize collections when you know the size: new ArrayList<>(n), HashMap.newHashMap(n) (Java 19) or new HashMap<>(capacity) with the load factor in mind.

  4. 4

    Strings. s += x in a loop copies the whole string every time: O(n²) characters copied. Use StringBuilder (Topic 6.3) or String.join / Collectors.joining. (A single expression like a + b + c is fine: since Java 9 it compiles to one optimised invokedynamic call.) String.format is much slower than concatenation in hot paths. Pattern.compile is expensive, so compile a regex once into a static final Pattern instead of calling String.matches or replaceAll repeatedly. Building a log message that is then discarded wastes work: use parameterised logging (log.debug("order {}", id)) or a level check (Topic 15.6).

  5. 5

    Exceptions, locks and threads. Creating an exception fills in a stack trace, which is costly; never use exceptions for normal control flow (like parsing in a loop to test "is this a number?"). A single synchronized block or Collections.synchronizedMap shared by all request threads becomes a queue under load: use concurrent collections, smaller critical sections or LongAdder (Topic 13.8). parallelStream() on small inputs or I/O-bound work is slower, not faster (Topic 10.8). Creating a thread per task is expensive; use pools or virtual threads (Topics 13.6 and 13.10).

  6. 6

    I/O is usually the real bottleneck. Unbuffered reads and writes (one system call per byte, Topic 12.2), printing in a hot loop (System.out locks and flushes), the N+1 query pattern (one database query per item instead of one query for all), chatty remote calls without batching, missing connection pools, and missing timeouts all cost far more than any CPU-level detail. Batch, buffer, cache with bounds, and do independent remote calls concurrently.

Explain it without notes

01

How do you approach a "the service is slow" report?

02

Why is s += x in a loop slow, and why is a + b + c not?

03

What are the costs of autoboxing, and how do you avoid them?

04

What is the N+1 query problem and how do you fix it?

Practice

01

Rewrite a method that joins 1 to 10 with commas using += into one using StringJoiner, and print the result.

02

Given an array of 10 ints with duplicates, count distinct values using a HashSet instead of nested loops, and print the count.

03

Write a static final Pattern that matches product codes like ABC-1234, test it on three inputs, and print the results.

Trade-offs

  • ↔

    Optimised code is often less readable; optimise only measured hot spots and keep the clear version elsewhere.

  • ↔

    Caching speeds reads but costs memory and brings staleness and invalidation problems; always bound caches.

  • ↔

    Presizing and primitive collections save allocation and memory but add assumptions (expected sizes, extra libraries).

  • ↔

    Batching reduces round trips but increases the latency of the first item and the size of each failure.

Done when you can

  • Done when you measure with a profiler or JMH before and after every optimisation.

  • Done when you can spot quadratic patterns: contains in a loop, += on strings, remove(0) on ArrayList.

  • Done when you avoid boxing, regex recompilation and string-building logs in hot paths.

  • Done when you can recognise lock contention in a thread dump and fix it with concurrent structures.

  • Done when you can explain and fix N+1 queries and unbuffered I/O.