Java Tutorial
🔍

Java String vs StringBuilder vs StringBuffer

Java String vs StringBuilder vs StringBuffer

Three classes. All three work with text. All three live in java.lang. A developer writing their first Java program uses String without thinking twice. A developer working on a report generator that builds a ten-thousand-line output discovers that using + in a loop is unbearably slow and switches to StringBuilder. A developer building a shared logging system for a multi-threaded application learns that StringBuilder is not safe to share between threads and reaches for StringBuffer.

Each class exists because the previous one could not handle a specific real-world need. Understanding when to use each — and why — separates code that works from code that works well.

The Core Distinction in One Place

String           — Immutable.  Every change creates a new object.
                   Safe for sharing. Safe for equality comparison.
                   Use for values you do not build iteratively.

StringBuilder    — Mutable.    Modifies the same buffer in place.
                   NOT thread-safe. Fastest for building strings.
                   Use inside loops and single-threaded builders.

StringBuffer     — Mutable.    Same API as StringBuilder.
                   Thread-safe — every method is synchronised.
                   Use only when multiple threads share one buffer.

Performance order (fastest to slowest for building):
  StringBuilder  >  StringBuffer  >  String (+)
  (no sync)         (sync overhead)   (new obj each concat)

1 — String: Immutable, Safe, Everywhere

Every String operation returns a new String. The original is never modified. This is not a limitation — it is a deliberate design that makes Strings safe to share between threads without any synchronisation, safe to use as HashMap keys, and eligible for pool reuse.

// File: StringBehaviour.java public class StringBehaviour { public static void main(String[] args) { String city = "mumbai"; // Every method returns a NEW String — original unchanged String upper = city.toUpperCase(); String trimmed = " hello ".trim(); String replaced = city.replace("mumbai", "delhi"); System.out.println("Original : " + city); // mumbai — unchanged System.out.println("Upper : " + upper); // MUMBAI System.out.println("Replaced : " + replaced); // delhi System.out.println("city still: " + city); // mumbai — still unchanged System.out.println(); // String concatenation with + in a simple expression — fine String name = "Priya"; String role = "Developer"; String profile = "Name: " + name + " | Role: " + role; // OK — small, fixed System.out.println(profile); System.out.println(); // String concatenation with + in a LOOP — problem String result = ""; for (int i = 1; i <= 5; i++) { result = result + "item" + i + ","; // Each iteration: old result + new content = NEW String object // Old string is discarded. For 100,000 iterations, 100,000 discarded objects. } System.out.println("Loop result: " + result); System.out.println(); // When String is the RIGHT choice String orderId = "ORD-2024-001"; // fixed value — no building needed String status = "PLACED"; // constant-like boolean isPlaced = "PLACED".equals(status); // safe comparison System.out.println("Is placed: " + isPlaced); } }
Output:
Original  : mumbai
Upper     : MUMBAI
Replaced  : delhi
city still: mumbai

Name: Priya | Role: Developer

Loop result: item1,item2,item3,item4,item5,

Is placed: true

2 — StringBuilder: Mutable, Fast, Single-Threaded

StringBuilder maintains a single internal buffer. Every append(), insert(), delete(), and replace() modifies the same buffer. No new objects are created until toString() is called. It is not synchronised — safe and fast in a single thread, undefined behaviour if shared across threads.

// File: StringBuilderBehaviour.java public class StringBuilderBehaviour { public static void main(String[] args) { // Same loop as before — now with StringBuilder StringBuilder sb = new StringBuilder(); for (int i = 1; i <= 5; i++) { sb.append("item").append(i).append(","); // No new objects — same buffer modified every iteration } System.out.println("Loop result: " + sb.toString()); System.out.println(); // All the modification methods StringBuilder demo = new StringBuilder("Hello World"); demo.insert(5, ","); // Hello, World demo.replace(7, 12, "Java"); // Hello, Java demo.delete(0, 7); // Java demo.reverse(); // avaJ demo.append("!!!"); // avaJ!!! System.out.println("Modified: " + demo); System.out.println(); // Chaining — all methods return 'this' String result = new StringBuilder() .append("Order: ORD-001") .append(" | Customer: Priya") .append(" | Amount: Rs.1299") .append(" | Status: PLACED") .toString(); System.out.println(result); System.out.println(); // StringBuilder is NOT thread-safe — do NOT share between threads // Local variables are fine — they are never seen by other threads StringBuilder localOnly = new StringBuilder(); // safe — local to this method localOnly.append("This is safe to use here."); System.out.println(localOnly); } }
Output:
Loop result: item1,item2,item3,item4,item5,

