skip to content

How would you decide between +, StringBuilder, and String.join/StringJoiner/Collectors.joining for a given concatenation task?

level: seniorimportance: should knowfreq 40%

answer

  1. Few fixed parts, one expression -> +
  2. Loop / unknown count / conditional -> StringBuilder (pre-size)
  3. Join collection with delimiter -> String.join
  4. Need prefix/suffix or imperative add -> StringJoiner
  5. Inside a stream -> Collectors.joining; shared threads -> StringBuffer (rare)

basics

~10 s

Use + for a few fixed pieces in one statement. Use StringBuilder when building up text in a loop. Use String.join or Collectors.joining when gluing a collection together with a separator.

solid answer

~50 s

The decision is about readability and complexity, not dogma. For a small, fixed set of parts in a single expression, + is the clearest choice and the compiler already compiles it efficiently. For accumulation across a loop or an unknown number of appends, use StringBuilder (pre-sized if you can estimate length) to keep it linear instead of quadratic. When the task is specifically joining the elements of a collection or stream with a delimiter, prefer the purpose-built tools: String.join(", ", items) for an Iterable, StringJoiner when you also need a prefix/suffix, and Collectors.joining(", ", "[", "]") in a stream pipeline. These read better and use a builder internally. Reach for StringBuffer only when a single buffer is shared across threads, which is rare. The guiding principle: pick the most readable option whose complexity is acceptable for the input size.

go deeper

for a junior

Knows the three rough buckets (+ for few, StringBuilder for loops, join for collections) even if fuzzy on edge tools.

for a middle

Picks the right tool per task and knows String.join/Collectors.joining exist for delimited joins.

for a senior

Justifies choices on readability plus complexity, knows StringJoiner prefix/suffix and pre-sizing, and the StringBuffer caveat.

for a principal

Codifies team conventions and reviews for over/under-engineering, balancing clarity, allocation cost, and maintainability across the codebase.

## The mental model All these tools ultimately build a `String`, but they differ in **readability** and **performance characteristics**. Choosing well means matching the tool to the shape of the task. ## Option 1: the + operator Best for a **small, fixed number of parts in one expression**: ```java String msg = "User " + name + " has " + count + " items"; ``` It is the most readable form, and (Java 9+) the compiler compiles the whole expression into one optimized `invokedynamic` concatenation. Do **not** over-engineer this into a builder. The only place `+` is wrong is **accumulation across iterations** (see the loop pitfall): there it becomes quadratic. ## Option 2: StringBuilder Best when you **append repeatedly**, especially in a loop, or build conditionally: ```java StringBuilder sb = new StringBuilder(estimatedLength); for (Item it : items) { if (it.visible()) sb.append(it.label()).append('\n'); } String out = sb.toString(); ``` `StringBuilder` is a mutable buffer that grows geometrically, giving amortized **linear** time. Pre-size with a capacity when you can estimate the final length to skip resize copies. It also shines when the building logic is **branchy** (conditional appends) where a single `+` expression cannot express the flow. ## Option 3: String.join / StringJoiner / Collectors.joining Best when the task is literally **"join these elements with a separator"**: - `String.join(", ", list)` — simplest for an `Iterable<CharSequence>` or varargs. - `StringJoiner(", ", "[", "]")` — when you also want a **prefix and suffix**, or need to add elements imperatively. - `list.stream().map(...).collect(Collectors.joining(", ", "[", "]"))` — when you are already in a **stream pipeline** and transform elements first. These express intent directly, handle the "no trailing separator" edge case for you, and use a builder under the hood — so they are both readable and efficient. ## StringBuffer — the rare case `StringBuffer` has the same API as `StringBuilder` but is **synchronized**. The locking costs throughput and is almost never needed because builders are usually thread-local. Use it only when one buffer is genuinely shared and mutated across threads — and consider whether that sharing is itself a design problem. ## A quick decision guide - Few fixed parts, one statement → `+`. - Loop / unknown count / conditional building → `StringBuilder` (pre-size if possible). - Join a collection with a delimiter → `String.join`; add prefix/suffix → `StringJoiner`; inside a stream → `Collectors.joining`. - Shared across threads → `StringBuffer` (rare). ## The overarching principle Optimize for **readability first**, then ensure the complexity is acceptable for the expected input size. Micro-optimizing a small `+` into a builder hurts clarity for no gain; using `+` in a hot, large loop hurts performance for no readability gain. Match the tool to the task.

  • You need to join a List<String> with commas and wrap it in brackets like [a, b, c]. What is the cleanest tool?
    StringJoiner(", ", "[", "]") (or Collectors.joining(", ", "[", "]") in a stream). Both add the delimiter between elements and the prefix/suffix once, avoiding manual trailing-separator handling.
  • Is it worth replacing a simple two-part + expression with a StringBuilder for performance?
    No. A single small + expression is already compiled efficiently and is more readable; switching to a builder adds noise without measurable benefit. Reserve StringBuilder for loops/conditional accumulation.

saying these in an interview costs you the question

  • Always using StringBuilder even for two fixed parts (hurts readability)
  • Hand-rolling a separator loop with a trailing-comma bug instead of String.join
  • Defaulting to StringBuffer without any threading need
  • Treating the choice as performance-only and ignoring readability

context