StringBuilder vs StringBuffer in Java — When and Why to Use Each
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 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 append() 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 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)); } }
StringBuilder took: 4 ms
Results match: true
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 append() exists.
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(), delete(), and replace() calls fluidly on a single line.
One underused feature is insert() — 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.
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 } }
--- HTTP Request ---
GET /api/users HTTP/1.1
Host: api.example.com
Accept: application/json
After insert: Hello, World
Trimmed log: disk full
Is palindrome: true
Initial capacity: 16
Length after append: 5
Capacity unchanged: 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(), reverse(), and 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 append() 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 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); } }
Buffer content: T1 T1 T2 T1 T2 T3 T1 T3 T2 T3 T1 T2 T3 T2 T3
--- Performance Comparison ---
StringBuffer: 8,241,300 ns
StringBuilder: 2,156,800 ns
StringBuilder was 3.8x faster
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 append() call is the complete unit of work. Legacy codebases using Java 1.4 or earlier often use it throughout.
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)); } }
/api/products?q=java book&category=tech&limit=20
/api/products?category=fiction&limit=10
/api/products?q=keyboard
ID,NAME,EMAIL,JOINDATE
| 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 is immutable — every + concatenation in a loop creates a new object. With 50,000 iterations that's 50,000 discarded objects hitting the garbage collector.
- StringBuilder is your default tool for all dynamic string building in single-threaded code. It's faster than StringBuffer by roughly 3-4x because it carries zero synchronization overhead.
- StringBuffer synchronizes every individual method call, not entire workflows — making it insufficient for compound operations like check-then-append, and unnecessarily slow for single-threaded use.
- The modern alternatives to both — StringJoiner for delimited lists and String.join() for simple cases — eliminate entire categories of delimiter bugs and should be in your muscle memory.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Creating a new StringBuilder inside a loop — Symptom: you're using StringBuilder but still seeing slow performance and high GC pressure — Fix: declare the StringBuilder BEFORE the loop, and call sb.setLength(0) inside the loop if you need to reuse it for each iteration, rather than creating a fresh instance every time.
- ✕Mistake 2: Assuming StringBuffer makes multi-step operations atomic — Symptom: race conditions and corrupted output even though you're using StringBuffer — Fix: understand that StringBuffer only synchronizes individual method calls. A check-then-act sequence like
if (buf.length() < max) buf.append(data)is still a race condition. Wrap the entire compound operation in a synchronized block or redesign to avoid shared mutable state. - ✕Mistake 3: Using StringBuilder's toString() result before you're done building — Symptom: partial strings appearing in output, or logic bugs where you capture the string mid-build and then continue appending — Fix: treat toString() as the final step that 'seals' the builder. Assign the result to a local variable only when the string is genuinely complete, and never use the StringBuilder again after calling toString() in production-critical paths.
Interview Questions on This Topic
- QWhat is the difference between String, StringBuilder, and StringBuffer in Java, and how would you decide which one to use in a given situation?
- QIf StringBuffer is thread-safe and StringBuilder is not, why would you ever choose StringBuilder over StringBuffer in a production codebase?
- QTricky 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
Is StringBuilder thread-safe in Java?
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.
Does Java automatically use StringBuilder for string concatenation?
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.
When would you ever use StringBuffer in modern Java?
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.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.