Modified: avaJ!!!

Order: ORD-001 | Customer: Priya | Amount: Rs.1299 | Status: PLACED

This is safe to use here.

3 — StringBuffer: Mutable, Thread-Safe, Shared

StringBuffer is identical to StringBuilder in every method — same append(), insert(), delete(), replace(), reverse(). The only difference: every method carries synchronized. One thread at a time. Correct for concurrent access, slower than StringBuilder due to locking.

// File: StringBufferBehaviour.java import java.util.concurrent.CountDownLatch; public class StringBufferBehaviour { public static void main(String[] args) throws InterruptedException { // Show that StringBuffer is safe when shared across threads StringBuffer shared = new StringBuffer(); CountDownLatch latch = new CountDownLatch(3); // Three threads all appending to the same buffer for (int t = 1; t <= 3; t++) { final int threadNum = t; new Thread(() -> { for (int i = 1; i <= 3; i++) { shared.append("T").append(threadNum) .append("-MSG").append(i).append(" | "); } latch.countDown(); }).start(); } latch.await(); // wait for all threads to finish String result = shared.toString(); System.out.println("StringBuffer — all messages present:"); System.out.println("Message count : " + result.split("\\| ").length); System.out.println("Contains T1 : " + result.contains("T1-MSG1")); System.out.println("Contains T2 : " + result.contains("T2-MSG1")); System.out.println("Contains T3 : " + result.contains("T3-MSG1")); System.out.println("No data loss : " + (result.split("\\| ").length >= 9)); System.out.println(); // All the same methods as StringBuilder StringBuffer buf = new StringBuffer("Java StringBuffer"); System.out.println("Original : " + buf); buf.insert(5, " is"); System.out.println("insert : " + buf); buf.delete(0, 5); System.out.println("delete : " + buf); buf.replace(0, 2, "IS"); System.out.println("replace : " + buf); buf.reverse(); System.out.println("reverse : " + buf); } }
Output:
StringBuffer — all messages present:
Message count : 9
Contains T1   : true
Contains T2   : true
Contains T3   : true
No data loss  : true

Original  : Java StringBuffer
insert    : Java is StringBuffer
delete    : is StringBuffer
replace   : IS StringBuffer
reverse   : reffuBgnirtS SI

Side-by-Side Performance Comparison

// File: PerformanceComparison.java public class PerformanceComparison { static final int ITERATIONS = 100_000; // Method 1 — String with + static long testStringConcat() { long start = System.currentTimeMillis(); String result = ""; for (int i = 0; i < ITERATIONS; i++) { result = result + i + ","; } return System.currentTimeMillis() - start; } // Method 2 — StringBuilder static long testStringBuilder() { long start = System.currentTimeMillis(); StringBuilder sb = new StringBuilder(); for (int i = 0; i < ITERATIONS; i++) { sb.append(i).append(","); } String result = sb.toString(); return System.currentTimeMillis() - start; } // Method 3 — StringBuffer static long testStringBuffer() { long start = System.currentTimeMillis(); StringBuffer sbf = new StringBuffer(); for (int i = 0; i < ITERATIONS; i++) { sbf.append(i).append(","); } String result = sbf.toString(); return System.currentTimeMillis() - start; } public static void main(String[] args) { // Warm up JVM first testStringBuilder(); testStringBuffer(); long stringTime = testStringConcat(); long sbTime = testStringBuilder(); long sbfTime = testStringBuffer(); System.out.println("Performance comparison (" + ITERATIONS + " iterations):"); System.out.println("─".repeat(45)); System.out.printf("%-20s : %5d ms%n", "String (+)", stringTime); System.out.printf("%-20s : %5d ms%n", "StringBuilder", sbTime); System.out.printf("%-20s : %5d ms%n", "StringBuffer", sbfTime); System.out.println("─".repeat(45)); if (sbTime > 0) { System.out.printf("String is ~%dx slower than StringBuilder%n", stringTime / sbTime); } if (sbfTime > 0 && sbTime > 0) { System.out.printf("StringBuffer is ~%dx slower than StringBuilder%n", sbfTime / Math.max(sbTime, 1)); } } }
Output:
Performance comparison (100000 iterations):
─────────────────────────────────────────────
String (+)           :  2843 ms
StringBuilder        :     7 ms
StringBuffer         :    18 ms
─────────────────────────────────────────────
String is ~406x slower than StringBuilder
StringBuffer is ~2x slower than StringBuilder

