Contents

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
FeatureStringBuilderStringBuffer
Thread safetyNot synchronizedAll methods synchronized
Single-thread speedFaster (no lock overhead)Slower (~15-30% overhead)
Introduced inJava 5Java 1.0
Use whenString 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)
ApproachRelative SpeedNotes
+ operator (single expression)1x (baseline)JVM-optimized via invokedynamic
StringBuilder~1xEquivalent to + for simple cases
String.format()~5-10x slowerParses pattern every call
MessageFormat (pre-compiled)~3-6x slowerBetter 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
Deduplication vs Interning
Aspectintern()G1 String Dedup
LevelApplication codeGC / JVM
What is sharedEntire String object (same reference)Only the backing byte[]
Identity (==)Yes — same objectNo — separate String objects
Code changesMust call .intern()None — JVM flag only
OverheadHash table lookup on intern()GC pause slightly longer
Best forSmall, known set of repeated valuesLarge-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)
Benchmarksize=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:

Formatting benchmark
Benchmarkns/opRelative
concatOperator (single-expression +)~121x (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
Don't
JVM flags reference
FlagDefaultPurpose
-XX:+CompactStringsEnabled (Java 9+)Latin-1 strings use 1 byte/char instead of 2
-XX:+UseStringDeduplicationDisabledG1 GC deduplicates backing byte[] of identical strings
-XX:StringTableSize=N65536 (Java 11+)Number of buckets in the intern hash table
-XX:StringDeduplicationAgeThreshold=N3Minimum GC age before a string is considered for dedup
-XX:+PrintStringTableStatisticsDisabledPrint string pool stats at JVM shutdown