Explain StringBuilder's internal capacity and growth strategy, and when pre-sizing capacity is worthwhile. How does this affect performance reasoning in hot paths?
answer
- length (content) vs capacity (array size)
- Growth ~ oldCap*2+2, geometric -> amortized O(N)
- Resizes still allocate+copy = transient garbage
- Pre-size via StringBuilder(cap) / buildString(cap){}
- Measure first; matters in hot serializers/logging
basics
~20 sStringBuilder keeps a backing array bigger than the text it holds. When it fills, it makes a bigger array (usually about double) and copies. If you know roughly how long the result is, you can set the capacity up front to skip those copies.
solid answer
~50 sA `StringBuilder` separates `length` (current char count) from `capacity` (size of the backing `char[]`). When `append` would exceed capacity, the buffer grows — on the JVM typically to `oldCapacity * 2 + 2`, then copies existing characters into the new array. Because growth is geometric, the amortized cost of N appends is O(N) even though individual resizes are O(currentSize). However, the resizes still allocate and copy intermediate arrays, producing transient garbage. In a hot path where you can estimate the output length, constructing with `StringBuilder(capacity)` or `buildString(capacity) { }` pre-allocates the array so no growth/copy happens — fewer allocations, less GC, predictable latency. Don't over-tune blindly: for occasional or small builds the default is fine, and an overly large capacity wastes memory. Measure before pre-sizing; it matters most in tight loops, serializers, and logging fast paths.
code
kotlin · 13 lines// Hot serializer path: avoid resize churn
fun toCsv(rows: List<IntArray>): String {
val approx = rows.sumOf { it.size } * 5 + rows.size
return buildString(approx) {
for (r in rows) {
for ((i, v) in r.withIndex()) {
if (i > 0) append(',')
append(v)
}
appendLine()
}
}
}go deeper
Understands capacity is a hidden buffer that grows, and that StringBuilder avoids per-step copies.
Distinguishes length vs capacity and knows pre-sizing via the constructor/buildString overload avoids some resizes.
Explains geometric growth and amortized O(N), why resizes still cause garbage/jitter, and pre-sizes hot paths deliberately.
Frames pre-sizing as a measured trade-off (throughput, tail latency, heap waste, cache), insists on profiling, and reasons about ensureCapacity/trimToSize and allocation budgets across a system.
## length vs capacity - **`length`**: number of characters currently stored (what `toString()` materializes). - **`capacity`**: the size of the internal backing array; always `>= length`. It is an allocation detail, not part of the logical content. ```kotlin val sb = StringBuilder(16) // capacity hint = 16 sb.append("hi") // length = 2, capacity still 16 ``` ## Growth strategy (amortization) When an `append` needs more room than `capacity`, the builder allocates a larger array and copies existing characters. On the JVM the new capacity is typically `oldCapacity * 2 + 2` (geometric growth). Geometric growth is what makes N appends **amortized O(N)**: - Total characters copied across all resizes is bounded by ~2N (a geometric series), not N². - Any single resize is O(currentSize), but resizes happen O(log N) times. Contrast with naive `String +=` in a loop, which copies on **every** step — O(N²). StringBuilder's win is precisely avoiding per-element copies. ## Why pre-size? Even amortized-linear growth still: 1. **Allocates** several intermediate arrays (garbage for the GC). 2. **Copies** characters multiple times. 3. Adds **jitter**: a resize on iteration K is a latency spike. If you can estimate the final length, pass it up front: ```kotlin val n = items.size val out = buildString(capacity = n * 12) { // estimate avg 12 chars/item for (it in items) append(it.code).append('\n') } ``` Now zero resizes occur (assuming the estimate holds): one allocation, no copy churn, flat latency. ## When it actually matters - **Hot paths**: serializers (JSON/CSV), template rendering, log formatting executed millions of times. - **Large outputs**: building multi-megabyte strings. - **Latency-sensitive code**: avoiding GC pauses and resize spikes. For a one-off small string, pre-sizing is noise — favor readability. An over-large capacity wastes heap. The discipline: **measure first** (allocation profiler / JMH), then pre-size only proven hot builders. ## ensureCapacity and setLength `ensureCapacity(min)` grows the backing array to at least `min` without changing length — another way to pre-allocate on an existing builder. `setLength(n)` changes the logical length (truncate or pad with `\u0000`), independent of capacity. `trimToSize()` can shrink the backing array to the current length if you want to release slack memory.
- If the default growth is already amortized O(N), why pre-size at all?To cut transient allocations and copies and remove resize-induced latency spikes — throughput and tail-latency wins in hot paths, even though Big-O is unchanged.
- What's the risk of always pre-sizing with a huge capacity?Wasted heap and worse cache behavior; if you guess far too big you allocate memory you never use. Pre-size to a realistic estimate, not a worst case.
Capacity is like booking a banquet hall sized for your best guest estimate: too small and you keep moving everyone to a bigger room (resize+copy); too big wastes rent.
saying these in an interview costs you the question
- Confusing length with capacity
- Claiming each append is O(N) (ignoring amortization)
- Asserting pre-sizing changes the Big-O complexity
- Always pre-sizing with arbitrary giant capacities
- Pre-optimizing without ever profiling