StringBuilder is the fastest. StringBuffer is around 2x slower due to the synchronisation overhead. String + in a loop is hundreds of times slower because it creates a new String object on every iteration — for 100,000 iterations, the total amount of data copied grows quadratically.

Complete Comparison Table

AspectStringStringBuilderStringBuffer
MutableNo — every operation returns a new StringYes — modifies buffer in placeYes — modifies buffer in place
Thread-safeYes — immutability makes it inherently safeNo — not synchronisedYes — every method is synchronized
Performance in loopsSlowest — O(n²) character copiesFastest — O(n) character copiesFast but slower than SB — sync overhead
When to useFixed values, constants, parameters, comparisonsBuilding strings in a loop — single threadBuilding strings shared across multiple threads
+ operatorYes — compiles to StringBuilder behind the scenesNo — use append()No — use append()
String PoolYes — literals are pooledNo — toString() creates a heap objectNo — toString() creates a heap object
Return type of methodsNew String (immutable result)this — chainablethis — chainable
equals() behaviourCompares character contentInherits from Object — reference onlyInherits from Object — reference only
hashCode()Based on content — stableBased on identity — unstable for HashMap keysBased on identity — unstable for HashMap keys
Null appendN/AAppends "null" safelyAppends "null" safely
Default capacityN/A16 characters16 characters
IntroducedJava 1.0Java 1.5Java 1.0
Packagejava.langjava.langjava.lang

Decision Guide — Which One to Use

START: Do you need to BUILD a string by combining parts?
         |
        NO → Use String
              Examples:
              — "ORDER_PLACED" (constant)
              — comparing: "admin".equals(role)
              — method parameters: void process(String name)
         |
        YES → Is the building happening INSIDE A LOOP
              or does it need insert/delete/replace?
                  |
                 NO → Small, fixed number of + concatenations
                       Compiler handles it → String + is fine
                       "Name: " + name + " | Age: " + age
                  |
                 YES → Will the buffer be SHARED BETWEEN
                       MULTIPLE THREADS simultaneously?
                           |
                          NO → Use StringBuilder
                                — local variable in a method
                                — building a report / email
                                — constructing a SQL query
                                — assembling a JSON string
                           |
                          YES → Use StringBuffer
                                — shared log buffer
                                — concurrent event collector
                                — multi-thread message aggregator

When Each One Is Correct — Real Examples

// File: WhenToUseEach.java import java.util.List; public class WhenToUseEach { // ── Use String ──────────────────────────────────────────────────── // Reason: Fixed values, no iterative building, needs equals/hashCode static final String DEFAULT_STATUS = "PENDING"; public static boolean isOrderPlaced(String status) { return "PLACED".equals(status); // immutable String — correct } // ── Use StringBuilder ───────────────────────────────────────────── // Reason: Building in a loop — single thread — local variable public static String buildCsvRow(String[] fields) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < fields.length; i++) { sb.append(fields[i]); if (i < fields.length - 1) sb.append(","); } return sb.toString(); } public static String buildHtmlList(List<String> items) { StringBuilder html = new StringBuilder("<ul>"); for (String item : items) { html.append("<li>").append(item).append("</li>"); } html.append("</ul>"); return html.toString(); } // ── Use StringBuffer ────────────────────────────────────────────── // Reason: Shared instance field accessed by multiple threads static class SharedEventLog { private final StringBuffer log = new StringBuffer(); // shared — needs sync public void record(String event) { log.append("[").append(System.currentTimeMillis()).append("] ") .append(event).append("\n"); } public String dump() { return log.toString(); } } public static void main(String[] args) { // String use System.out.println("=== String ==="); System.out.println("Is PLACED: " + isOrderPlaced("PLACED")); System.out.println("Is PLACED: " + isOrderPlaced("PENDING")); System.out.println("Default : " + DEFAULT_STATUS); System.out.println(); // StringBuilder use System.out.println("=== StringBuilder ==="); String[] orderFields = {"ORD-001", "Priya", "1299.50", "PLACED"}; System.out.println("CSV: " + buildCsvRow(orderFields)); List<String> items = List.of("Laptop", "Mouse", "Keyboard"); System.out.println("HTML: " + buildHtmlList(items)); System.out.println(); // StringBuffer use System.out.println("=== StringBuffer ==="); SharedEventLog eventLog = new SharedEventLog(); eventLog.record("User login: Priya"); eventLog.record("Order placed: ORD-001"); eventLog.record("Payment processed: PAY-789"); System.out.println("Log has content: " + (eventLog.dump().length() > 0)); System.out.println("Log line count : " + eventLog.dump().split("\n").length); } }
Output:
=== String ===
Is PLACED: true
Is PLACED: false
Default  : PENDING

