StringBuilder vs StringBuffer — ThreadLocal Pollution Fix
ThreadLocal<StringBuilder> polluted log entries across requests.
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
- StringBuilder and StringBuffer are mutable character sequences for building strings without creating intermediate objects.
- StringBuilder is unsynchronized, faster, and the default choice for single-threaded code (99% of use cases).
- StringBuffer synchronizes every method, providing thread safety at a 3-4x performance cost.
- Pre-size the capacity if you know approximate final length to avoid costly array copies during resizing.
- Both share nearly identical API: append, insert, delete, reverse, toString – only the synchronization differs.
StringBuilder and StringBuffer are Java's two mutable character sequence classes, both designed to solve the same fundamental problem: String immutability. Every time you concatenate with + in a loop, you create a new String object, copying the entire character array — O(n²) memory churn that kills performance at scale.
Both classes let you append, insert, or delete characters in-place using a resizable internal buffer, avoiding that allocation tax. The catch: StringBuffer synchronizes every method (append, insert, toString, etc.) with synchronized blocks, making it thread-safe but 2-5x slower in single-threaded benchmarks.
StringBuilder is the unsynchronized clone added in Java 1.5 — identical API, no locking overhead. In practice, you almost never need StringBuffer. The JVM's own String concatenation optimization (javac compiles + to StringBuilder.append() for simple cases) already uses StringBuilder internally.
StringBuffer survives mainly in legacy codebases and rare scenarios where a mutable string is shared across threads without external synchronization — think logging frameworks or buffer pools accessed by multiple workers. For everything else, StringBuilder is the default choice, and choosing the right initial capacity (e.g., new StringBuilder(estimatedLength)) avoids internal array copies that double performance cost.
Imagine you're writing a group shopping list on a whiteboard. String in Java is like writing on a sticky note — every time you add an item, you throw the note away and write a brand new one. StringBuilder is like that whiteboard — you just add to it directly. StringBuffer is the same whiteboard, but with a lock on it so only one person can write at a time, preventing chaos when multiple people try to edit it simultaneously.
Every Java application that builds dynamic text — log messages, SQL queries, JSON payloads, HTML templates — ends up concatenating strings. Doing that naively with the + operator is one of the most common silent performance killers in Java code. Senior developers spot it in code reviews immediately, and fixing it can shave milliseconds off hot paths that run millions of times a day.
The problem is that Java's String class is immutable. Every time you concatenate two strings, Java secretly creates a brand new String object in memory and throws the old one away. Do that inside a loop a thousand times and you've just created a thousand objects for the garbage collector to clean up. StringBuilder and StringBuffer exist specifically to solve this — they let you build up a string piece by piece in one mutable buffer, then convert to an immutable String only once at the end.
By the end of this article you'll understand not just how to use StringBuilder and StringBuffer, but why they exist, what makes them fundamentally different from each other, when to choose one over the other, and the exact mistakes that trip up developers in interviews and production code alike.
Why Two Mutable String Classes Exist in Java
StringBuilder and StringBuffer are Java's mutable character sequences. Unlike String, which creates a new object on every modification, both classes operate on an internal char array that grows as needed. The core difference: StringBuffer synchronizes every method, making it thread-safe at the cost of a synchronized block per call. StringBuilder drops all synchronization, trading safety for speed.
Internally, both use an expandable char[] buffer. When you append, the class checks capacity and, if full, allocates a new array (typically 2x the old size + 2) and copies the data. This resizing is O(n) per expansion, but amortized O(1) per append. The default initial capacity is 16 characters — a common source of unnecessary resizing in loops.
Use StringBuilder in single-threaded contexts: string concatenation inside methods, building JSON, SQL, or XML. Use StringBuffer only when the same builder instance is accessed from multiple threads — a rare scenario in modern Java where local variables dominate. In practice, StringBuilder is the default; StringBuffer is a legacy class from Java 1.0.
Why String Concatenation in Loops is a Silent Performance Trap
Before we talk about the solution, you need to feel the pain of the problem. In Java, String objects are immutable — once created, their content never changes. That sounds harmless until you write a loop.
When you write result = result + word inside a loop, the JVM doesn't append word to the existing string. It allocates a fresh String object that holds the combined content, then makes result point to that new object. The old one becomes garbage. Run that 10,000 times and you've created 10,000 short-lived String objects — all to build one final string.
Modern JVMs do optimise simple, single-line concatenations using StringBuilder under the hood (thanks to the javac compiler). But inside a loop, the compiler can't reliably collapse those operations, so you're on your own.
This is the exact reason StringBuilder was introduced in Java 5. It maintains an internal char[] array that grows dynamically. Every call writes into that array — no new object, no garbage. You only pay the cost of creating a String once, at the very end when you call append()toString().
public class StringConcatenationComparison { public static void main(String[] args) { int iterations = 50_000; // --- Approach 1: Naive String concatenation --- long startNaive = System.currentTimeMillis(); String naiveResult = ""; for (int i = 0; i < iterations; i++) { // Each iteration creates a brand-new String object in memory naiveResult = naiveResult + "word "; } long naiveDuration = System.currentTimeMillis() - startNaive; System.out.println("Naive String concatenation took: " + naiveDuration + " ms"); // --- Approach 2: StringBuilder --- long startBuilder = System.currentTimeMillis(); // One mutable buffer — no intermediate objects created StringBuilder builder = new StringBuilder(); for (int i = 0; i < iterations; i++) { builder.append("word "); // writes into the internal char[] array } String builderResult = builder.toString(); // ONE String object created here long builderDuration = System.currentTimeMillis() - startBuilder; System.out.println("StringBuilder took: " + builderDuration + " ms"); // Prove both produce the same output System.out.println("Results match: " + naiveResult.equals(builderResult)); } }
"Hello" + " " + "World" with a StringBuilder call — but only for compile-time constant expressions. Inside a loop, it creates a new StringBuilder on every iteration, which defeats the purpose. Always create ONE StringBuilder before the loop.StringBuilder Deep Dive — The Right Tool for Single-Threaded Work
StringBuilder is the class you'll reach for 95% of the time. It's fast, simple, and lives in java.lang so no import is needed. Let's understand it properly rather than just knowing exists.append()
Under the hood, StringBuilder allocates a char[] with a default capacity of 16 characters. When you append and exceed that capacity, it automatically doubles the array size and copies the content over. If you already know roughly how long your final string will be, pass that as an initial capacity — it eliminates those costly resize operations entirely.
The API is designed with method chaining in mind. Every method that modifies content returns this, so you can chain , append(), insert(), and delete() calls fluidly on a single line.replace()
One underused feature is — it lets you splice content into the middle of what you've already built, which is incredibly useful for building things like template strings or protocol messages where the length field comes before the body but is only known after you've written the body.insert()
public class StringBuilderDeepDive { public static void main(String[] args) { // --- 1. Pre-sized capacity for performance --- // If you know the output will be ~200 chars, say so upfront StringBuilder csvRow = new StringBuilder(200); String[] columns = {"Alice", "32", "Engineer", "London", "true"}; for (int i = 0; i < columns.length; i++) { csvRow.append(columns[i]); if (i < columns.length - 1) { csvRow.append(","); // delimiter — only between items, not after the last } } System.out.println("CSV Row: " + csvRow); // --- 2. Method chaining (fluent API) --- String httpRequest = new StringBuilder() .append("GET /api/users HTTP/1.1").append("\n") .append("Host: api.example.com").append("\n") .append("Accept: application/json").append("\n") .toString(); // produce the final immutable String System.out.println("--- HTTP Request ---"); System.out.println(httpRequest); // --- 3. insert() — underused but powerful --- StringBuilder message = new StringBuilder("Hello World"); message.insert(5, ","); // insert a comma at index 5 System.out.println("After insert: " + message); // Hello, World // --- 4. delete() and reverse() --- StringBuilder logEntry = new StringBuilder("ERROR: disk full"); logEntry.delete(0, 7); // remove the "ERROR: " prefix System.out.println("Trimmed log: " + logEntry); // disk full StringBuilder palindromeCheck = new StringBuilder("racecar"); String original = palindromeCheck.toString(); String reversed = palindromeCheck.reverse().toString(); System.out.println("Is palindrome: " + original.equals(reversed)); // --- 5. Current capacity vs length --- StringBuilder capacityDemo = new StringBuilder(); // default capacity: 16 System.out.println("Initial capacity: " + capacityDemo.capacity()); // 16 capacityDemo.append("Hello"); System.out.println("Length after append: " + capacityDemo.length()); // 5 System.out.println("Capacity unchanged: " + capacityDemo.capacity()); // still 16 } }
StringBuffer — Why Thread Safety Has a Price
StringBuffer is StringBuilder's older sibling. It was introduced in Java 1.0, a full four years before StringBuilder arrived in Java 5. The API is almost identical — you get the same , append(), insert(), delete(), and reverse()toString() methods. The one fundamental difference is that every mutating method in StringBuffer is declared synchronized.
Synchronization means the JVM places a monitor lock on the StringBuffer object before executing each method. If two threads call at the same moment, one of them waits until the other is done. This prevents the kind of data corruption you'd get if two threads tried to resize the internal append()char[] simultaneously.
But locks are expensive. Even with no contention — when only one thread is actually using the buffer — the JVM still has to acquire and release the lock on every single method call. That overhead adds up fast in tight loops.
The honest truth? StringBuffer is almost never the right answer today. If you need thread-safe string building, you usually need higher-level coordination anyway — producing the string in one thread and passing it to another, or using a thread-local StringBuilder. StringBuffer's fine-grained method-level locking rarely matches real concurrency requirements.
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class StringBufferThreadSafetyDemo { public static void main(String[] args) throws InterruptedException { // --- Demo 1: StringBuffer is safe when shared across threads --- // Each synchronized append() completes atomically before the next begins StringBuffer sharedBuffer = new StringBuffer(); ExecutorService threadPool = Executors.newFixedThreadPool(3); // Three threads all appending to the same buffer simultaneously for (int threadId = 1; threadId <= 3; threadId++) { final int id = threadId; threadPool.submit(() -> { for (int i = 0; i < 5; i++) { sharedBuffer.append("T" + id + " "); // each append is thread-safe } }); } threadPool.shutdown(); threadPool.awaitTermination(5, TimeUnit.SECONDS); // No characters will be garbled or lost — StringBuffer guarantees this System.out.println("Total chars appended: " + sharedBuffer.length()); System.out.println("Buffer content: " + sharedBuffer.toString()); // --- Demo 2: Why StringBuffer still isn't enough for compound operations --- // 'Synchronized methods' doesn't mean 'synchronized workflows' // This check-then-act pattern is NOT atomic even with StringBuffer: // // if (sharedBuffer.length() < 100) { <-- thread A reads length // sharedBuffer.append("new data"); <-- thread B also reads length here! // } <-- both threads append: race condition // // For this you need external synchronization or a different design entirely. System.out.println("\n--- Performance Comparison ---"); int ops = 100_000; // StringBuffer with lock overhead StringBuffer buffer = new StringBuffer(); long start1 = System.nanoTime(); for (int i = 0; i < ops; i++) { buffer.append("x"); } long bufferTime = System.nanoTime() - start1; // StringBuilder — no lock, no overhead StringBuilder builder = new StringBuilder(); long start2 = System.nanoTime(); for (int i = 0; i < ops; i++) { builder.append("x"); } long builderTime = System.nanoTime() - start2; System.out.printf("StringBuffer: %,d ns%n", bufferTime); System.out.printf("StringBuilder: %,d ns%n", builderTime); System.out.printf("StringBuilder was %.1fx faster%n", (double) bufferTime / builderTime); } }
ThreadLocal<StringBuilder>) or have each thread build its own string and combine at the end. StringBuffer's per-method locks don't protect multi-step workflows anyway.Real-World Patterns — When to Actually Use Each
Theory is great, but let's talk about the situations you'll actually encounter on the job. Knowing which tool to reach for without having to think about it is what separates a junior from a senior.
Use StringBuilder whenever you're building a string dynamically in a single thread — which covers building SQL fragments, constructing log messages, assembling file paths, generating HTML or CSV content in a loop, or implementing a toString() method on a complex object.
Use StringBuffer almost never — but legitimately when a buffer truly must be written to by multiple threads concurrently and each individual call is the complete unit of work. Legacy codebases using Java 1.4 or earlier often use it throughout.append()
Use plain String concatenation freely for simple, readable two- or three-part joins outside any loop. The compiler handles those efficiently. Readability wins there.
The pattern below shows a real-world toString() implementation — something every Java developer writes constantly — done correctly with StringBuilder.
import java.util.List; import java.util.StringJoiner; public class RealWorldStringBuilderPatterns { // --- Pattern 1: Building a clean toString() method --- static class Order { private final int orderId; private final String customerName; private final List<String> items; private final double totalAmount; Order(int orderId, String customerName, List<String> items, double totalAmount) { this.orderId = orderId; this.customerName = customerName; this.items = items; this.totalAmount = totalAmount; } @Override public String toString() { // Pre-size based on rough estimate — avoids internal resize copies StringBuilder sb = new StringBuilder(128); sb.append("Order{id=").append(orderId) .append(", customer='").append(customerName).append("'") .append(", items=").append(items.size()) .append(", total=$").append(String.format("%.2f", totalAmount)) .append("}"); return sb.toString(); } } // --- Pattern 2: Building a parameterised query string safely --- static String buildSearchQuery(String keyword, String category, int maxResults) { StringBuilder query = new StringBuilder("/api/products?"); // Only add parameters that are actually provided boolean firstParam = true; if (keyword != null && !keyword.isBlank()) { query.append("q=").append(keyword); firstParam = false; } if (category != null && !category.isBlank()) { if (!firstParam) query.append("&"); query.append("category=").append(category); firstParam = false; } if (maxResults > 0) { if (!firstParam) query.append("&"); query.append("limit=").append(maxResults); } return query.toString(); } // --- Pattern 3: StringJoiner — the modern alternative for delimited lists --- static String buildCsvHeader(List<String> columnNames) { // StringJoiner handles the delimiter logic for you — no trailing comma issues StringJoiner joiner = new StringJoiner(",", "", "\n"); // delimiter, prefix, suffix for (String column : columnNames) { joiner.add(column.toUpperCase()); } return joiner.toString(); } public static void main(String[] args) { Order order = new Order(10245, "Alice Chen", List.of("Laptop", "Mouse", "USB Hub"), 1249.99); System.out.println(order); System.out.println(buildSearchQuery("java book", "tech", 20)); System.out.println(buildSearchQuery(null, "fiction", 10)); System.out.println(buildSearchQuery("keyboard", null, 0)); List<String> headers = List.of("id", "name", "email", "joinDate"); System.out.print(buildCsvHeader(headers)); } }
String.join() before StringBuilder. They eliminate the 'trailing delimiter' bug entirely — the one where you accidentally end up with 'Alice,Bob,Charlie,' and can't figure out why.String.join() is cleaner and safer.Performance Tuning: Choosing the Right Initial Capacity
The default capacity of StringBuilder (and StringBuffer) is 16 characters. If you append more than that, the internal array is doubled and copied. This resizing is O(n) per copy, and it happens multiple times as the buffer grows. If you know your final string will be around 200 characters, the buffer will resize 4 times (16→32→64→128→256). Each resize copies all existing content.
Providing an initial capacity that matches your expected output eliminates all resizing. This is a micro-optimization, but on hot paths it matters. Use new StringBuilder(expectedLength) where expectedLength is a reasonable upper bound.
But be careful: over-sizing wastes memory. If you set capacity to 10,000 but only append 100 characters, you've allocated a 10,000-char array. For short-lived builders this is fine, but for long-lived ones it's wasteful.
A good rule: use expected string length as capacity. If you're not sure, 256 is a safe default for most logs and messages. You can also use ensureCapacity(int) after construction if you discover a larger size is needed.
public class CapacityTuningDemo { public static void main(String[] args) { int iterations = 1_000_000; // --- Without capacity hint: multiple resizes --- long start = System.nanoTime(); StringBuilder sb = new StringBuilder(); // default 16 for (int i = 0; i < iterations; i++) { sb.append("x"); } long noHint = System.nanoTime() - start; // --- With capacity hint --- start = System.nanoTime(); StringBuilder sb2 = new StringBuilder(iterations); // 1_000_000 for (int i = 0; i < iterations; i++) { sb2.append("x"); } long withHint = System.nanoTime() - start; System.out.printf("No capacity hint: %d ns%n", noHint); System.out.printf("With capacity hint: %d ns%n", withHint); System.out.printf("Speedup: %.2fx%n", (double) noHint / withHint); } }
- Capacity is the allocated memory size (char[] length).
- Length is the number of characters currently stored.
- When length == capacity, the kitchen needs to expand — doubling is the default.
- Expansion creates a new char[] and copies old data — O(n) per resize.
- Setting initial capacity to expected final length avoids the resize tax entirely.
new StringBuilder(1024) eliminated a resize per log line — reduced CPU usage by 8% across the cluster.The Immutability Trap That Costs You Heap Dumps
Every junior learns String is immutable. Few understand the heap pressure this creates when you concatenate in loops. Each '+' operation allocates a new char array, copies both operands, and orphans the previous array. After 10,000 iterations, you've created 10,000 temporary objects waiting for GC. That's not just slow—it's the kind of allocation pattern that triggers full GC pauses in production. StringBuilder pre-allocates a single char array and grows it geometrically (typically 2x when capacity is exceeded). For a loop building a 500 KB response, that's one allocation versus thousands. Spring Boot apps generating large JSON or XML bodies hit this constantly. If you see GC pressure and mysterious allocation spikes, look for string concatenation in request-processing loops first.
// io.thecodeforge public class HeapPressureDemo { // BAD: 10,000 allocations, high GC churn public String buildResponseBad(List<String> items) { String result = ""; for (String item : items) { result += "<item>" + item + "</item>"; } return "<root>" + result + "</root>"; } // GOOD: 3-5 allocations total public String buildResponseGood(List<String> items) { // Estimate capacity: <root> + items * (avg item len + 20) StringBuilder sb = new StringBuilder(items.size() * 50); sb.append("<root>"); for (String item : items) { sb.append("<item>").append(item).append("</item>"); } sb.append("</root>"); return sb.toString(); } }
StringBuffer's Synchronized Illusion in Modern Spring Boot
StringBuffer synchronizes every append, insert, and length method. In single-threaded code, you pay for locks you never use—a method call that takes 20 nanoseconds takes 80 because of monitor acquisition. In Spring Boot controllers, your request handler runs on a single thread per request by design. Every StringBuffer you use there is pure overhead. The argument for StringBuffer only holds when the same mutable string object is actually shared across threads AND you need deterministic reads. That's nearly never in practice. StringBuilder is not thread-safe, but properly scoped local variables never escape the thread. If you think you need StringBuffer, ask: 'Is this StringBuilder reference being read by another thread while I write to it?' If no—and it's almost always no—use StringBuilder. The synchronized guarantee of StringBuffer cost millions of CPU cycles daily in production apps that should have used StringBuilder.
// io.thecodeforge // Spring Boot controller - still single-threaded per request @RestController @RequestMapping("/api/reports") public class ReportController { // WRONG: StringBuffer on a local variable = wasted synchronization @GetMapping("/{id}") public String getReportSync(@PathVariable String id) { StringBuffer sb = new StringBuffer(256); sb.append("Report_for_").append(id); // ... more single-threaded work return sb.toString(); } // RIGHT: StringBuilder in local scope @GetMapping("/v2/{id}") public String getReportAsync(@PathVariable String id) { StringBuilder sb = new StringBuilder(256); sb.append("Report_for_").append(id); // ... exactly the same work, no lock overhead return sb.toString(); } }
Thread-Local StringBuilder Shared Across Requests Caused Data Corruption in Logging Pipeline
- ThreadLocal does not guarantee isolation across tasks if you don't reset state between uses.
- StringBuilder is safe when it's truly thread-confined — verify your concurrency model before reusing any mutable object.
- StringBuffer's synchronized methods wouldn't have helped here either; the issue wasn't concurrent writes but stale content. The real fix is lifecycle management, not thread safety.
jstat -gcutil <pid> 1000 10sudo perf stat -e cache-misses -p <pid>jstack <pid> | grep -A 10 'java.lang.StringBuffer.append'jcmd <pid> Thread.printjmap -histo:live <pid> | grep 'char\[\]'jcmd <pid> GC.class_histogram | grep char| Feature / Aspect | StringBuilder | StringBuffer |
|---|---|---|
| Introduced in Java | Java 5 (2004) | Java 1.0 (2000) |
| Thread-safe? | No — not synchronized | Yes — all methods synchronized |
| Performance (single-threaded) | Faster — no lock overhead | Slower — acquires monitor on every call |
| Performance (multi-threaded) | Unsafe if shared | Safe but still slow — consider redesign |
| API surface | Identical to StringBuffer | Identical to StringBuilder |
| Default capacity | 16 characters | 16 characters |
| Recommended use case | Single-threaded string building (99% of cases) | Legacy code or genuinely shared mutable buffer |
| Compiler optimisation | javac uses it internally for + operator | Never used by compiler automatically |
| Method chaining support | Yes — all mutating methods return this | Yes — all mutating methods return this |
| In java.lang package? | Yes — no import needed | Yes — no import needed |
Key takeaways
String.join() for simple cases — eliminate entire categories of delimiter bugs and should be in your muscle memory.Common mistakes to avoid
3 patternsCreating a new StringBuilder inside a loop
Assuming StringBuffer makes multi-step operations atomic
buf.length() < max) buf.append(data) still has a race.Using StringBuilder's toString() result before you're done building
Interview Questions on This Topic
What is the difference between String, StringBuilder, and StringBuffer in Java, and how would you decide which one to use in a given situation?
If StringBuffer is thread-safe and StringBuilder is not, why would you ever choose StringBuilder over StringBuffer in a production codebase?
Tricky follow-up: You have a multi-threaded service where 10 threads each build their own log message string. Would you use StringBuffer? What about a shared StringBuilder? What would you actually do?
Frequently Asked Questions
No. StringBuilder has no synchronization — if two threads write to the same StringBuilder simultaneously you'll get corrupted output or an ArrayIndexOutOfBoundsException during an internal resize. For single-threaded use (which covers most scenarios) this is a non-issue and gives you much better performance.
Yes, but only for simple cases. The javac compiler converts expressions like 'Hello' + name + '!' into a single StringBuilder chain at compile time. However, inside a loop, the compiler creates a new StringBuilder on every iteration — which is why you still need to manually create one StringBuilder before a loop and reuse it.
Rarely. The most legitimate case is maintaining a legacy Java 1.4 codebase where changing it would introduce risk. In new code, if you genuinely need multiple threads to contribute to a shared string buffer, you almost always need higher-level coordination (like having each thread build its own string and combine results at the end) rather than a StringBuffer, because per-method synchronization doesn't protect multi-step workflows anyway.
16 characters. When the buffer exceeds its capacity, it doubles (or grows by some factor) and copies existing content to a new array. You can set a different initial capacity via the constructor: new StringBuilder(256).
Yes, but you must reset it with setLength(0) before appending again. Calling toString() does not clear the internal buffer — it creates a new String from the current content, but the builder retains its data. If you append more after toString(), the old content remains at the beginning.
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
That's Strings. Mark it forged?
7 min read · try the examples if you haven't