skip to content

You profile a service and find a hot method spending most of its time in String operations while building a large CSV-like payload in a loop. How do you diagnose and fix it, and what trade-offs guide your choice of approach?

level: seniorimportance: should knowfreq 35%

answer

  1. Profile first: String frames + high allocation/GC = concat-in-loop
  2. Quadratic signature: 2x rows ≈ 4x time
  3. Fix: StringBuilder append in loop, toString once
  4. Pre-size rows*avgLen to skip grows/garbage
  5. Huge/streaming output → write to BufferedWriter, don't hold one String
  6. Re-profile to confirm

basics

~20 s

Look for += String building inside the loop — that's almost certainly the culprit, because it copies everything each turn (O(n^2)). Replace it with a StringBuilder (append in the loop, toString once), pre-size it if you can estimate the length, and re-profile to confirm the time and garbage dropped.

solid answer

~50 s

First confirm with the profiler that the cost is in concatenation/allocation, not something else. The classic cause is `result += row` accumulating across the loop: immutable String means each iteration re-copies the whole accumulated payload, giving O(n^2) time plus heavy GC from throwaway Strings. Fix by accumulating into a single StringBuilder, appending each row inside the loop and calling toString() once at the end; pre-size with an estimated capacity (e.g. rows * avgRowLength) to skip buffer grows and reduce garbage. For a delimiter-separated payload, String.join or Collectors.joining (backed by StringJoiner) is often cleaner and just as fast. Avoid StringBuffer unless the builder is shared across threads. For truly huge or streaming output, don't build one giant String at all — stream directly to the response/Writer (e.g. a BufferedWriter) so you never hold the whole thing in memory. Then re-profile to verify the hot spot moved and allocation rate fell.

go deeper

for a junior

Recognizes += in a loop as the likely cause and swaps in a StringBuilder.

for a middle

Confirms with a profiler, explains O(n^2) vs O(n) and GC pressure, pre-sizes the builder, and re-measures.

for a senior

Chooses among StringBuilder, String.join/Collectors.joining, and streaming based on output size and destination; understands amortized growth and capacity estimation.

for a principal

Frames it as profile-driven optimization with memory/throughput trade-offs (in-memory vs streaming), sets team conventions, and weighs OOM/backpressure risk for large payloads in a service.

## Step 1 — Diagnose, don't guess A **profiler** is a tool that measures where a running program spends time and memory. A **hot method** is one that dominates CPU time. When a profiler shows a method spending its time in String operations and you also see a high **allocation rate** / frequent garbage collection, the prime suspect is **String concatenation in a loop**. Why this specific pattern? A Java **String is immutable** — it can't be edited in place. So `result += row` is really `result = result + row`, which allocates a new String and **copies all previously accumulated characters** every iteration. Over n rows that's `1 + 2 + ... + n ≈ n^2/2` character copies — **O(n^2)** (quadratic: work grows with the square of the input) — plus one throwaway String per iteration, which is what spikes GC. Confirm before fixing: check that the hot frames are concatenation/allocation (not, say, I/O or formatting). Cheap sanity test: does runtime grow roughly *quadratically* when you double the number of rows? If 2x rows ≈ 4x time, that's the quadratic signature. ## Step 2 — The standard fix: StringBuilder `StringBuilder` is a **mutable** string accumulator backed by a growable `char[]`. `append` writes into the buffer's free tail (amortized **O(1)**), so the whole build is **O(n)** (linear): ```java StringBuilder sb = new StringBuilder(estimatedSize); // pre-size if you can for (Row row : rows) { sb.append(row.a()).append(',') .append(row.b()).append('\n'); } String csv = sb.toString(); // materialize the String once ``` **Pre-sizing:** the default buffer holds 16 chars and roughly *doubles* (copying) when full. Each grow is amortized away (total still O(n)), but passing an estimate like `rows.size() * avgRowLength` to the constructor skips those grow-and-copy cycles and the garbage they create — a constant-factor win that matters on hot paths. ## Step 3 — Consider cleaner / better-fitting alternatives - **`String.join(",", values)`** or **`values.stream().collect(Collectors.joining(","))`** — when you're joining a known set of fields with a delimiter. These are backed by `StringJoiner`/`StringBuilder` and are clearer than a hand-rolled loop. `StringJoiner` also handles prefix/suffix/delimiter cleanly. - **`StringBuffer`** — the synchronized sibling; **avoid** it unless the builder is genuinely shared and mutated across threads, since the locking is pure overhead in the normal single-threaded case. ## Step 4 — Don't build a giant String at all (streaming) For very large or unbounded output, materializing one huge String wastes memory (and risks heap pressure / OOM). Instead, **stream** directly to the destination: write each row to the HTTP response's `Writer`/`OutputStream`, ideally wrapped in a `BufferedWriter`, so you never hold the whole payload in memory: ```java try (BufferedWriter out = new BufferedWriter(response.getWriter())) { for (Row row : rows) { out.write(row.a()); out.write(','); out.write(row.b()); out.write('\n'); } } ``` This is O(n) time, O(1) extra memory, and starts sending bytes immediately. ## Step 5 — Verify Re-run the profiler. You should see the String frame fall out of the hot list and the allocation/GC rate drop. Measuring before and after is what makes this an engineering fix rather than a guess. ## Trade-off summary - **StringBuilder (pre-sized):** simplest in-memory fix; O(n); good default. - **String.join / Collectors.joining:** most readable for delimiter-joining known values. - **Streaming to a Writer:** best for huge/streaming payloads (constant memory) but you give up having the full String in hand. - **StringBuffer:** only when truly cross-thread shared. Pick by *where the output goes and how big it is*, and always confirm with the profiler.

  • What runtime signature in a quick benchmark suggests the bottleneck is quadratic String concatenation?
    Roughly quadratic scaling: doubling the number of rows quadruples the time (and allocation/GC climbs sharply), versus linear scaling after switching to StringBuilder.
  • When would you stream to a Writer instead of building a StringBuilder?
    When the output is very large or unbounded and you don't need the whole String in memory — streaming to a BufferedWriter keeps memory roughly constant and starts emitting bytes immediately, avoiding heap pressure.

saying these in an interview costs you the question

  • Jumping to a fix without profiling/confirming the hot spot is actually concatenation.
  • Switching to StringBuffer 'for safety' in single-threaded code — adds lock overhead for nothing.
  • Building a multi-megabyte String in memory when you could stream it to the response.
  • Calling toString() inside the loop, reintroducing repeated materialization.
  • Assuming the JIT/compiler will auto-fix the loop — it won't fuse across iterations.

context