=== StringBuilder ===
CSV: ORD-001,Priya,1299.50,PLACED
HTML: <ul><li>Laptop</li><li>Mouse</li><li>Keyboard</li></ul>

=== StringBuffer ===
Log has content: true
Log line count : 3

The Compiler and String +

A common misconception is that String + is always bad. The Java compiler is smarter than that. For simple, non-loop expressions, it automatically converts + to StringBuilder internally.

// File: CompilerOptimisationDemo.java public class CompilerOptimisationDemo { public static void main(String[] args) { String name = "Priya"; int age = 24; String role = "Developer"; // This line: String result = "Name: " + name + " | Age: " + age + " | Role: " + role; // Compiles to something equivalent to: // String result = new StringBuilder() // .append("Name: ").append(name) // .append(" | Age: ").append(age) // .append(" | Role: ").append(role) // .toString(); System.out.println(result); System.out.println(); // BUT — in a loop, the compiler does NOT carry the StringBuilder across iterations String loop1 = ""; for (int i = 0; i < 5; i++) { loop1 = loop1 + i; // compiler creates a NEW StringBuilder PER ITERATION // effectively: loop1 = new StringBuilder(loop1).append(i).toString(); } System.out.println("Loop with + : " + loop1); // 01234 // With explicit StringBuilder — same buffer used across ALL iterations StringBuilder sb = new StringBuilder(); for (int i = 0; i < 5; i++) { sb.append(i); // same buffer — no temporary object } System.out.println("Loop with SB: " + sb); // 01234 System.out.println(); System.out.println("Same output, very different performance for large loops."); System.out.println("Compiler optimises simple expressions, NOT loops."); } }
Output:
Name: Priya | Age: 24 | Role: Developer

Loop with + : 01234
Loop with SB: 01234

Same output, very different performance for large loops.
Compiler optimises simple expressions, NOT loops.

The two outputs are identical, but the performance is not. The + loop creates a new StringBuilder and a new String object on every single iteration. For 100,000 iterations, that is 100,000 StringBuilder and 100,000 String objects created and discarded. The explicit StringBuilder loop uses one buffer throughout.

Basic Example

public class Main { public static void main(String[] args) { // String — immutable, "+" creates a brand new object each time String s = "Hi"; s = s + " there"; System.out.println("String : " + s); // StringBuilder — mutable, fast, use in single-threaded code StringBuilder sb = new StringBuilder("Hi"); sb.append(" there"); System.out.println("StringBuilder : " + sb); // StringBuffer — mutable, thread-safe, same result but synchronized StringBuffer sbf = new StringBuffer("Hi"); sbf.append(" there"); System.out.println("StringBuffer : " + sbf); } }

Output

String        : Hi there
StringBuilder : Hi there
StringBuffer  : Hi there

How it works:

