StringBuilder
Build text in loops with a mutable StringBuilder, and know when plain + or a joiner is the clearer choice.
StringBuilder sb = new StringBuilder();
sb.append("id=").append(42).append('\n');
sb.setLength(sb.length() - 1); // drop last char
String s = sb.toString();Because a String cannot change, result = result + piece inside a loop creates a brand new String each time and copies everything built so far into it. With a few items nobody notices. With thousands of rows in a CSV export the copying grows with the square of the output size and shows up as CPU time and garbage collection.
StringBuilder is a mutable buffer of characters. append adds to the end of the same buffer, growing it when needed, and toString() produces the final String once.
StringBuilder csv = new StringBuilder();
for (Order o : orders) {
csv.append(o.id()).append(',').append(o.total()).append('\n');
}
String body = csv.toString();When plain + is fine
A single expression such as "Order " + id + " shipped to " + city is already compiled into efficient code (since Java 9 the compiler uses invokedynamic string concatenation), so rewriting it with a builder only makes it harder to read. The builder pays off when text is assembled across a loop or across several statements.
Useful methods
append(x)is overloaded for every type and returns the builder, so calls chain.insert(index, x),deleteCharAt(index)andreverse()edit in place.setLength(n)truncates, which is the cheap way to drop a trailing separator, or to reset the builder withsetLength(0).new StringBuilder(capacity)preallocates when you know the rough size, avoiding repeated growth.
Mistakes people make
equals on a StringBuilder is not overridden: it compares identity, so two builders holding the same text are not equal. Compare with compareTo (Java 11), String.contentEquals, or the toString() values. A builder is also not thread-safe. Keep it as a local variable inside one method call; do not share one in a field of a singleton service that handles concurrent requests. StringBuffer is the older synchronised version and is rarely the right fix.
When the job is "items separated by commas", String.join, StringJoiner or Collectors.joining say it more clearly and handle the separator for you.
Not for SQL values
Building a SQL statement with a builder is fine for structure, such as the right number of ? placeholders for an IN list. The values themselves must still go through PreparedStatement parameters, never be appended into the text.
Example
When to use it
- Build a CSV export of a few thousand order rows into one response body without quadratic copying.
- Generate the right number of ? placeholders for a SQL IN list whose values are bound separately.
- Assemble a multi-line plain-text email body from optional sections in a notification service.
More examples
Dropping a trailing separator and editing in place
setLength shortens the buffer without copying, which is the usual way to undo a separator added on the last pass. The isEmpty check stops it throwing when the list was empty.
void main() {
List<String> tags = List.of("java", "http", "json");
StringBuilder out = new StringBuilder();
for (String tag : tags) {
out.append(tag).append(',');
}
if (!out.isEmpty()) {
out.setLength(out.length() - 1); // remove the last comma
}
IO.println(out);
out.insert(0, "tags=");
IO.println(out);
IO.println(new StringBuilder("stressed").reverse());
}
// Output:
// java,http,json
// tags=java,http,json
// dessertsequals compares identity, not text
StringBuilder inherits equals from Object, so only the same instance is equal to itself. This also means a builder makes a poor key in a Map or element in a Set.
void main() {
StringBuilder a = new StringBuilder("abc");
StringBuilder b = new StringBuilder("abc");
IO.println(a.equals(b));
IO.println(a.compareTo(b) == 0);
IO.println(a.toString().equals(b.toString()));
IO.println("abc".contentEquals(a));
}
// Output:
// false
// true
// true
// trueStringJoiner handles separators for you
StringJoiner adds the separator only between items and wraps the result in a prefix and suffix, so there is nothing to trim afterwards. setEmptyValue controls what an empty result looks like.
void main() {
List<Integer> ids = List.of(7, 12, 30);
StringJoiner json = new StringJoiner(",", "[", "]");
for (int id : ids) {
json.add(String.valueOf(id));
}
IO.println(json);
StringJoiner empty = new StringJoiner(",", "[", "]");
IO.println(empty);
empty.setEmptyValue("none");
IO.println(empty);
}
// Output:
// [7,12,30]
// []
// none
Discussion