Definition
- StringJoiner –
new StringJoiner(", ", "[", "]")builds a delimited string with prefix and suffix. Useadd()to append elements. - String.join –
String.join(", ", list)is a static convenience for the common no-prefix/suffix case. - Collectors.joining –
Collectors.joining(", ", "[", "]")is the stream collector version. - Relationship – all three use StringJoiner internally and produce identical results.
- setEmptyValue – overrides the default empty output (prefix+suffix) with a custom message.
StringJoiner is the bank's form printer: you feed account IDs one by one and it produces a formatted list. String.join is a quick-print button for simple lists. Collectors.joining is the same printer connected to the stream conveyor belt.
Understanding the Concepts
What is StringJoiner?
import java.util.StringJoiner;
StringJoiner sj = new StringJoiner(", ", "[", "]");
sj.add("SAV2001");
sj.add("CUR3001");
sj.add("FD4001");
System.out.println(sj.toString()); // [SAV2001, CUR3001, FD4001]
// Empty StringJoiner with setEmptyValue:
StringJoiner empty = new StringJoiner(", ", "[", "]");
empty.setEmptyValue("[no accounts]");
System.out.println(empty.toString()); // [no accounts]
How do the three options relate?
import java.util.List;
import java.util.stream.Collectors;
List<String> accounts = List.of("SAV2001", "CUR3001", "FD4001");
// Option 1: StringJoiner (imperative)
StringJoiner sj = new StringJoiner(", ", "[", "]");
for (String id : accounts) sj.add(id);
String result1 = sj.toString();
// Option 2: String.join (static helper, no prefix/suffix)
String result2 = String.join(", ", accounts);
// Option 3: Collectors.joining (stream)
String result3 = accounts.stream()
.collect(Collectors.joining(", ", "[", "]"));
// All produce the same formatted string.
What is the comparison table?
| Context | Use | Prefix/Suffix | Empty fallback |
|---|---|:---:|:---:|
| Imperative loop with conditions | StringJoiner | Yes | setEmptyValue |
| Simple delimiter join | String.join | No | N/A |
| Stream pipeline | Collectors.joining | Yes | N/A |
How does StringJoiner handle conditional adds?
package com.bank.service;
import java.util.List;
import java.util.StringJoiner;
public class ConditionalJoin {
record Account(String id, boolean active) {}
public static void main(String[] args) {
List<Account> accounts = List.of(
new Account("SAV2001", true),
new Account("SAV2002", false),
new Account("CUR3001", true));
// StringJoiner with condition (imperative)
StringJoiner sj = new StringJoiner(", ", "Active: [", "]");
for (Account a : accounts) {
if (a.active()) sj.add(a.id());
}
System.out.println(sj.toString()); // Active: [SAV2001, CUR3001]
// Stream equivalent
String result = accounts.stream()
.filter(Account::active)
.map(Account::id)
.collect(java.util.stream.Collectors.joining(", ", "Active: [", "]"));
System.out.println(result); // Active: [SAV2001, CUR3001]
}
}
How does merge work for parallel streams?
StringJoiner.merge(other) combines two joiners, which is how Collectors.joining handles parallel streams.
StringJoiner sj1 = new StringJoiner(", ");
sj1.add("SAV2001").add("SAV2002");
StringJoiner sj2 = new StringJoiner(", ");
sj2.add("CUR3001").add("CUR3002");
sj1.merge(sj2);
System.out.println(sj1.toString()); // SAV2001, SAV2002, CUR3001, CUR3002
How does setEmptyValue work?
StringJoiner sj = new StringJoiner(", ", "[", "]");
sj.setEmptyValue("[no results]");
// Without any adds:
System.out.println(sj.toString()); // [no results]
// With adds:
sj.add("SAV2001");
System.out.println(sj.toString()); // [SAV2001]
Without setEmptyValue, an empty joiner returns just prefix+suffix: [].
What are common mistakes?
// WRONG: using + concatenation in a loop
String result = "";
for (String id : accounts) {
result += id + ", "; // O(n^2) string copying!
}
// Trailing comma, poor performance
// CORRECT: StringJoiner or String.join
String result = String.join(", ", accounts); // O(n), no trailing comma
What should a senior engineer know?
- StringJoiner.merge(other) combines two joiners, which is how Collectors.joining handles parallel streams.
- String.join uses StringJoiner internally: the implementation is
new StringJoiner(delimiter).add(elements)...toString(). - For conditional adds (only join non-empty strings), StringJoiner with an if-check is cleaner than stream+filter+joining.
- StringJoiner is NOT thread-safe. Use Collectors.joining for parallel streams, which handles merging internally.
- setEmptyValue replaces the default prefix+suffix output for empty joiners with a custom message.
Visual Overview
Three Layers:
┌───────────────────────────────────┐
│ Collectors.joining(", ","[","]")│ ◀── Stream pipeline
│ (stream collector) │
├───────────────────────────────────┤
│ String.join(", ", list) │ ◀── Static helper
│ (convenience method) │
├───────────────────────────────────┤
│ StringJoiner(", ", "[", "]") │ ◀── Underlying builder
│ .add("SAV2001").add("CUR3001") │
└───────────────────────────────────┘
All use StringJoiner internally.
Key Takeaway
StringJoiner is the underlying builder for delimited string concatenation. String.join is a static shortcut for simple cases. Collectors.joining is the stream collector. All three produce identical results because they all use StringJoiner internally.
Choose based on context: imperative loops use StringJoiner directly (especially when you need conditional adds or setEmptyValue), static helper calls use String.join, and stream pipelines use Collectors.joining. StringJoiner.merge combines two joiners, which is how Collectors.joining handles parallel stream processing.
Remember one sentence: StringJoiner is the builder underneath both String.join and Collectors.joining; choose the layer that matches your context.
---