Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Java  Module 4 – Arrays and strings in depth

StringBuilder and string-building performance

When += in a loop copies every char again and when one-line + is fast: StringBuilder, StringJoiner and their traps, with memory measured on JDK 25.

  • Intermediate
  • 20 minutes
  • Examples run with Java temurin-25.0.4+101.0.LTS and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Estimate how many chars += copies in a loop, and replace the loop with one StringBuilder
  • Explain why one + expression compiles to a single call while += in a loop builds a new string every round
  • Predict a StringBuilder's capacity and contents after append, insert, replace, setLength and deleteCharAt
  • Join items with StringJoiner, String.join or Collectors.joining, and choose what an empty list prints
  • Avoid the char constructor, equals and surrogate traps, and give each thread a builder of its own

Before you start

What you’ll learn · Before you start

What you will learn

  • Estimate how many chars += copies in a loop, and replace the loop with one StringBuilder
  • Explain why one + expression compiles to a single call while += in a loop builds a new string every round
  • Predict a StringBuilder's capacity and contents after append, insert, replace, setLength and deleteCharAt
  • Join items with StringJoiner, String.join or Collectors.joining, and choose what an empty list prints
  • Avoid the char constructor, equals and surrogate traps, and give each thread a builder of its own

Before you start

On this page

Building a 100,000-character string with s += "x" in a loop copies about 5 billion characters, and the recorded run below allocated 5 GB to do it. Each round creates a new String and copies everything built so far into it, so the work grows with the square of the length, while a StringBuilder appends into one buffer that grows only now and then. A + that joins a few values in one expression is fine: javac compiles it to a single call that builds one result.

You will measure both ways on JDK 25, read the bytecode javac writes for each, and step through a builder as it grows and as its methods edit it in place. Then come StringJoiner for lists with separators, three traps, and what StringBuffer does and does not do for threads. The exercise is a run-length encoder that must handle 400,000 characters within a second.

Concatenating in a loop copies every char again

A String is an immutable object: An object whose state can never change once it exists, such as a String. Methods that seem to change it, like toUpperCase(), return a new object, so one object can be shared safely by many variables and threads. More in “Strings: first look and equality”, so s += "x" cannot add a character to s. It builds a new string from the old one and "x", and the old one becomes An object that no variable or other object refers to any more. The garbage collector reclaims its memory, and making and collecting such objects is part of what += in a loop costs.. The first round copies 1 character, the second 2, and round n copies n: n(n + 1) / 2 characters in all, a quadratic growth: Work that grows with the square of the input: twice the input, about four times the work. Building a string of n chars with += in a loop copies n(n + 1) / 2 chars, which is quadratic. amount. A StringBuilder writes each new character into unused space in its buffer, and copies its contents only when the buffer is full, into a new buffer about twice the size:

s += "x" makes a new, longer string each round and copies every char again; a StringBuilder appends into one buffer.s += "x" in a loopEach += makes a new Stringand copies every char so far.xnew String, 1 char copiedxxnew String, 2 chars copiedxxxnew String, 3 chars copiedxxxxnew String, 4 chars copiedn appends copy 1 + 2 + … + n = n(n+1)/2 charssb.append("x") in a loopOne buffer: each append writes one charinto unused space; the chars already there stay.xxxxcapacity 16, length 4full: copy once into abuffer about twice as bigcapacity 34, length 17Growth is rare (16, 34, 70, 142 …): n appendscopy fewer than 2n chars in all

Why += in a loop copies so much more than StringBuilder

Text description of the diagram

The diagram has two panels.

  1. The first panel, s += "x" in a loop, shows four strings one below the other, of 1, 2, 3 and 4 characters. Each round creates a new String and copies every character built so far into it: 1 character copied, then 2, then 3, then 4. After n appends that adds up to 1 + 2 + … + n = n(n+1)/2 characters.
  2. The second panel, sb.append("x") in a loop, shows one buffer of 16 slots with 4 filled. Each append writes one character into an unused slot and leaves the characters already there where they are. When the buffer is full, its contents are copied once into a buffer about twice as large, shown with 34 slots and 17 filled. Because the capacity grows 16, 34, 70, 142 and so on, n appends copy fewer than 2n characters in all.

Step through the first rounds of both loops, and the round where the builder’s 16 slots run out. Watch the left column, where every round leaves one more string behind:

Interactive · step by step s += "x" and sb.append("x"), round by round Step through both loops of Growth.java. On the left each round makes a new string; on the right one buffer fills up.
s += "x" and sb.append("x"), round by round, step 5 of 5. Showing s = "x" (garbage after round 2), s = "xx" (garbage after round 3), s = "xxx" (garbage long ago), s: 17 chars (n(n + 1) / 2 chars copied), buffer of 16 slots (full, copied once), buffer of 34 slots (copies below 2n in all). In focus: s: 17 chars, buffer of 34 slots.copy 1, add xcopy 2, add xrounds 4 to 17copy 16 onces = "x"garbage after round 2s = "xx"garbage after round 3s = "xxx"garbage long agos: 17 charsn(n + 1) / 2 chars copiedbuffer of 16 slotsfull, copied oncebuffer of 34 slotscopies below 2n in all

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Round 1. Both loops start from nothing. s += "x" makes a new string of 1 char. sb.append("x") writes x into slot 0 of the builder's buffer, which has 16 slots.
  2. Round 2. A string never changes, so += makes another new string: it copies the 1 char of the old one and adds x, and the old string is garbage. The builder writes x into slot 1, and nothing else moves.
  3. Round 3. Round 3 copies 3 chars into a third string. So far += has copied 1 + 2 + 3 = 6 chars, while the builder has written each x once, 3 writes in all.
  4. Round 17. Round 17 copies 17 chars, the 16 of the old string and the new x. The builder's 16 slots are full, so it allocates a buffer of 34 and copies its 16 chars across, once, before it writes the 17th.
  5. After n rounds. Round n of += copies n chars, n(n + 1) / 2 in all: 5,000,050,000 for n = 100,000. The builder copies only when it grows, 16 chars, then 34, then 70, which stays below 2n.

The program below builds the same string both ways for four sizes and asks the JVM how much memory each loop allocated:

Memory and time for n appends, measured Java · Growth.java
import java.lang.management.ManagementFactory;

