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
intin an object likeInteger, 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%.
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.
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 either04Regex, 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.
// 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).
Try it yourself
- 1
Double the input
In "String += in a loop", change
nto 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
Make the hash useless
In "The hidden O(n²)", change
hashCode()toreturn 42;. Predict the set'sequalscount, then run. (It explodes: every entry lands in the same bucket, so HashMap falls back to comparing withequals; it treeifies large buckets but needsComparablekeys to do it well, Topic 9.4.) A badhashCodeis a performance bug. - 3
Profile a real hot spot
On your own JDK, wrap the
+=loop of the first example in a method, setnto 100,000, and run it withjava -XX:StartFlightRecording=duration=30s,filename=s.jfr Main.java. Runjfr print --events jdk.ObjectAllocationSample s.jfr | head -30and find the allocations from string concatenation.
Code & diagrams
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.
Expected output
same text: true
length: 2000
chars copied (+=): 1001000
chars copied (sb): 4272Counting 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.
Expected output
unique (list): 2000, equals calls: 4000000
unique (set): 2000, equals calls: 2000
same order: trueA 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.
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// 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 NBreak 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.
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.
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
What if the export still takes too long for an HTTP request?
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.
LongAdderand 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 useLongAdder. - ▸
Memory layout matters: an
int[]is contiguous and CPU-prefetch-friendly, while aList<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
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
Algorithms and data structures dominate.
list.contains(x)inside a loop is O(n²); aHashSetmakes it O(n).LinkedList.get(i)in a loop is O(n²). Removing from the front of anArrayListshifts every element; useArrayDeque. 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
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 newLongon almost every addition;Map<Integer, Integer>stores 16-byte objects instead of 4-byte ints;Stream<Integer>boxes whereIntStreamwouldn'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) ornew HashMap<>(capacity)with the load factor in mind. - 4
Strings.
s += xin a loop copies the whole string every time: O(n²) characters copied. UseStringBuilder(Topic 6.3) orString.join/Collectors.joining. (A single expression likea + b + cis fine: since Java 9 it compiles to one optimisedinvokedynamiccall.)String.formatis much slower than concatenation in hot paths.Pattern.compileis expensive, so compile a regex once into astatic final Patterninstead of callingString.matchesorreplaceAllrepeatedly. 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
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
synchronizedblock orCollections.synchronizedMapshared by all request threads becomes a queue under load: use concurrent collections, smaller critical sections orLongAdder(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
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.outlocks 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
How do you approach a "the service is slow" report?
Why is s += x in a loop slow, and why is a + b + c not?
What are the costs of autoboxing, and how do you avoid them?
What is the N+1 query problem and how do you fix it?
Practice
Rewrite a method that joins 1 to 10 with commas using += into one using StringJoiner, and print the result.
Given an array of 10 ints with duplicates, count distinct values using a HashSet instead of nested loops, and print the count.
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.