Contents
- String Immutability Recap
- String Concatenation
- StringBuilder vs StringBuffer
- String.format vs MessageFormat
- String Interning
- String Deduplication
- Text Blocks Performance
- Common Anti-Patterns
- Benchmarks
- Best Practices
Every String in Java is immutable — once created, its contents never change. This design enables safe sharing across threads, safe use as HashMap keys (the hash code is cached on first computation), and security guarantees for class loading and network connections. However, immutability has a direct performance cost: any operation that appears to modify a string — concatenation, replace(), toUpperCase(), trim() — actually allocates a new String object and a new backing byte[] array.
The performance implications are significant. Each new String object costs at minimum 40 bytes of heap (16-byte object header + 12 bytes for the reference to the backing array + 4 bytes for the cached hash + 4 bytes for the coder flag + padding). The backing byte[] adds another 16 + length bytes. For short strings, the overhead-to-payload ratio is high — a 5-character Latin-1 string consumes roughly 61 bytes total.
// 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
Immutability is not a flaw — it is a carefully chosen trade-off. The performance cost is paid per modification, not per read. For strings that are created once and read many times (the common case), immutability is a net win because of hash caching, thread safety, and string pool deduplication.
The + operator is the most common way to build strings. Prior to Java 9, the compiler translated every + expression into a StringBuilder chain: new StringBuilder().append(a).append(b).toString(). Starting with Java 9 (JEP 280), the compiler emits an invokedynamic call that defers the concatenation strategy to the JVM at runtime. This allows the JVM to choose the optimal approach — pre-sizing buffers, using byte[] directly, or even generating specialized bytecode — without recompiling the source.
Single-expression concatenation
For a single expression like String msg = a + " " + b, the JVM handles optimization efficiently. In Java 9+, the invokedynamic bootstrap method (StringConcatFactory) can calculate the exact buffer size before allocating, producing zero intermediate objects. This is fast and clean — do not replace single-expression + with 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 + across loop iterations. Each iteration creates a new String object, copies all previously accumulated characters into it, and then appends the new content. For n iterations, this results in O(n²) character copies and O(n) intermediate String allocations — a quadratic blowup that becomes dramatic for even moderate n.
// 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();
Never use + or += inside a loop to accumulate strings. Even with Java 9+ invokedynamic optimizations, the compiler cannot optimize across loop boundaries. Use StringBuilder or String.join() instead.
String.join() and Collectors.joining()
For joining a collection of strings with a delimiter, String.join() and Collectors.joining() are both clean and performant. Internally, String.join() uses a StringJoiner which is backed by a StringBuilder.
// 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 and StringBuffer share the same API — append(), insert(), delete(), replace(), reverse(). The sole difference is that StringBuffer synchronizes every method call, making it thread-safe but slower due to lock acquisition overhead. In practice, string building almost always happens within a single method on a single thread, so the synchronization is wasted.
Capacity and growth
Both classes start with a default capacity of 16 characters. When the buffer is full, it grows to (oldCapacity + 1) * 2. Each growth requires allocating a new byte[] and copying existing content. Pre-sizing the capacity eliminates these reallocations entirely.
// 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 |
In modern Java, if you need thread-safe string assembly, a better pattern is for each thread to build with its own StringBuilder and then publish only the final immutable String. This avoids contention entirely.
String.format() parses the format string on every invocation — there is no caching of the parsed pattern. For hot code paths, this parsing overhead dominates execution time. MessageFormat can be pre-compiled, but its parsing is also non-trivial. Both are significantly slower than StringBuilder or simple concatenation.
// 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 |
| + operator (single expression) | 1x (baseline) | JVM-optimized via invokedynamic |
| StringBuilder | ~1x | Equivalent to + for simple cases |
| String.format() | ~5-10x slower | Parses pattern every call |
| MessageFormat (pre-compiled) | ~3-6x slower | Better than format(), still heavy |
Avoid String.format() in tight loops or latency-sensitive code. For logging, use parameterized messages (SLF4J style: log.info("User {} has {} items", name, count)) which avoid formatting entirely when the log level is disabled.
The JVM maintains a String Pool (also called the intern pool) — a hash table of unique String instances. All string literals are automatically interned at class-loading time. Calling intern() on a runtime-created string adds it to the pool (or returns the existing pooled instance if an equal string already exists), allowing identity comparison (==) instead of 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 String was backed by a char[] using 2 bytes per character (UTF-16). Java 9 introduced Compact Strings (JEP 254): strings containing only Latin-1 characters are now stored in a byte[] using 1 byte per character. This reduces heap usage for Latin-1-dominated workloads by roughly 30-40%. A coder field distinguishes LATIN1 (0) from UTF16 (1) encoding.
// 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 (default is 65536 since Java 11; was 60013 in Java 7/8). Use a prime number for best distribution.
// 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
Do not blindly intern every string. The string pool uses native memory and has a fixed hash table — over-interning increases GC root scanning time and can cause long pauses. Intern only when you have a small set of frequently repeated values (country codes, status enums, column names).
Java 8u20 introduced G1 String Deduplication (-XX:+UseStringDeduplication). When enabled, the G1 garbage collector identifies String objects with identical backing byte[] arrays and makes them share a single copy. Unlike intern(), this operates at the GC level — it does not affect == identity and does not change the string pool. It deduplicates the byte[] payload, not the String object itself.
// 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 intern() but want heap savings
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 (Java 13 preview, Java 15 standard) use the """ delimiter for multi-line string literals. A common concern is whether text blocks have runtime overhead. They do not — the compiler processes text blocks entirely at compile time. The resulting bytecode contains a single constant-pool string identical to what you would get from a manually concatenated equivalent. Indentation stripping, line-ending normalization, and escape processing all happen during compilation.
// 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
Text blocks are pure syntactic sugar resolved at compile time. Use them freely for readability — SQL queries, JSON templates, HTML fragments — without worrying about performance. The only runtime cost is if you chain .formatted() or .replace() on the result.
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());
}
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:
- plusInLoop shows clear O(n²) growth: ~650,000 ns at size=1000 versus ~6,800 ns for StringBuilder — roughly 95x slower
- Pre-sizing StringBuilder saves ~20-25% by eliminating buffer regrowth and array copies
- String.join() is slower than raw StringBuilder because it requires building a List first, but it avoids the O(n²) trap and offers cleaner code
- Single-expression + operator (concatOperator) performs comparably to StringBuilder — do not micro-optimize it
Formatting benchmark
| Benchmark | ns/op | Relative |
| concatOperator (single-expression +) | ~12 | 1x (baseline) |
| StringBuilder | ~15 | ~1.2x |
| MessageFormat (pre-compiled) | ~450 | ~37x |
| String.format() | ~600 | ~50x |
These benchmarks are representative but will vary by JVM version, hardware, and workload. Always measure your own application with JMH before making optimization decisions. Avoid premature optimization — readability matters.
A concise summary of do's and don'ts for string performance in Java applications.
Do
- Use StringBuilder for string assembly in loops — pre-size it when you can estimate the final length
- Use String.join() or Collectors.joining() for delimiter-separated assembly — clean and O(n)
- Use parameterized logging (log.info("msg {}", val)) to avoid concatenation when the log level is disabled
- Pre-compile Pattern objects and store them as static final fields
- Use intern() selectively for a small, known set of frequently repeated strings (country codes, enum-like values)
- Enable -XX:+UseStringDeduplication for G1 GC workloads with high string redundancy
- Prefer charAt() over substring() when you only need a single character
- Use text blocks for multi-line literals — zero runtime cost, better readability
- Run on Java 9+ to benefit from compact strings and invokedynamic concatenation
Don't
- Don't use += in loops — it causes O(n²) allocation and copying
- Don't replace single-expression + with StringBuilder — the JVM already optimizes it
- Don't use String.format() in hot paths — it parses the format string on every call
- Don't call .toString() on values already known to be String
- Don't intern unbounded user input — it fills the string pool and increases GC root scanning
- Don't use StringBuffer unless the buffer is genuinely shared across threads (almost never)
- Don't compile regex patterns inside loops — use Pattern.compile() once and reuse
- 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 |