  1. ›s = s + " there" does not modify the original "Hi" — it builds a new String object and makes s point to it.
  2. ›sb.append(" there") modifies the same StringBuilder buffer in place — no new object is created for the append.
  3. ›sbf.append(" there") does the same as StringBuilder, but the method is synchronized, so it is safe even if multiple threads shared this buffer.
  4. ›All three produce the identical text, but they get there differently — which is why the right choice depends on whether you are building text in a loop (StringBuilder), sharing it across threads (StringBuffer), or just holding a fixed value (String).

Best Practices

Default to String for values you read but do not build. Configuration values, method parameters, comparison targets, HashMap keys, and return values that come from a single expression are all best represented as String. Immutability means safe sharing, safe use as map keys, and no defensive copying needed.

Default to StringBuilder for anything you build iteratively. Any time you write a for loop or while loop that builds a string piece by piece, use StringBuilder. The performance difference over String + grows from negligible at 5 iterations to hundreds of times faster at 100,000 iterations.

Use StringBuffer only when you have confirmed multi-threaded shared access. The synchronisation overhead is real — StringBuffer is about twice as slow as StringBuilder. Introducing that overhead in single-threaded code is a performance regression with no correctness benefit.

For simple fixed concatenations outside loops, String + is fine. The Java compiler converts "Hello, " + name + "!" to StringBuilder internally. You gain nothing by writing new StringBuilder().append("Hello, ").append(name).append("!").toString() for two or three parts outside a loop.

Common Mistakes

Mistake 1 — String + in a Loop

// Creates thousands of temporary objects — very slow for large n String result = ""; for (String item : largeList) { result = result + item + ", "; // new String object every iteration } // Fix StringBuilder sb = new StringBuilder(); for (String item : largeList) { sb.append(item).append(", "); } String result = sb.toString();

Mistake 2 — StringBuilder in a Multi-Threaded Shared Context

// WRONG — StringBuilder shared across threads is not safe class Logger { private StringBuilder log = new StringBuilder(); // shared — race condition! public void write(String msg) { log.append(msg).append("\n"); // two threads can corrupt this } } // Fix class Logger { private StringBuffer log = new StringBuffer(); // synchronised — safe public void write(String msg) { log.append(msg).append("\n"); } }

Mistake 3 — Comparing StringBuilder or StringBuffer With equals()

StringBuilder sb1 = new StringBuilder("hello"); StringBuilder sb2 = new StringBuilder("hello"); System.out.println(sb1.equals(sb2)); // false! — StringBuilder inherits Object.equals() // equals() for StringBuilder/StringBuffer is reference comparison — not content // Fix — convert to String first System.out.println(sb1.toString().equals(sb2.toString())); // true — String.equals()

Neither StringBuilder nor StringBuffer overrides equals(). Calling equals() on them compares object references, not content. Always call toString() before comparing.

Mistake 4 — Using StringBuilder as a HashMap Key

Map<StringBuilder, String> map = new HashMap<>(); StringBuilder key = new StringBuilder("user-001"); map.put(key, "Priya"); key.append("-updated"); // mutates the key! System.out.println(map.get(key)); // null — hash changed, key no longer found // Fix — use String as HashMap keys, always Map<String, String> map = new HashMap<>(); map.put("user-001", "Priya");

StringBuilder's hashCode() is based on its object identity — not its content. Even if it were content-based, mutating the key after insertion would corrupt the map. Always use immutable String as HashMap keys.

Interview Questions

Q1. What are the main differences between String, StringBuilder, and StringBuffer?

String is immutable — every modification creates a new object. It is thread-safe by design, eligible for String Pool reuse, and correct for equality comparison via equals(). StringBuilder is mutable — it modifies its internal buffer in place, is not thread-safe, and is the fastest option for building strings iteratively in a single thread. StringBuffer is mutable with the same API as StringBuilder but every method is synchronised, making it thread-safe at the cost of performance overhead. Use String for fixed values, StringBuilder for loops, StringBuffer for multi-threaded shared buffers.

Q2. When should you use StringBuilder over String concatenation with +?

Whenever string building happens inside a loop or involves a variable number of parts. The Java compiler converts simple multi-part + expressions outside loops into StringBuilder automatically — so "Hello, " + name + "!" is already efficient. But in a loop, each iteration creates a new StringBuilder and discards it, producing O(n²) character copy work. An explicit StringBuilder declared outside the loop reuses the same buffer for all iterations, reducing copy work to O(n). The difference is negligible for 5 iterations and hundreds of times for 100,000.

Q3. Is StringBuffer fully thread-safe for compound operations?

Each individual method call is thread-safe — only one thread can execute any given method at a time. But a sequence of calls is not atomic. If Thread A reads sb.length() and then calls sb.insert(length, value), Thread B could modify the buffer between those two calls. The result of Thread A's insert would then target a position that no longer reflects what it read. For compound read-then-modify operations, callers must wrap the sequence in a synchronized (buffer) block themselves.

Q4. Does the Java compiler optimise String + automatically?

Yes, for simple expressions. The compiler converts "a" + b + "c" to equivalent StringBuilder code. However, this optimisation does not apply across loop iterations. Each iteration of result = result + item compiles to a new StringBuilder that wraps the current result, appends item, and calls toString() — creating a new String each time. For loops, explicit StringBuilder outside the loop is required to get the performance benefit.

Q5. Why can you not use StringBuilder as a HashMap key?

StringBuilder does not override hashCode() or equals(). Its hashCode() is based on object identity — the memory address — not on content. If you put a StringBuilder as a key and then mutate it (which is easy since it is mutable), the hash code changes and the map can no longer find the key. HashMap stores entries by initial hash — after mutation, lookups compute a different hash, land in a different bucket, and find nothing. Always use immutable String as map keys — its content-based hashCode() is stable.

Q6. What is the output of new StringBuilder("hello").equals(new StringBuilder("hello"))?

false. StringBuilder inherits equals() from Object, which compares references — not content. Two different StringBuilder objects with the same content are not equal by equals(). To compare content, call toString() first: sb1.toString().equals(sb2.toString()). This is a common interview trap — unlike String, which overrides equals() to compare characters, StringBuilder and StringBuffer do not.

FAQs

Can you convert between String, StringBuilder, and StringBuffer?

Yes. new StringBuilder(str) creates a StringBuilder from a String. sb.toString() converts a StringBuilder back to a String. new StringBuffer(sb.toString()) converts from StringBuilder to StringBuffer. All three also accept CharSequence in their constructors — the interface that all three implement — so conversion is always straightforward.

Does String.format() use StringBuilder internally?

Yes. String.format() builds the result using a Formatter which internally uses StringBuilder. So String.format("Name: %s | Age: %d", name, age) is efficient — no multiple String objects are created in the process. For formatting a single structured output string, String.format() is clean and readable. For building many formatted strings in a loop, pre-allocate a StringBuilder and use Formatter or String.format() per item, appending results.

Which should you use in a Spring Boot REST controller?

For building response bodies, JSON strings, or HTML in a controller method — use StringBuilder. These methods run in a single thread per request (the request-handling thread). The StringBuilder is local to the method — it is never shared. For any shared state that multiple request threads write to concurrently (like a shared in-memory log), use StringBuffer or better: a ConcurrentLinkedQueue or logging framework.

Do modern Java features replace StringBuilder?

For many common cases, yes. Java 8 String.join() replaces delimiter-separated list building. Streams with Collectors.joining() replace loop-based aggregation. Java 15 text blocks replace multi-line string construction with +. For complex dynamic building — conditional appends, template filling, HTML generation — StringBuilder is still the clearest tool.

Is there any case where String + is faster than StringBuilder?

For a single concatenation — "Hello, " + name — the compiler-generated StringBuilder code and manual StringBuilder produce identical bytecode. For two or three fixed parts outside a loop, there is no performance difference. String + only becomes significantly slower when it is inside a loop where the compiler cannot carry the same StringBuilder across iterations.

Summary

String, StringBuilder, and StringBuffer each exist because the others could not handle a specific real-world need efficiently and correctly.

String is the default — immutable, safe to share, eligible for pool reuse, correct for equality comparison. Use it for everything that is not iteratively built.

StringBuilder is the loop workhorse — single mutable buffer, fastest possible building, not thread-safe. Use it any time you assemble a string in a loop or through multiple conditional appends in one thread.

StringBuffer is the concurrent option — same methods as StringBuilder with synchronisation on every call. Use it when one buffer object is written by multiple threads simultaneously.

The decision is almost never difficult in practice: String for values, StringBuilder for building, StringBuffer only when the building is concurrent.

What to Read Next

Practice Programs

Put this topic into practice — each program has full code, output and a step-by-step explanation you can run online.

Browse all Java programs →