// Builds the same text, n copies of "x", with += and with a StringBuilder, as n doubles. For each way it prints the
// memory the loop allocated, which is the same on every run, and the time it took, which is not. A rough
// measurement (one run, a short warm-up); the performance module measures properly with JMH.
String withPlus(int n) {
    String s = "";
    for (int i = 0; i < n; i++) {
        s += "x";
    }
    return s;
}

String withBuilder(int n) {
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < n; i++) {
        sb.append("x");
    }
    return sb.toString();
}

// Bytes allocated so far by this thread (a HotSpot extension of ThreadMXBean).
long allocated() {
    return ((com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean()).getCurrentThreadAllocatedBytes();
}

void main() {
    IO.println("processors available to the JVM: " + Runtime.getRuntime().availableProcessors());
    withPlus(1_000);
    withBuilder(1_000);
    boolean same = true;
    for (int n = 25_000; n <= 200_000; n *= 2) {
        long bytes0 = allocated();
        long time0 = System.nanoTime();
        String a = withPlus(n);
        long time1 = System.nanoTime();
        long bytes1 = allocated();
        String b = withBuilder(n);
        long time2 = System.nanoTime();
        long bytes2 = allocated();
        same &= a.equals(b);
        IO.println(String.format("n = %,7d: += allocated %,8.1f MB in %,5d ms; StringBuilder %.3f MB in %.1f ms",
                n, (bytes1 - bytes0) / 1e6, (time1 - time0) / 1_000_000, (bytes2 - bytes1) / 1e6, (time2 - time1) / 1e6));
    }
    IO.println("both ways built the same strings: " + same);
}

Output

processors available to the JVM: 18
n =  25,000: += allocated    313.6 MB in   219 ms; StringBuilder 0.100 MB in 0.8 ms
n =  50,000: += allocated  1,252.2 MB in   203 ms; StringBuilder 0.199 MB in 1.6 ms
n = 100,000: += allocated  5,004.4 MB in   669 ms; StringBuilder 0.396 MB in 1.1 ms
n = 200,000: += allocated 20,008.8 MB in 2,160 ms; StringBuilder 0.791 MB in 1.0 ms
both ways built the same strings: true

This output changes from run to run: The times change from run to run with the load on the computer; the memory figures are the same on every run.

Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Growth.java

The memory column is the one to read, because it is the same on every run. Each time n doubles, += allocates four times as much, and StringBuilder twice as much: one curve grows with n², the other with n. The numbers also match the arithmetic. Strings whose characters all fit in one byte are stored with one byte per character (JEP 254, compact strings: How the JVM stores a String since JDK 9 (JEP 254): one byte per char when every char fits in Latin-1, and two bytes per char, as UTF-16, otherwise.), so for n = 100,000 the copies alone come to 100,000 × 100,001 / 2 = 5,000,050,000 bytes, about 5,000 MB. The measured 5,004.4 MB adds a few dozen bytes of object headers for each of the 100,000 strings. To build a 100 KB string, the loop allocated 5 GB, and every string but the last became garbage one round after it was made.

The times tell the same story less precisely: they include the garbage collector and the JIT compilation: Just-in-time compilation: while a program runs, the JVM compiles the methods that run often from bytecode to native machine code, so a Java program gets faster after a short warm-up. The option -Xint switches it off. More in “What Java is, how it runs and where it is used”, which changes the loop’s code while it runs. On the computer of the recorded run, += was hundreds of times slower than StringBuilder at the larger sizes, while the builder’s time hardly changed from one size to the next. A careful measurement needs a benchmark harness such as JMH, which the performance module uses.

Key idea

+= in a loop copies everything built so far in every round, n(n + 1) / 2 chars for n appends, while a StringBuilder appends in place and copies only when its buffer grows.

Checkpoint

Check: += in a loop copies everything again

  1. Question 1 of 2 String s = ""; then s += "x"; runs 2,000 times. Counting each char every time it is copied into a new string, how many chars are copied in all?

    Type a number.

    A hint

    Round i copies i chars. Add up 1 + 2 + … + 2,000.

    Show the answer: Question 1 of 2

    Answer: 2001000 chars

    Round i builds a new string of i chars, so the rounds copy 1 + 2 + … + 2,000 = 2,000 × 2,001 / 2 = 2,001,000 chars. A StringBuilder writes each x once, 2,000 writes, and copies fewer than 4,000 chars while it grows.

  2. Question 2 of 2 The recorded run allocated about 5,004 MB with += for n = 100,000. About how much would n = 400,000 allocate?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: About 80,000 MB, 16 times as much

    The run measured 313.6, 1,252.2, 5,004.4 and 20,008.8 MB as n doubled from 25,000 to 200,000: four times as much each time. Two more doublings multiply 5,004 MB by 16, about 80 GB, to build a string of 400,000 chars.

A single concatenation expression compiles to one call

The rule of thumb “never use +, always use StringBuilder” comes from older JDKs. The compiler’s output shows why it no longer holds for a single expression, and why it still matters in a loop. javac compiles the class below, and javap -c prints its bytecode: The instructions that javac writes into class files: the instruction set of the Java Virtual Machine, a computer that exists only as a specification, so the same bytecode runs on any system that has a recent enough JVM. More in “What Java is, how it runs and where it is used”:

Concat.java Java
// Two uses of +, compiled and then disassembled with javap to see what javac made of them.
class Concat {
    // One expression that joins three values.
    static String label(String city, int pin) {
        return city + " - " + pin;
    }

    // += inside a loop.
    static String repeatX(int n) {
        String s = "";
        for (int i = 0; i < n; i++) {
            s += "x";
        }
        return s;
    }
}
javac, then javap -c Python · bytecode_session.py
from session import run

run("""
javac -d out Concat.java
javap -c out/Concat.class
""")

Output

$ javac -d out Concat.java
$ javap -c out/Concat.class
Compiled from "Concat.java"
class Concat {
  Concat();
    Code:
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return

  static java.lang.String label(java.lang.String, int);
    Code:
         0: aload_0
         1: iload_1
         2: invokedynamic #7,  0              // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;I)Ljava/lang/String;
         7: areturn

  static java.lang.String repeatX(int);
    Code:
         0: ldc           #11                 // String
         2: astore_1
         3: iconst_0
         4: istore_2
         5: iload_2
         6: iload_0
         7: if_icmpge     23
        10: aload_1
        11: invokedynamic #13,  0             // InvokeDynamic #1:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
        16: astore_1
        17: iinc          2, 1
        20: goto          5
        23: aload_1
        24: areturn
}

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bytecode_session.py

javap -c disassembles a class file into its bytecode instructions. In label, the whole expression city + " - " + pin became one invokedynamic: A bytecode instruction whose target the JVM links the first time it runs, by calling a bootstrap method. Since JDK 9 javac compiles each string concatenation expression to one, which StringConcatFactory links. instruction that names makeConcatWithConstants (JEP 280, JDK 9). Before JDK 9, javac wrote a chain of StringBuilder.append calls instead. Either way, one expression builds one result each time it runs, so a single + expression was never the problem. Step through the life of that instruction, from javac to every later call of label:

Interactive · stage by stage One invokedynamic, from javac to every call Step through the life of the instruction javac wrote for city + " - " + pin, then see why the loop in repeatX is different.
One invokedynamic, from javac to every call, step 5 of 5. Showing javac compiles label() (unchanged in the class file), the first call of label(), StringConcatFactory, every later call, one new String (a new, longer s each round). In focus: every later call, one new String, loop.repeatX: next roundjavac compiles label()unchanged in the class filethe first call of label()the JVM calls the bootstrapStringConcatFactoryreturns the joining codeevery later callruns that code directlyone new Stringa new, longer s each round

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. javac writes one instruction. javac turns the whole expression city + " - " + pin into one invokedynamic instruction, at offset 2 of label(). It names makeConcatWithConstants and the types it joins, a String and an int; the text " - " waits in the class file, in the recipe of the call.
  2. The first call links it. The first time that instruction runs, the JVM calls its bootstrap method, StringConcatFactory.makeConcatWithConstants, with a recipe of the expression: a value, the text " - ", a value.
  3. The JDK picks the code. The factory returns code that joins exactly those values, and the instruction stays linked to it. The code is the JDK's own, so a newer JDK can join faster without recompiling your class.
  4. Every later call. Each later call of label() runs the linked code directly, which builds one new String from city and pin. One expression, one result, whatever the number of + signs.
  5. The same instruction in a loop. In repeatX the instruction sits inside the loop, so every round runs it again and builds a new string from s and "x". Each result is longer than the last: the copying of the previous section.

On the first call, the JDK’s StringConcatFactory links the instruction to concatenation code of the JDK’s own choosing, so the strategy can improve with each JDK without recompiling your classes. In repeatX, the same instruction sits inside the loop: the goto 5 at offset 20 jumps back to the loop’s test, and every round runs the invokedynamic at offset 11 again, producing a new string from s and "x". javac does not turn the loop into one buffer for you, because each new string is assigned to s, where the next statement could read it, and the 5 GB that the same loop allocated in Growth.java show that the running JVM did not either. That is the case for StringBuilder.

Checkpoint

Check: One expression, one invokedynamic call

  1. Question 1 of 2 Which of these builds a new String in every round of its loop?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: for (String w : words) line += w;

    Only a concatenation that runs again in each round and keeps its result copies the text so far every time. The invokedynamic instruction in repeatX runs once per round, exactly like line += w.

  2. Question 2 of 2 How many invokedynamic instructions does javac write for return name + ", you have " + count + " messages";?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 1 instructions

    A whole concatenation expression becomes one invokedynamic instruction, as in label(), where city + " - " + pin was the single instruction at offset 2. The text between the values goes into its recipe, and each call builds one String.

A builder grows to twice its capacity plus 2

A builder’s capacity: How many chars a StringBuilder can hold before it needs a bigger buffer: 16 for new StringBuilder(), and the text's length plus 16 for new StringBuilder(text). length() is how many it holds now. is how many chars it can hold before it needs a bigger buffer, and its length is how many it holds now. This program appends 300 chars one at a time, prints the capacity each time it changes, and counts the chars that growing moved:

Capacity as a builder grows Java · Capacity.java
// How a StringBuilder grows: it starts with room for 16 chars and, when an append does not fit, moves its chars to a
// bigger buffer. The capacities printed here are this JDK's choices; the Javadoc of ensureCapacity documents the rule.
void main() {
    StringBuilder sb = new StringBuilder();
    int capacity = sb.capacity();
    int copied = 0;
    IO.println("length 0, capacity " + capacity);
    for (int i = 0; i < 300; i++) {
        sb.append('x');
        if (sb.capacity() != capacity) {
            copied += sb.length() - 1;                   // the chars that were in the old buffer moved across
            capacity = sb.capacity();
            IO.println("length " + sb.length() + ", capacity " + capacity);
        }
    }
    IO.println("300 appends: " + copied + " chars copied while growing");
    StringBuilder sized = new StringBuilder(300);
    IO.println("new StringBuilder(300): capacity " + sized.capacity() + " before any append");
    IO.println("new StringBuilder(\"idli\"): length 4, capacity " + new StringBuilder("idli").capacity());
}

Output

length 0, capacity 16
length 17, capacity 34
length 35, capacity 70
length 71, capacity 142
length 143, capacity 286
length 287, capacity 574
300 appends: 548 chars copied while growing
new StringBuilder(300): capacity 300 before any append
new StringBuilder("idli"): length 4, capacity 20

Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Capacity.java

The builder started with room for 16 chars. Appending the 17th moved the contents into a buffer of 34, the 35th into one of 70, and so on: each new capacity is twice the old one plus 2, the rule the Javadoc of ensureCapacity gives. Follow one append that finds room and one that does not:

Interactive · stage by stage One append, with room and without Step through the appends of Capacity.java where the builder runs out of room, and the ones between them.
One append, with room and without, step 5 of 5. Showing append('x'), is length < capacity? (34 < 34? no), grow: 2 x capacity + 2 (548 chars copied in all), write x, length + 1 (length 300 of 574). In focus: grow: 2 x capacity + 2.yes: room leftappend('x')one more char to addis length < capacity?34 < 34? nogrow: 2 x capacity + 2548 chars copied in allwrite x, length + 1length 300 of 574

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Append 16. new StringBuilder() has capacity 16. The 16th append finds length 15, so one slot is still unused: x goes into it and the length becomes 16. Nothing is copied.
  2. Append 17. The 17th append finds length 16 and capacity 16, so there is no room. The builder allocates 2 x 16 + 2 = 34 slots, copies its 16 chars across and then writes x.
  3. Appends 18 to 34. The next 17 appends all find room, so each one only writes its char. Nothing moves until the length reaches 34 and fills the 34 slots.
  4. Append 35. No room again: 2 x 34 + 2 = 70 slots, and the 34 chars move across before x is written. Each growth copies about twice as many chars as the one before.
  5. After 300 appends. The run grew at lengths 17, 35, 71, 143 and 287, to 574 slots, and copied 16 + 34 + 70 + 142 + 286 = 548 chars while growing: less than 2 x 300. new StringBuilder(300) would have copied none.

  • Growing copied 16 + 34 + 70 + 142 + 286 = 548 chars for 300 appends, as the run counted. Each copy is about twice the one before, so all of them together stay below twice the final length, which is why n appends cost about n.
  • When you know the final size, new StringBuilder(300) allocates it at once and nothing is ever copied.
  • new StringBuilder("idli") starts with the text and room for 16 more chars: capacity 20, the length of the argument plus 16, as the constructor’s Javadoc says.

Checkpoint

Check: How a builder's capacity grows

  1. Question 1 of 2 A new StringBuilder() receives 35 appends of one char each. What is its capacity afterwards?

    Type a number.

    A hint

    The capacity changes only when an append finds no room. Start from 16.

    Show the answer: Question 1 of 2

    Answer: 70 chars

    It starts at 16. The 17th append grows it to 2 × 16 + 2 = 34, and the 35th to 2 × 34 + 2 = 70, as Capacity.java printed: length 35, capacity 70.

  2. Question 2 of 2 What is new StringBuilder("dosa").capacity()?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 20 chars

    A builder made from a string holds that string and room for 16 more chars: 4 + 16 = 20, the same as the run's new StringBuilder("idli"), whose text also has 4 chars.

Editing methods change the builder in place

A StringBuilder is a mutable sequence of chars. Its methods change it in place and return the builder itself, so calls can be chained. Editing.java builds a list of seats in a loop, then edits it six times:

Editing one builder Java · Editing.java
// A StringBuilder is a buffer of chars that you can change in place. Each method below edits the same builder.
void main() {
    StringBuilder sb = new StringBuilder();
    for (String seat : new String[] {"34", "35", "36"}) {
        sb.append("B2-").append(seat).append(", ");      // append returns the builder, so calls chain
    }
    IO.println("after the loop:      [" + sb + "]");

    sb.setLength(sb.length() - 2);                        // drop the last ", "
    IO.println("setLength(len - 2):  [" + sb + "]");

    sb.insert(0, "Seats: ");
    IO.println("insert(0, ...):      [" + sb + "]");

    sb.replace(7, 9, "A1");                              // chars 7 and 8 become "A1"
    IO.println("replace(7, 9, \"A1\"): [" + sb + "]");

    sb.setCharAt(sb.length() - 1, '7');
    sb.deleteCharAt(5);                                   // the ':'
    IO.println("setCharAt, deleteCharAt: [" + sb + "]");

    IO.println("reverse():           [" + new StringBuilder("stressed").reverse() + "]");

    sb.setLength(0);                                      // empty it, keep its buffer
    IO.println("setLength(0):        [" + sb + "], capacity still " + sb.capacity());
}

Output

after the loop:      [B2-34, B2-35, B2-36, ]
setLength(len - 2):  [B2-34, B2-35, B2-36]
insert(0, ...):      [Seats: B2-34, B2-35, B2-36]
replace(7, 9, "A1"): [Seats: A1-34, B2-35, B2-36]
setCharAt, deleteCharAt: [Seats A1-34, B2-35, B2-37]
reverse():           [desserts]
setLength(0):        [], capacity still 34

Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Editing.java

Step through the same edits, with the builder’s length and capacity after each one:

Interactive · step by step One builder, edited in place Step through the calls of Editing.java. The builder is the same object throughout; only its chars and its length change.
One builder, edited in place, step 6 of 6. Showing the call (setLength(0)), the builder ([]), length, capacity (length 0, capacity 34). In focus: the builder, length, capacity.changes it in placethe callsetLength(0)the builder[]length, capacitylength 0, capacity 34

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The loop. Three rounds of sb.append("B2-").append(seat).append(", ") fill the builder. Each append returns the builder itself, which is why the calls chain. The 17th char did not fit the 16 slots, so the capacity is now 34.
  2. setLength. setLength(sb.length() - 2) cuts the builder to 19 chars, which drops the last comma and space. No char is copied: only the length changes.
  3. insert. insert(0, "Seats: ") moves all 19 chars 7 places to the right and writes the 7 new ones in front. The length becomes 26, which still fits the 34 slots.
  4. replace. replace(7, 9, "A1") takes positions like substring: it starts at 7 and stops before 9. The two chars there, B and 2, become A and 1, so the first seat is A1-34.
  5. setCharAt, deleteCharAt. setCharAt(length() - 1, '7') overwrites the last char, 6. deleteCharAt(5) removes the colon at position 5 and moves every later char one place left, so the length is 25.
  6. setLength(0). setLength(0) empties the builder but keeps its 34 slots, so a loop can reuse it for the next line without allocating again.

  • setLength(sb.length() - 2) is the tidy way to drop the separator after the last item; setLength(0) empties the builder but keeps its buffer, so a loop can reuse one builder.
  • insert(offset, text) and replace(start, end, text) take positions like substring: the start is included and the end is not. setCharAt overwrites one char and deleteCharAt removes one, moving the rest left.
  • reverse() reverses the chars, so "stressed" becomes "desserts".

Every edit after the loop ran in the 34 slots the builder already had, so none of them allocated a buffer.

Checkpoint

Check: Editing a builder in place

  1. Question 1 of 2 What does this print?

    Read the code, then choose one answer.

    StringBuilder sb = new StringBuilder("B2-34");
    sb.replace(0, 2, "A1");
    IO.println(sb);
    Show the answer: Question 1 of 2

    Answer: A1-34

    Positions work as in substring: the start is included and the end is not. The run's replace(7, 9, "A1") changed the two chars at 7 and 8 in the same way.

  2. Question 2 of 2 What does sb.setLength(0) do to a builder with length 26 and capacity 34?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It empties the builder and keeps its 34 slots for the next text

    setLength(0) is the quick way to reuse one builder for many lines: the chars are dropped and the buffer stays.

StringJoiner puts separators only between items

A loop that appends an item and a comma, then removes the last comma, is common and easy to get wrong. StringJoiner remembers a separator, a prefix and a suffix and adds the separator only between items, so there is nothing to delete afterwards:

StringJoiner, Collectors.joining and String.join Java · Joining.java
// StringJoiner remembers a separator, a prefix and a suffix, and what to print when nothing was added.
void main() {
    StringJoiner seats = new StringJoiner(", ", "[", "]");
    seats.setEmptyValue("(no seats)");
    IO.println("before any add: " + seats);
    seats.add("B2-34").add("B2-35");
    seats.add("B2-36");
    IO.println("after three:    " + seats + ", length " + seats.length());

    StringJoiner wait = new StringJoiner(", ", "Waiting list: ", ".");
    IO.println("no setEmptyValue: \"" + wait + "\"");

    IO.println(Stream.of("WL 1", "WL 2").collect(Collectors.joining(", ", "Waiting list: ", ".")));
    IO.println(String.join(", ", List.of("B2-34", "B2-35", "B2-36")));
}

Output

before any add: (no seats)
after three:    [B2-34, B2-35, B2-36], length 21
no setEmptyValue: "Waiting list: ."
Waiting list: WL 1, WL 2.
B2-34, B2-35, B2-36

Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Joining.java

setEmptyValue decides what an empty joiner prints: without it, the result is the prefix and the suffix with nothing between them, "Waiting list: .", which reads like a bug. The three ways of joining suit three situations, and the run printed one line for each:

The items Join them with The run printed
a list or an array you already have String.join(", ", list) B2-34, B2-35, B2-36
a stream Collectors.joining(", ", prefix, suffix) Waiting list: WL 1, WL 2.
items that arrive one by one in a loop a StringJoiner and its add [B2-34, B2-35, B2-36]

An empty list deserves its own text

Call setEmptyValue whenever the list can be empty, as the seat list does with "(no seats)", so the empty case reads as a message instead of a stray prefix and suffix.

Comma Separated List Converter Turn a column of names into a comma-separated list, the job of String.join and StringJoiner, without writing code.

Checkpoint

Check: Separators with StringJoiner

  1. Question 1 of 2 What does this print?

    Read the code, then choose one answer.

    StringJoiner j = new StringJoiner(", ", "[", "]");
    IO.println(j);
    Show the answer: Question 1 of 2

    Answer: []

    The run's waiting list shows the same thing: without setEmptyValue it printed "Waiting list: .". Set an empty value whenever the list can be empty.

  2. Question 2 of 2 A loop reads train numbers and keeps only those still running, then prints them as Running: 12127, 22105., or Running: none. when none are left. Which tool fits best?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A StringJoiner(", ", "Running: ", ".") with setEmptyValue("Running: none.")

    Choose by where the items come from: a list or an array you already have (String.join), a stream (Collectors.joining), or a loop that decides item by item (StringJoiner).

Three traps: a char, equals and half an emoji

Each trap compiles without a warning and fails only when the program runs. The program shows all three:

A char argument, equals, and half an emoji Java · Traps.java
// Three ways a StringBuilder surprises people.
void main() {
    // 1. There is no constructor that takes a char: 'a' is widened to the int 97 and becomes the capacity.
    StringBuilder fromChar = new StringBuilder('a');
    fromChar.append("bc");
    IO.println("new StringBuilder('a').append(\"bc\") is \"" + fromChar + "\", capacity was " + new StringBuilder('a').capacity());

    // 2. StringBuilder does not override equals: two builders with the same chars are not equal.
    StringBuilder x = new StringBuilder("idli");
    StringBuilder y = new StringBuilder("idli");
    IO.println("x.equals(y): " + x.equals(y) + ", x.compareTo(y) == 0: " + (x.compareTo(y) == 0)
            + ", \"idli\".contentEquals(x): " + "idli".contentEquals(x) + ", x.toString().equals(y.toString()): " + x.toString().equals(y.toString()));

    // 3. deleteCharAt removes one char, which is half of an emoji.
    StringBuilder thanks = new StringBuilder("Thanks 🙏");
    thanks.deleteCharAt(thanks.length() - 1);
    String left = thanks.toString();
    IO.println("after deleteCharAt: length " + left.length() + ", last char U+" + Integer.toHexString(left.charAt(left.length() - 1)).toUpperCase()
            + ", a lone surrogate: " + Character.isHighSurrogate(left.charAt(left.length() - 1)));
}

Output

new StringBuilder('a').append("bc") is "bc", capacity was 97
x.equals(y): false, x.compareTo(y) == 0: true, "idli".contentEquals(x): true, x.toString().equals(y.toString()): true
after deleteCharAt: length 8, last char U+D83D, a lone surrogate: true

Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Traps.java

  1. A char is not text here. StringBuilder has constructors for an int capacity, a String and a CharSequence, but none for a char. So new StringBuilder('a') widens 'a' to the int 97 and makes an empty builder with capacity 97, and the "a" never appears. Write new StringBuilder("a").
  2. Two builders are never equal. StringBuilder does not override equals, so x.equals(y) compares identities and is false for two builders with the same chars. Compare contents with x.compareTo(y) == 0 (Java 11), "idli".contentEquals(x) or x.toString().equals(y.toString()), and never use a builder as a key of a HashMap.
  3. One char is not one character. deleteCharAt removes a single char, and the emoji 🙏 takes two of them, a surrogate pair: Two UTF-16 code units, a high one from D800 to DBFF and a low one from DC00 to DFFF, that together encode one character beyond U+FFFF, such as an emoji. length() counts both; codePointCount counts one. More in “char, boolean, casting and conversions”, so deleting the last char left half of it: a lone high surrogate, U+D83D. reverse() takes care to keep such pairs together, but it reverses everything else char by char, which still breaks emoji with skin tones and words in Indian scripts. The module’s lesson on Unicode and Indian scripts shows how to work with what a reader sees as one character.

The last chars of "Thanks 🙏" show the third trap, position by position:

Position 6 7 8
In "Thanks 🙏" a space U+D83D U+DE4F
After deleteCharAt(8) a space U+D83D (gone)
Reverse Text, Mirror & Upside Down Reverse a word such as नमस्ते or an emoji with a skin tone: the tool keeps each visible character whole, which StringBuilder.reverse() does not.

Checkpoint

Check: The char, equals and surrogate traps

  1. Question 1 of 2 A HashSet<StringBuilder> holds a builder with the chars idli. Does set.contains(new StringBuilder("idli")) find it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: No, because StringBuilder keeps the equals and hashCode of Object, which compare identities

    Never use a builder as a key or a set element. Store builder.toString(), a String, which compares its chars.

  2. Question 2 of 2 StringBuilder sb = new StringBuilder("Hi 👍"); What does sb.deleteCharAt(sb.length() - 1) leave?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Hi, a space and a lone high surrogate, half of the emoji

    The length goes from 5 to 4 and the last char is U+D83D, the same high surrogate the run's Thanks 🙏 ended with. The module's Unicode lesson removes whole characters instead.

StringBuffer locks each call, not each line

StringBuffer is the original class from Java 1.0 and has the same methods, but it Declared synchronized: a thread must hold the object's lock to run the method, so calls on one object from different threads run one at a time. Each call is protected on its own; a sequence of calls is not. them, so that the calls made on one buffer behave as if they ran one after another, whichever threads make them. StringBuilder arrived in Java 5 for the usual case of one thread: its documentation says outright that it is not safe for use by several threads at once, and recommends StringBuffer when synchronisation is needed.

That recommendation is rarely the right design, though. Synchronising each call does not make a sequence of calls atomic: two threads that each run buf.append(name).append(", ") can still interleave their calls and produce "AshaRavi, , ". Step through that order, then the fix:

Interactive · message by message Two threads, one StringBuffer Step through the calls in the order Interleave.java forces. Each call takes the buffer's lock; watch what the lock does not protect.
Two threads, one StringBuffer, step 6 of 6. Messages so far: thread A to shared StringBuffer: append("Asha"); thread B to shared StringBuffer: append("Ravi"); thread A to shared StringBuffer: append(", "); thread B to shared StringBuffer: append(", "); thread A to shared StringBuffer: append("Asha, "); thread B to shared StringBuffer: append("Ravi, ").thread Aadds "Asha, "shared StringBufferAsha, Ravi, thread Badds "Ravi, "append("Asha")append("Ravi")append(", ")append(", ")append("Asha, ")append("Ravi, ")

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A appends its name. Thread A calls append("Asha"). The call holds the buffer's lock until it returns, so no other call can run on the buffer at the same time.
  2. B gets in between. Before A makes its second call, thread B takes the lock and appends its own name. That call is just as safe as A's was: one whole call, then the next.
  3. A appends its separator. Thread A now adds the comma and space that should have followed Asha. They land after Ravi, because the buffer only keeps the order in which calls arrive.
  4. B appends its separator. Thread B adds its comma and space. Every call was synchronised, and no char was lost, yet both lines are broken: the buffer holds AshaRavi, , as the run printed.
  5. The fix, A's line. Each thread builds its line in a StringBuilder of its own, which no other thread can see, and hands over the finished line in one call.
  6. The fix, B's line. The calls may still arrive in any order, but a line is now one call, so it can never be split: the buffer holds Asha, Ravi, as the run's second line shows.

Interleave.java runs the same two threads for real. Three A java.util.concurrent counter that threads wait on with await() until other threads have called countDown() enough times. Here each one makes a thread wait for the call before it. force the order of the figure, which a busy computer can also produce by itself, then the second half hands over finished lines:

Four synchronised calls, two broken lines Java · Interleave.java
import java.util.concurrent.CountDownLatch;

// Two threads each add one line, "name, ", to a shared StringBuffer. Every call of a StringBuffer is synchronised,
// but a line built with two appends is two calls. The latches below force the order a busy computer sometimes
// produces on its own: A's name, B's name, A's ", ", B's ", ". Then the same order, with whole lines.
void main() throws InterruptedException {
    StringBuffer shared = new StringBuffer();
    CountDownLatch aNamed = new CountDownLatch(1);
    CountDownLatch bNamed = new CountDownLatch(1);
    CountDownLatch aDone = new CountDownLatch(1);
    Thread a = Thread.ofPlatform().start(() -> {
        shared.append("Asha");
        aNamed.countDown();
        await(bNamed);
        shared.append(", ");
        aDone.countDown();
    });
    Thread b = Thread.ofPlatform().start(() -> {
        await(aNamed);
        shared.append("Ravi");
        bNamed.countDown();
        await(aDone);
        shared.append(", ");
    });
    a.join();
    b.join();
    IO.println("two appends per line, one shared StringBuffer: [" + shared + "]");

    // The fix: each thread builds its line in a StringBuilder of its own, then hands over the finished line in one call.
    StringBuffer lines = new StringBuffer();
    CountDownLatch aLine = new CountDownLatch(1);
    Thread c = Thread.ofPlatform().start(() -> {
        String line = new StringBuilder().append("Asha").append(", ").toString();
        lines.append(line);
        aLine.countDown();
    });
    Thread d = Thread.ofPlatform().start(() -> {
        String line = new StringBuilder().append("Ravi").append(", ").toString();
        await(aLine);
        lines.append(line);
    });
    c.join();
    d.join();
    IO.println("one append per finished line:                [" + lines + "]");
}

void await(CountDownLatch latch) {
    try {
        latch.await();
    } catch (InterruptedException e) {
        throw new IllegalStateException(e);
    }
}

Output

two appends per line, one shared StringBuffer: [AshaRavi, , ]
one append per finished line:                [Asha, Ravi, ]

Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Interleave.java

Give each thread its own StringBuilder and combine the finished strings, or hand complete lines to something built for sharing, such as a concurrent queue or a logging library. The concurrency module covers both.

Key idea

A lock on each call keeps every call whole but not every line: build a line where only one thread can see it, and hand it over in one call.

Checkpoint

Check: StringBuffer and threads

  1. Question 1 of 2 Two threads each run buf.append(name).append(";") on one shared StringBuffer, with the names Asha and Ravi. Which results are possible?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Asha;Ravi; and Ravi;Asha;, but also AshaRavi;;

    The figure and Interleave.java show the order that turns two lines into AshaRavi, , . Build each line in a StringBuilder that only its thread uses, and append the finished line in one call.

  2. Question 2 of 2 A server builds one HTML page for each request it handles, often several at once. Where should the StringBuilder for a page live?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: In a local variable of the method that builds the page

    A builder that only one thread can reach needs no locking at all. Share finished Strings, which never change, not the builder that makes them.

Practise: run-length encoding

compress("aaabcc") should return "a3b1c2": each run of one character becomes the character and the length of the run, which is called run-length encoding: Writing each run of one repeated character as the character and the length of the run, so aaabcc becomes a3b1c2. It shortens text with long runs and lengthens text without them.. The last check compresses 400,000 characters and must finish within a second. As it starts, compress returns an empty string, so every check fails except the one for empty input:

The starting point Java · Compress.java
// Your turn: run-length encoding. compress("aaabcc") is "a3b1c2": each run of one character becomes the character and
// the length of the run. Build the result with a StringBuilder, so that the method stays fast on long inputs.
String compress(String s) {
    return "";   // Write your code here.
}

int failed = 0;
int total = 0;

void check(String input, String expected) {
    total++;
    String got = compress(input);
    if (!got.equals(expected)) {
        failed++;
        IO.println("FAIL compress(\"" + input + "\"): expected \"" + expected + "\", got \"" + got + "\"");
    }
}

void main() {
    check("aaabcc", "a3b1c2");
    check("abc", "a1b1c1");
    check("zzzzzzzzzzzz", "z12");
    check("aabbaa", "a2b2a2");
    check("a", "a1");
    check("", "");

    // 400,000 characters with no repeats: the answer is 800,000 characters long, and it must arrive within a second.
    total++;
    String input = "ab".repeat(200_000);
    long start = System.nanoTime();
    String got = compress(input);
    long millis = (System.nanoTime() - start) / 1_000_000;
    if (!got.equals("a1b1".repeat(200_000))) {
        failed++;
        IO.println("FAIL compress of 400,000 characters: wrong result (length " + got.length() + ")");
    } else if (millis > 1_000) {
        failed++;
        IO.println("FAIL compress of 400,000 characters: right, but it took " + millis + " ms");
    }

    IO.println(failed + " of " + total + " checks failed");
    if (failed > 0) {
        System.exit(1);
    }
}

Output (exit status 1)

FAIL compress("aaabcc"): expected "a3b1c2", got ""
FAIL compress("abc"): expected "a1b1c1", got ""
FAIL compress("zzzzzzzzzzzz"): expected "z12", got ""
FAIL compress("aabbaa"): expected "a2b2a2", got ""
FAIL compress("a"): expected "a1", got ""
FAIL compress of 400,000 characters: wrong result (length 0)
6 of 7 checks failed

Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. To run it yourself: java Compress.java

You are done when it prints 0 of 7 checks failed. Hints: walk the string with an index i, move a second index j forward while s.charAt(j) == s.charAt(i), append the character and j - i, then continue from j. Appending an int writes its digits, so out.append(c).append(j - i) needs no conversion. Try the same method with += too: the last check fails, because the result is 800,000 characters long.

Then test yourself on the seven ideas, from what += copies to what a lock protects. Practice asks about your weakest idea first, and each choice says why it fits or misses:

Practice that adapts to you

Practise this lesson

17 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 17 String s = ""; then s += "x"; runs 1,000 times. How many characters are copied in total, counting each character every time it is copied into a new string?

    Type a number.

    Show the answer: Question 1 of 17

    Answer: 500500 chars

    Round i builds a new string of i characters, so the rounds copy 1 + 2 + … + 1,000 = 1,000 × 1,001 / 2 = 500,500 characters. For 100,000 rounds it is about 5 billion, which is why the measured allocation grows four times when n doubles.

  2. Question 2 of 17 Why can s += "x" not simply add a char to the string that s refers to?

    Choose one answer.

    Show the answer: Question 2 of 17

    Answer: A String never changes, so += builds a new string and makes s refer to it

    Immutability is the reason: += on a String always means a new String with the old chars copied into it.

  3. Question 3 of 17 Why does the lesson read the memory column of Growth.java rather than its times?

    Choose one answer.

    Show the answer: Question 3 of 17

    Answer: The memory figures are the same on every run, while the times change with the computer's load, the garbage collector and the JIT compiler

    A careful timing needs a benchmark harness such as JMH. The allocation, counted by the JVM itself, is exact and repeatable, and it shows the n² curve directly.

  4. Question 4 of 17 Which of these is fine to leave as it is?

    Choose one answer.

    Show the answer: Question 4 of 17

    Answer: return city + " - " + pin;

    A single expression compiles to one invokedynamic call that builds one result. The three loops run += once per round, so each round copies everything built so far into a new string; they need a StringBuilder (or, for text going to a file, a Writer).

  5. Question 5 of 17 What did javac write for one + expression before JDK 9?

    Choose one answer.

    Show the answer: Question 5 of 17

    Answer: A chain of StringBuilder.append calls, then toString()

    Before JDK 9 javac wrote new StringBuilder().append(…).append(…).toString() for each expression. Since JDK 9 it writes one invokedynamic call, and the JDK decides how to join.

  6. Question 6 of 17 A new StringBuilder() receives 17 appends of one char each. What is its capacity afterwards?

    Type a number.

    Show the answer: Question 6 of 17

    Answer: 34 chars

    It starts with room for 16 chars. The 17th append does not fit, so the builder moves its contents to a new buffer of twice the old capacity plus 2: 16 × 2 + 2 = 34.

  7. Question 7 of 17 A full StringBuilder of capacity c needs room for one more char. What capacity does it grow to?

    Choose one answer.

    Show the answer: Question 7 of 17

    Answer: 2c + 2

    Doubling is what keeps appends cheap: each growth copies about twice as much as the last, so all the copies together stay below twice the final length.

  8. Question 8 of 17 new StringBuilder(300) then receives 300 appends of one char each. How many chars are copied while it grows?

    Type a number.

    Show the answer: Question 8 of 17

    Answer: 0 chars

    Its capacity is 300 from the start, so every append finds room and nothing is ever copied. Without the 300, the run copied 548 chars while growing to 574.

  9. Question 9 of 17 What does this print?

    Read the code, then choose one answer.

    StringBuilder sb = new StringBuilder("idli vada");
    sb.insert(5, "dosa ");
    IO.println(sb);
    Show the answer: Question 9 of 17

    Answer: idli dosa vada

    i, d, l and i are positions 0 to 3, the space is 4 and the v is 5. insert moves the chars from position 5 on to the right and writes the new ones in front of them.

  10. Question 10 of 17 Why can calls such as sb.append(a).append(b) be chained?

    Choose one answer.

    Show the answer: Question 10 of 17

    Answer: Each append changes the builder and returns that same builder

    The methods that edit a builder, such as append, insert, replace and reverse, return the builder itself, so one statement can make several edits.

  11. Question 11 of 17 stations is a Stream<String> of Pune, Lonavala and Mumbai. Which call gives Pune, Lonavala, Mumbai?

    Choose one answer.

    Show the answer: Question 11 of 17

    Answer: stations.collect(Collectors.joining(", "))

    String.join for an array or a list you have, Collectors.joining for a stream, StringJoiner for items that arrive one at a time.

  12. Question 12 of 17 What does setEmptyValue of a StringJoiner decide?

    Choose one answer.

    Show the answer: Question 12 of 17

    Answer: What the joiner prints while nothing has been added to it

    Without an empty value, a joiner with no items prints its prefix and suffix, as the run's "Waiting list: ." did.

  13. Question 13 of 17 What is new StringBuilder('A').length()?

    Type a number.

    Show the answer: Question 13 of 17

    Answer: 0 chars

    There is no constructor that takes a char, so 'A' is widened to the int 65 and used as the capacity. The builder is empty: its length is 0 and its capacity 65. Write new StringBuilder("A") to start with the letter.

  14. Question 14 of 17 StringBuilder a = new StringBuilder("idli"); StringBuilder b = new StringBuilder("idli"); Which expression is true?

    Choose one answer.

    Show the answer: Question 14 of 17

    Answer: a.compareTo(b) == 0

    StringBuilder does not override equals, so a.equals(b) compares identities, like a == b. Since Java 11 it implements Comparable, and compareTo compares the chars. Two toString() calls create two different String objects, so == between them is false; compare them with equals.

  15. Question 15 of 17 How many chars does the emoji 🙏 take in a Java string?

    Type a number.

    Show the answer: Question 15 of 17

    Answer: 2 chars

    Its code point, U+1F64F, lies above U+FFFF, so UTF-16 writes it as a surrogate pair: two chars, U+D83D and U+DE4F. That is why deleting one char of it leaves half an emoji.

  16. Question 16 of 17 What does the StringBuilder documentation say about threads?

    Choose one answer.

    Show the answer: Question 16 of 17

    Answer: Its instances are not safe for use by several threads, and StringBuffer is recommended if synchronisation is needed

    StringBuilder came in Java 5 for the usual case of one thread, without locking. StringBuffer, from Java 1.0, has the same methods and synchronises them.

  17. Question 17 of 17 Does a StringBuffer make buf.append(a).append(b) atomic?

    Choose one answer.

    Show the answer: Question 17 of 17

    Answer: No. Each call is synchronised on its own, so another thread's call can run between the two

    To keep a line whole, build it where only one thread can see it and hand it over in one call, as the second half of Interleave.java does.

Interview questions

Fresher · What is the difference between String, StringBuilder and StringBuffer?

A String is immutable: every method that seems to change it returns a new string, which makes strings safe to share and to use as keys. StringBuilder and StringBuffer are mutable buffers of chars with the same methods, such as append, insert, delete and reverse, for building or editing text without creating a new object for each step. The difference between those two is synchronisation: StringBuffer synchronises its methods, while StringBuilder (added in Java 5) has no locking and is meant for one thread, which makes it the usual choice. A good answer adds when each is needed: one + expression is already compiled efficiently, += in a loop copies the whole text every round, and StringBuffer is mostly found in old code, because sharing one buffer between threads is rarely a good design anyway.

An interviewer who hears that answer often tests it on a line of code or a design choice. Try these two before you read the explanations:

Checkpoint

Interview round: two follow-up questions

  1. Question 1 of 2 An interviewer shows String msg = "Hi " + name + ", you have " + n + " new messages"; and asks whether to rewrite it with a StringBuilder for speed. Which answer is strongest?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: No. One expression compiles to a single invokedynamic call that builds the string once, so a builder adds code and saves nothing

    The rule is about growing one string across rounds, not about the + sign: one expression is one result, and += in a loop is the case for a builder.

  2. Question 2 of 2 The interviewer goes on: if immutability is what makes += in a loop slow, why is String immutable at all? Which answer is strongest?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: So that one String can be shared by any number of variables and threads, and used as a map key, without anyone changing it; text being built goes into a StringBuilder

    A String handed to a map, a cache or another thread cannot change under it. Build text in a StringBuilder, then share the finished String.

The module bank holds two longer questions on the same ground, a nightly report that takes 40 minutes to build and audit lines that come out merged:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A nightly job builds a 40 MB CSV report with report += line + "\n" for 500,000 lines of about 80 characters and takes 40 minutes, most of it in garbage collection. Explain the cost with numbers, fix it, and say whether the fix should keep the whole report in memory.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    Worker threads append audit lines to one static StringBuilder that is written to a file every minute. The file sometimes has two lines merged into one or a line missing, and once the job died with ArrayIndexOutOfBoundsException. Explain what happens and how you would redesign it.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • One expression joined with + compiles to a single invokedynamic call (JEP 280) and builds its result once.
  • += in a loop copies everything built so far in every round: n(n + 1) / 2 chars, which was 20 GB of allocation for a 200,000-character string in the recorded run.
  • A StringBuilder grows to twice its capacity plus 2 when it is full, so n appends copy fewer than 2n chars; presize it with new StringBuilder(capacity) when you know the length.
  • setLength, insert, replace, setCharAt, deleteCharAt and reverse edit the builder in place, and StringJoiner, String.join and Collectors.joining put separators only between items.
  • new StringBuilder('a') sets a capacity of 97, builders do not override equals, and deleteCharAt can split an emoji in half.
  • StringBuffer synchronises each call, which does not make a sequence of calls safe: give each thread its own builder and hand over finished lines.

Terms in this lesson

capacity
How many chars a StringBuilder can hold before it needs a bigger buffer: 16 for new StringBuilder(), and the text's length plus 16 for new StringBuilder(text). length() is how many it holds now.
compact strings
How the JVM stores a String since JDK 9 (JEP 254): one byte per char when every char fits in Latin-1, and two bytes per char, as UTF-16, otherwise.
invokedynamic
A bytecode instruction whose target the JVM links the first time it runs, by calling a bootstrap method. Since JDK 9 javac compiles each string concatenation expression to one, which StringConcatFactory links.
quadratic growth
Work that grows with the square of the input: twice the input, about four times the work. Building a string of n chars with += in a loop copies n(n + 1) / 2 chars, which is quadratic.
run-length encoding
Writing each run of one repeated character as the character and the length of the run, so aaabcc becomes a3b1c2. It shortens text with long runs and lengthens text without them.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.