Java String Performance: Concatenation, Interning & Best Practices
Strings are the most commonly allocated objects in most Java applications, often consuming 25–40% of the heap. Because String is immutable, every modification creates a new object, making careless string handling a leading source of GC pressure and latency spikes. This guide covers the full landscape of string performance: concatenation strategies, StringBuilder sizing, intern() and the string pool, compact strings, G1 string deduplication, text blocks, common anti-patterns, and JMH benchmark data to back up every recommendation.
Contents
String Immutability Recap
Every StringHashMapreplace()toUpperCase()trim()Stringbyte[]
The performance implications are significant. Each new Stringbyte[]
// Immutability means every "modification" allocates a new object
String s = "Hello";
s.toUpperCase(); // returns NEW String "HELLO"; s is still "Hello"
s = s.toUpperCase(); // now s points to the new object; old "Hello" becomes garbage
// Safe as HashMap key — hash is cached, contents never change
Map<String, Integer> map = new HashMap<>();
map.put(s, 1); // hash computed once, then cached inside the String object
// Safe across threads — no synchronization needed for reads
// Multiple threads can share the same String reference without risk
String Concatenation
The ++StringBuildernew StringBuilder().append(a).append(b).toString()invokedynamicbyte[]
Single-expression concatenation
For a single expression like String msg = a + " " + binvokedynamicStringConcatFactory+StringBuilder
// Single-expression concatenation — optimized by JVM, leave as-is
String greeting = "Hello, " + name + "! You have " + count + " messages.";
// Java 9+ compiles this to:
// invokedynamic makeConcatWithConstants("Hello, \u0001! You have \u0001 messages.")
// The JVM pre-computes the exact buffer size and fills it in one pass
Loop concatenation: the classic trap
The optimizer cannot merge +StringnStringn
// BAD: O(n^2) — each iteration copies the entire accumulated string
String result = "";
for (int i = 0; i < 100_000; i++) {
result += i + ","; // allocates new String every iteration
}
// GOOD: O(n) amortized — single mutable buffer
StringBuilder sb = new StringBuilder(600_000); // pre-size if you can estimate
for (int i = 0; i < 100_000; i++) {
sb.append(i).append(',');
}
String result = sb.toString();
++=invokedynamicStringBuilderString.join()String.join() and Collectors.joining()
For joining a collection of strings with a delimiter, String.join()Collectors.joining()String.join()StringJoinerStringBuilder
// String.join — clean API for delimiter-separated strings
List<String> items = List.of("apple", "banana", "cherry");
String csv = String.join(", ", items); // "apple, banana, cherry"
// Collectors.joining — for stream pipelines
String csv2 = items.stream()
.collect(Collectors.joining(", ", "[", "]")); // "[apple, banana, cherry]"
// StringJoiner — for incremental building with delimiter
StringJoiner sj = new StringJoiner(", ", "{", "}");
sj.add("one").add("two").add("three");
String result = sj.toString(); // "{one, two, three}"
StringBuilder vs StringBuffer
StringBuilderStringBufferappend()insert()delete()replace()reverse()StringBuffer
Capacity and growth
Both classes start with a default capacity of 16 characters. When the buffer is full, it grows to (oldCapacity + 1) * 2byte[]
// Default capacity: 16
StringBuilder sb = new StringBuilder(); // capacity = 16
StringBuilder sb2 = new StringBuilder(512); // capacity = 512 — avoids early regrowth
StringBuilder sb3 = new StringBuilder("Hi"); // capacity = 16 + 2 = 18
// Growth pattern: (current + 1) * 2
// 16 -> 34 -> 70 -> 142 -> 286 -> 574 -> ...
// Pre-sizing for known workloads
int lineCount = 10_000;
int avgLineLength = 80;
StringBuilder log = new StringBuilder(lineCount * avgLineLength);
Performance comparison
| Feature | StringBuilder | StringBuffer |
|---|---|---|
| Thread safety | Not synchronized | All methods synchronized |
| Single-thread speed | Faster (no lock overhead) | Slower (~15-30% overhead) |
| Introduced in | Java 5 | Java 1.0 |
| Use when | String building in local scope (vast majority) | Genuinely shared mutable buffer across threads |
StringBuilderStringString.format vs MessageFormat
String.format()MessageFormatStringBuilder
// String.format — convenient but slow in hot paths
// Parses the format pattern on EVERY call
String msg = String.format("User %s has %d items", name, count);
// MessageFormat — can pre-compile the pattern
MessageFormat fmt = new MessageFormat("User {0} has {1,number,integer} items");
String msg2 = fmt.format(new Object[]{name, count});
// StringBuilder — fastest for structured string assembly
String msg3 = new StringBuilder(64)
.append("User ").append(name)
.append(" has ").append(count).append(" items")
.toString();
// Java 21+ String templates (preview) — compiler-optimized formatting
// String msg4 = STR."User \{name} has \{count} items";
Relative performance (approximate)
| Approach | Relative Speed | Notes |
|---|---|---|
+ | 1x (baseline) | JVM-optimized via invokedynamic |
StringBuilder | ~1x | Equivalent to + for simple cases |
String.format() | ~5-10x slower | Parses pattern every call |
MessageFormat | ~3-6x slower | Better than format(), still heavy |
String.format()log.info("User {} has {} items", name, count)String Interning
The JVM maintains a String PoolStringintern()==equals()
How the string pool works
// Literals are automatically interned
String a = "hello"; // goes into the string pool
String b = "hello"; // reuses the SAME object from the pool
System.out.println(a == b); // true — same reference
// new String() creates a separate heap object
String c = new String("hello");
System.out.println(a == c); // false — different object
System.out.println(a.equals(c)); // true — same content
// intern() returns the pooled instance
String d = c.intern();
System.out.println(a == d); // true — d points to the pooled "hello"
// Practical use: deduplicating parsed data
Map<String, List<Record>> grouped = new HashMap<>();
for (Record r : records) {
String country = r.getCountry().intern(); // thousands of records, few unique countries
grouped.computeIfAbsent(country, k -> new ArrayList<>()).add(r);
}
Compact Strings (Java 9+)
Before Java 9, every Stringchar[]byte[]coder
// Java 9+ compact strings — automatic, no code changes needed
String latin1 = "Hello World"; // stored as byte[] with 1 byte/char (LATIN1)
String utf16 = "Hello \u4e16\u754c"; // contains non-Latin1 chars, stored as 2 bytes/char (UTF16)
// Compact strings are enabled by default
// To disable (rare): -XX:-CompactStrings
// Memory savings example:
// Java 8: "Hello" = char[5] = 10 bytes payload
// Java 9+: "Hello" = byte[5] = 5 bytes payload (Latin-1)
// ~50% savings on the character data for Latin-1 strings
String table sizing
The string pool is implemented as a fixed-size hash table in native memory. If you intern a large number of strings, collisions increase and lookup performance degrades. You can tune the bucket count with -XX:StringTableSize=N
// JVM flags for string pool tuning
// -XX:StringTableSize=120011 (increase bucket count for heavy interning)
// -XX:+PrintStringTableStatistics (print pool stats at shutdown)
// Diagnostic: print string table statistics at JVM exit
// java -XX:+PrintStringTableStatistics MyApp
// Output shows: Number of buckets, entries, average chain length, max chain length
String Deduplication
Java 8u20 introduced G1 String Deduplication (-XX:+UseStringDeduplicationStringbyte[]intern()==byte[]String
// Enable G1 string deduplication
// java -XX:+UseG1GC -XX:+UseStringDeduplication MyApp
// How it works:
// 1. G1 GC identifies String objects during young-gen collection
// 2. It hashes their byte[] content
// 3. If two Strings have identical byte[] content, they are made
// to share the same byte[] reference
// 4. The duplicate byte[] becomes garbage and is reclaimed
// Key points:
// - Only works with G1 GC (default GC since Java 9)
// - Deduplicates the backing byte[], not the String objects themselves
// - a == b is still false (different String objects)
// - a.equals(b) is true (same content)
// - No code changes required — purely a GC optimization
// Control the minimum age of strings considered for dedup:
// -XX:StringDeduplicationAgeThreshold=3 (default: 3 GC cycles)
When to use string deduplication
- Applications that parse large CSV/JSON files with many repeated field values
- Web servers storing session attributes with repeated keys
- Data processing pipelines with high string redundancy
- When you cannot easily modify code to call
but want heap savingsintern()
Deduplication vs Interning
| Aspect | intern() | G1 String Dedup |
|---|---|---|
| Level | Application code | GC / JVM |
| What is shared | Entire String object (same reference) | Only the backing byte[] |
| Identity (==) | Yes: same object | No: separate String objects |
| Code changes | Must call .intern() | None: JVM flag only |
| Overhead | Hash table lookup on intern() | GC pause slightly longer |
| Best for | Small, known set of repeated values | Large-scale dedup without code changes |
Text Blocks Performance
Text blocks (Java 13 preview, Java 15 standard) use the """
// Text block — NO runtime overhead
String json = """
{
"name": "%s",
"age": %d
}
""";
// Compiles to the EXACT same constant as:
String json2 = "{\n \"name\": \"%s\",\n \"age\": %d\n}\n";
// Text blocks are interned just like regular string literals
String a = """
hello""";
String b = "hello";
System.out.println(a == b); // true — same pooled constant
// formatted() convenience method (Java 15+)
String result = """
{
"name": "%s",
"age": %d
}
""".formatted(name, age);
// Note: formatted() has the same overhead as String.format() at runtime
.formatted().replace()Common Anti-Patterns
String misuse accounts for a surprising share of avoidable allocations in production Java code. Below are the most common anti-patterns and their fixes.
1. Concatenation in loops
// ANTI-PATTERN: O(n^2) concatenation
String sql = "";
for (String col : columns) {
sql += col + ", ";
}
// FIX: use String.join or StringBuilder
String sql = String.join(", ", columns);
// Or with StringBuilder for conditional logic
StringBuilder sb = new StringBuilder(columns.size() * 20);
for (String col : columns) {
if (sb.length() > 0) sb.append(", ");
sb.append(col);
}
String sql = sb.toString();
2. Unnecessary toString() calls
// ANTI-PATTERN: redundant toString()
String s = someString.toString(); // already a String
System.out.println("Value: " + obj.toString()); // + calls toString() automatically
log.info("Count: " + Integer.valueOf(n).toString()); // boxing + redundant toString()
// FIX: let the JVM handle conversions
String s = someString; // no-op, it's already a String
System.out.println("Value: " + obj); // + invokes toString() implicitly
log.info("Count: {}", n); // SLF4J parameterized — no concatenation
3. Regex compilation in loops
// ANTI-PATTERN: recompiles the regex pattern on every iteration
for (String line : lines) {
String[] parts = line.split("\\|"); // compiles Pattern each time
if (line.matches("\\d{4}-\\d{2}-\\d{2}")) { // compiles Pattern each time
// ...
}
}
// FIX: pre-compile the Pattern
private static final Pattern PIPE = Pattern.compile("\\|");
private static final Pattern DATE = Pattern.compile("\\d{4}-\\d{2}-\\d{2}");
for (String line : lines) {
String[] parts = PIPE.split(line);
if (DATE.matcher(line).matches()) {
// ...
}
}
4. String.substring() misunderstanding (pre-Java 7u6)
// Historical issue (Java 6 and earlier):
// substring() shared the original char[] — could prevent GC of large strings
// String small = hugeString.substring(0, 10); // held reference to huge char[]
// Java 7u6+ fix: substring() creates a new backing array
// No longer a memory leak concern in modern Java
// But still be aware of:
// ANTI-PATTERN: creating substrings you don't need
String first3 = input.substring(0, 3); // allocates new String + byte[]
char c = input.charAt(0); // zero allocation — prefer if you need a single char
5. Concatenation in log statements
// ANTI-PATTERN: concatenation happens even when log level is disabled
log.debug("Processing user " + user.getName() + " with id " + user.getId());
// FIX: parameterized logging — no string building if DEBUG is off
log.debug("Processing user {} with id {}", user.getName(), user.getId());
// FIX for expensive toString: use a lambda (Log4j2) or guard
if (log.isDebugEnabled()) {
log.debug("Full state: {}", expensiveObject.toDetailedString());
}
Benchmarks
The following JMH micro-benchmark compares common string assembly approaches. All benchmarks use JMH to avoid common pitfalls (dead-code elimination, constant folding, warm-up).
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(2)
@State(Scope.Thread)
public class StringConcatBenchmark {
@Param({"10", "100", "1000"})
int size;
@Benchmark
public String plusInLoop() {
String result = "";
for (int i = 0; i < size; i++) {
result += i;
}
return result;
}
@Benchmark
public String stringBuilder() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < size; i++) {
sb.append(i);
}
return sb.toString();
}
@Benchmark
public String stringBuilderPreSized() {
StringBuilder sb = new StringBuilder(size * 4);
for (int i = 0; i < size; i++) {
sb.append(i);
}
return sb.toString();
}
@Benchmark
public String stringJoin() {
List<String> list = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
list.add(String.valueOf(i));
}
return String.join(",", list);
}
@Benchmark
public String stringFormat() {
return String.format("Item %d costs %d in %s", 42, 100, "USD");
}
@Benchmark
public String concatOperator() {
return "Item " + 42 + " costs " + 100 + " in " + "USD";
}
}
Representative results (Java 21, Apple M2, JMH)
| Benchmark | size=10 (ns/op) | size=100 (ns/op) | size=1000 (ns/op) |
|---|---|---|---|
plusInLoop | ~180 | ~8,500 | ~650,000 |
stringBuilder | ~90 | ~700 | ~6,800 |
stringBuilderPreSized | ~75 | ~550 | ~5,200 |
stringJoin | ~220 | ~1,800 | ~17,000 |
Key observations from the benchmark data:
shows clear O(n²) growth: ~650,000 ns at size=1000 versus ~6,800 ns forplusInLoop . Roughly 95x slowerStringBuilder- Pre-sizing
saves ~20-25% by eliminating buffer regrowth and array copiesStringBuilder is slower than rawString.join() because it requires building aStringBuilder first, but it avoids the O(n²) trap and offers cleaner codeList- Single-expression
operator (+ ) performs comparably toconcatOperator : do not micro-optimize itStringBuilder
Formatting benchmark
| Benchmark | ns/op | Relative |
|---|---|---|
concatOperator | ~12 | 1x (baseline) |
StringBuilder | ~15 | ~1.2x |
MessageFormat | ~450 | ~37x |
String.format() | ~600 | ~50x |
Best Practices
A concise summary of do's and don'ts for string performance in Java applications.
Do
- Use
for string assembly in loops: pre-size it when you can estimate the final lengthStringBuilder - Use
orString.join() for delimiter-separated assembly: clean and O(n)Collectors.joining() - Use parameterized logging (
) to avoid concatenation when the log level is disabledlog.info("msg {}", val) - Pre-compile
objects and store them asPattern fieldsstatic final - Use
selectively for a small, known set of frequently repeated strings (country codes, enum-like values)intern() - Enable
for G1 GC workloads with high string redundancy-XX:+UseStringDeduplication - Prefer
overcharAt() when you only need a single charactersubstring() - Use text blocks for multi-line literals: zero runtime cost, better readability
- Run on Java 9+ to benefit from compact strings and
concatenationinvokedynamic
Don't
- Don't use
in loops: it causes O(n²) allocation and copying+= - Don't replace single-expression
with+ : the JVM already optimizes itStringBuilder - Don't use
in hot paths: it parses the format string on every callString.format() - Don't call
on values already known to be.toString()String - Don't intern unbounded user input: it fills the string pool and increases GC root scanning
- Don't use
unless the buffer is genuinely shared across threads (almost never)StringBuffer - Don't compile regex patterns inside loops: use
once and reusePattern.compile() - Don't prematurely optimize simple concatenation: measure first with JMH
JVM flags reference
| Flag | Default | Purpose |
|---|---|---|
-XX:+CompactStrings | Enabled (Java 9+) | Latin-1 strings use 1 byte/char instead of 2 |
-XX:+UseStringDeduplication | Disabled | G1 GC deduplicates backing byte[] of identical strings |
-XX:StringTableSize=N | 65536 (Java 11+) | Number of buckets in the intern hash table |
-XX:StringDeduplicationAgeThreshold=N | 3 | Minimum GC age before a string is considered for dedup |
-XX:+PrintStringTableStatistics | Disabled | Print string pool stats at JVM shutdown |