Layers of Logic

9.2

String Methods and StringBuilder

Fifty methods you do not need to memorise, and one class you do. Joining text in a loop is the commonest slow thing in beginner Java, and the fix is one line.

Core17 min read4 exercises
01

Previously on

Section 9.1 showed you that a String can never change, and that this is what makes the String Pool possible.

It also named the cost. Every method that appears to modify a String actually builds a new one. That cost is small when it happens once. This section is what happens when it happens forty thousand times.

02

The problem

Build one line of text out of many pieces. Reading a file, joining names, assembling a report.

String result = "";
for (int i = 0; i < 40000; i++) {
    result += "x";
}

That works. It also takes about 71 milliseconds on an ordinary machine.

Here is the same job done another way:

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

About 1 millisecond.

Same output. Seventy times the speed. Nothing about the first version looks wrong, which is why this mistake is everywhere.

03

The idea

Why + in a loop is so slow

A String cannot change. So result += "x" cannot add anything to result. It has to:

What one += really does

  1. Make a new char arrayBig enough for both pieces.
  2. Copy the whole old String into itEvery character, one at a time.
  3. Copy the new piece in after itThen wrap the array in a new String object.
  4. Point result at the new objectThe old String is now garbage.

Look at step two. On pass one you copy 1 character. On pass two, 2. On pass 40,000, you copy 40,000 characters just to add one more.

Add all of that up and you have copied about 800 million characters to build a 40,000 character String. You also created 40,000 String objects, and threw away 39,999 of them.

What StringBuilder does instead

StringBuilder is a mutable String. It keeps one char array and writes into it.

StringBuilder sb = new StringBuilder();
sb.append("hello");        // writes into the array. No new object.
sb.append(" world");       // writes further along the same array.
String result = sb.toString();   // one String, made once, at the end.

When the array fills up, it makes a bigger one and copies across. That happens a handful of times, not 40,000, because each new array is roughly double the last.

StringStringBuilder
Can it changeno, neveryes, in place
Joining in a loopa new object every passwrites into one array
40,000 piecesabout 71 msabout 1 ms
Safe to shareyes, it cannot changeno
Use it forstoring and passing textbuilding text
04

Under the hood

Core

The methods, in groups

There are about fifty. You do not memorise them. You learn what kinds of thing exist, then let your editor list them when you type a dot.

What you want to doMethods
How long is itsize and emptinesslength(), isEmpty(), isBlank()
Get one characterby positioncharAt(i), toCharArray()
Are these the samecomparisonequals(), equalsIgnoreCase(), compareTo()
Is this inside itsearchingcontains(), indexOf(), startsWith(), endsWith()
Give me part of itextractionsubstring(), split(), lines()
Change its shapeconversiontoUpperCase(), toLowerCase(), trim(), strip(), replace()
Build onejoiningconcat(), join(), repeat(), format()

Every one of the conversion methods returns a new String. None of them change the one you called them on.

Three that trip people up

length() has brackets. length does not.

int[] numbers = new int[5];
numbers.length      // a field. No brackets.

String name = "Atlas";
name.length()       // a method. Brackets.

There is no good reason. An array is built by the JVM and its size is part of its structure. A String is an ordinary class, so everything it offers has to be a method. It is history, and it will catch you.

isEmpty() and isBlank() ask different questions.

"   ".isEmpty()     // false. Length is 3.
"   ".isBlank()     // true.  Nothing but whitespace.
"".isEmpty()        // true
"".isBlank()        // true

For real user input, isBlank() is almost always the one you want. Somebody who presses space and then Enter has given you something that is not empty and is not useful.

substring takes a start and an end, and the end is not included.

"Aditya".substring(1, 4)     // "dit"

Positions 1, 2 and 3. Not 4. It is the same start-at-0-and-stop-before convention as for (int i = 0; i < n; i++) from Section 3.3, and it is consistent across Java once you see it.

StringBuilder and StringBuffer

Java has two mutable String classes. They have the same methods, the same constructors, and the same parent class, AbstractStringBuilder.

One difference:

StringBuilderStringBuffer
Thread safenoyes, every method is synchronized
Speedfasterslower, it pays for the locking
Arrived inJava 5Java 1.0
Use italmost alwaysalmost never

StringBuffer was written first, when Java made everything thread safe by default. StringBuilder was added later, without the locking, because it turned out that almost nobody shares a text builder between threads. A builder is nearly always a local variable inside one method.

So the rule is: use StringBuilder. If you ever think you need StringBuffer, first ask why a builder is being shared across threads at all. Usually that design is the real problem.

Useful things about StringBuilder

Methods chain, because each one returns the builder itself:

String s = new StringBuilder()
        .append("hello")
        .insert(0, ">> ")
        .reverse()
        .toString();

It starts with room for 16 characters and grows from there. If you know roughly how much you need, say so and skip some of the copying:

StringBuilder sb = new StringBuilder(1000);

+ outside a loop is fine. The compiler already turns "a" + b + "c" into a StringBuilder for you. The problem is only a loop, because there the compiler has to create a fresh builder on every pass.

05

What it costs

StringBuilder gives up everything immutability bought you. It is not safe to share between threads, it is not safe as a map key, and anyone holding a reference can change it under you.

It is also an extra object and an extra step. For joining three pieces of text, + is clearer and the difference is nothing.

Fifty methods is a lot to have available. Most people use ten of them, and the rest sit there making the documentation long. You are not meant to know them all, only to know that they probably exist.

And split has a sharp edge. It takes a regular expression, not plain text, so splitting on a dot needs split("\\."). A plain split(".") matches every character and gives you nothing.

What you get is a way to build text without paying for immutability on every step, while keeping immutability everywhere the text is stored or passed around. Build with a StringBuilder, call toString() once, and hand out something nobody can change.

06

Check yourself

Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.

  1. Why is joining 40,000 pieces of text with `+` so much slower than using a `StringBuilder`?

    Show the answer

    Because a String cannot change. Every + builds a brand new String and copies both sides into it.

    On pass one you copy 1 character. On pass two, 2. On pass 40,000, you copy 40,000. The total work grows with the square of the number of pieces, and every one of those Strings becomes garbage a moment later.

    A StringBuilder keeps one array and writes into it. When the array fills up it makes a bigger one and copies over, the same doubling you built by hand in the Section 4.2 exercises. That happens a handful of times, not 40,000.

    Measured on a normal machine: about 71 ms against about 1 ms.

  2. `" ".isEmpty()` is false but `" ".isBlank()` is true. What is the difference?

    Show the answer

    isEmpty() asks one question: is the length zero? Three spaces have a length of three, so the answer is no.

    isBlank() asks a different question: is there anything here other than whitespace? Three spaces are all whitespace, so the answer is yes, it is blank.

    This matters for real input. A user who presses space and then Enter has given you something that is not empty and is not useful. isBlank() catches that. isEmpty() does not.

    isBlank() arrived in Java 11. Before that people wrote s.trim().isEmpty(), which does the same job with an extra object.

  3. `StringBuilder` and `StringBuffer` have the same methods. When would you pick the second one?

    Show the answer

    Almost never, and the reason is worth knowing.

    The only difference is that every StringBuffer method is synchronized, which makes it safe when several threads use the same object at once. StringBuilder is not, and is faster because of it.

    The catch is that sharing one text builder across threads is rare. Almost always a builder is a local variable inside one method, used by one thread, and the locking is pure cost.

    Modern code that does need thread safety usually controls it deliberately, with locks around the section that needs them, rather than hoping a single class handles it. So the practical rule is: use StringBuilder. Reach for StringBuffer only when a builder is genuinely shared, which is a design you should question first.

07

Exercises

Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.

4 exercises85 pointsabout 100 minutes

The Layers of Logic VS Code extension runs the checks for exercises marked checked. For a manual exercise, run the program and compare its behaviour with the stated requirements and sample output.
A

Measure the 71x

Real work·25 min·20 points

ex-9-2-a

Measure the claim rather than trusting it.

Run all three sizes and write the numbers down. Then look at what happens between 20,000 and 40,000: one column roughly doubles, the other roughly quadruples. That difference in shape is more important than any single measurement.

Work out by hand how many characters the += version copies in total to build a 40,000 character String. The number is large, and it explains the whole gap.

Then say why the builder barely notices. The answer is the growing array from Section 4.2, which you have now met three times in three different disguises.

What your program must do

  • Run it and record all six timings
  • Double the input and check what happens to each column
  • Work out how many characters the += version copies in total for 40,000 pieces
  • Explain why the builder column barely moves
Joining.java
public class Joining {
    static long withPlus(int n) {
        long t0 = System.nanoTime();
        String s = "";
        for (int i = 0; i < n; i++) s += "x";
        if (s.length() == -1) System.out.print("");
        return (System.nanoTime() - t0) / 1_000_000;
    }

    static long withBuilder(int n) {
        long t0 = System.nanoTime();
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < n; i++) sb.append("x");
        String s = sb.toString();
        if (s.length() == -1) System.out.print("");
        return (System.nanoTime() - t0) / 1_000_000;
    }

    public static void main(String[] args) {
        for (int warm = 0; warm < 2; warm++) { withPlus(5000); withBuilder(5000); }
        for (int n : new int[]{10_000, 20_000, 40_000}) {
            System.out.printf("%6d pieces  +=%5d ms   builder=%3d ms%n",
                    n, withPlus(n), withBuilder(n));
        }
    }
}
Hint 1
Look at the shape, not the exact numbers. When you double the input, one column roughly doubles and the other roughly quadruples.
Hint 2
For the copy count: pass one copies 1 character, pass two copies 2, and so on up to 40,000. That sum is about n squared over 2, which is around 800 million.
Hint 3almost the answer
The builder writes into one array. It only copies when the array is full, and each new array is about double the last, so that happens around 12 times for 40,000 characters rather than 40,000 times.
What this is really testing

Whether the StringBuilder rule is a number you produced or advice you were given. The gap is large enough that you will never forget it once you have seen it.

B

isEmpty, isBlank, and Real Input

Warm up·20 min·15 points

ex-9-2-b

Five inputs, four checks, and they disagree in ways that matter for real users.

Predict the whole table before running. The tab row is the one people get wrong.

Then build the validation properly. Read a unit name, reject anything blank, and keep asking until you get something real. Store it trimmed, so " Atlas " and "Atlas" end up as the same name.

That trimming is not cosmetic. Two units whose names differ only by a space will look identical on screen and compare as different, and in Phase X that means two separate entries in a set that should hold one.

What your program must do

  • Predict the four columns for all five inputs before running
  • Say which check you would use to validate a unit name, and why
  • Read a name from the keyboard and reject blank input, looping until it is valid
  • Store the trimmed name and show that " Atlas " and "Atlas" become the same
Blank.java
import java.util.Scanner;

public class Blank {
    static void report(String label, String s) {
        System.out.printf("%-12s length=%d  isEmpty=%-5s  isBlank=%-5s  trim().isEmpty()=%s%n",
                label, s.length(), s.isEmpty(), s.isBlank(), s.trim().isEmpty());
    }

    public static void main(String[] args) {
        report("empty", "");
        report("spaces", "   ");
        report("tab", "\t");
        report("text", "atlas");
        report("padded", "  atlas  ");

        // TODO: read a unit name and reject it if the user gave nothing useful
        // TODO: store the name trimmed, and show the difference
    }
}
Hint 1
isEmpty() asks only about length. isBlank() asks whether there is anything other than whitespace.
Hint 2
For validation, isBlank() is the one you want. A user who presses space and Enter passes an isEmpty() check and has still given you nothing.
Hint 3almost the answer
Use a do-while loop, from Section 3.3. You have to read a value before you can tell whether it is valid, which is exactly the shape that loop is for.
What this is really testing

Whether you pick the right emptiness check for user input. A user who types a space has given you something that is not empty and is not useful.

C

Split Has a Sharp Edge

Real work·25 min·25 points

ex-9-2-c

split(".") returns an empty array. Nothing is thrown and nothing is printed.

Work out why before reading the hint. The clue is in the parameter type: split does not take plain text.

Then handle three real cases. Escaping a dot, keeping trailing empty pieces, and splitting on however many spaces happen to be there.

Every one of these turns up the first time you parse a real file, and every one fails quietly rather than loudly. That is what makes them worth meeting on purpose.

What your program must do

  • Explain why split(".") returns an empty array
  • Fix it so the version splits into three pieces
  • Show that trailing empty pieces are dropped, and keep them
  • Split on one or more spaces, whatever the count
Splitting.java
import java.util.Arrays;

public class Splitting {
    public static void main(String[] args) {
        String csv = "Atlas,101,88.5,true";
        System.out.println(Arrays.toString(csv.split(",")));

        String version = "1.2.3";
        System.out.println("split(\".\")  -> " + Arrays.toString(version.split(".")));
        // TODO: make this one work

        String messy = "a,b,,c,,";
        System.out.println("trailing gaps -> " + Arrays.toString(messy.split(",")));
        // TODO: keep the trailing empty pieces too

        // TODO: split a line on one or more spaces, however many there are
        String spaced = "Atlas    Beacon  Cipher";
    }
}
Hint 1
split takes a regular expression, not plain text. In a regex, . means any character, so every character is a separator and nothing survives.
Hint 2
Escape it: version.split("\\."). Two backslashes in Java source produce one backslash in the actual pattern.
Hint 3almost the answer
split drops trailing empty pieces by default. Pass a negative limit to keep them: messy.split(",", -1). For one or more spaces the pattern is " +", or "\\s+" to cover tabs as well.
What this is really testing

Whether you know that split takes a regular expression, not plain text. Splitting on a dot is the case that catches everybody, and it fails by returning nothing.

D

Registry Report Builder

Real work·30 min·25 points·The Registry

ex-9-2-d

Build a real report, the way you would in a real program.

Two thousand units, one line each, columns lined up. Use a StringBuilder and give it a sensible starting size.

Then write the same report with += and time both. The point is not that one is faster. It is that the slow version looks completely reasonable, and at five units it genuinely is fine.

That is why this bug survives review. It only shows up when the data grows, and by then nobody is looking at the loop that builds a report.

What your program must do

  • Build the report with a StringBuilder and print it
  • Write the same report with += and time both versions
  • Right align the numbers so the columns line up
  • Say why the += version is fine for 5 units and wrong for 2000
Report.java
public class Report {
    record Unit(String name, int id, double readiness, boolean active) { }

    static Unit[] roster() {
        Unit[] units = new Unit[2000];
        for (int i = 0; i < units.length; i++)
            units[i] = new Unit("Unit-" + i, 100 + i, (i * 37) % 101, i % 3 != 0);
        return units;
    }

    // TODO: build a formatted report of every unit, using StringBuilder
    // TODO: then write the same thing with += and time both

    public static void main(String[] args) {
        Unit[] units = roster();
        // print a header, one line per unit, and a summary
    }
}
Hint 1
Use String.format or sb.append(String.format(...)) for the columns. %-12s left aligns text, %6.1f right aligns a decimal to one place.
Hint 2
Give the builder a starting size if you can estimate it: new StringBuilder(2000 * 50). It saves the copying while the array grows.
Hint 3almost the answer
The += version is fine at 5 units because 5 throwaway objects cost nothing. At 2000 the copying grows with the square of the count, and the difference becomes seconds. The mistake is invisible until the data grows, which is why it reaches production.
What this is really testing

Whether you reach for a StringBuilder when building text in a loop. This is the shape the mistake takes in real code, where the loop count comes from data rather than a literal.

08

After the credits

compareTo was the quiet one in this section.

It returns a negative number, zero, or a positive number, rather than true or false. That shape is the Comparable contract, and in Phase X every sorting method in Java asks your class for exactly it. Collections.sort, Arrays.sort, TreeMap, PriorityQueue. All of them call compareTo and nothing else.

It has a contract too, like equals and hashCode in Section 8.3, and it fails just as quietly when you break it.

The growing array underneath StringBuilder comes back immediately as ArrayList, which is the same mechanism holding objects instead of characters.

Next, though, is a different problem. You can now build any text you like. But a class that holds text holds only text. Write a box that holds a String and you cannot use it for an Integer. Write it for Object and you lose every type check. Section 9.3 is how Java escapes that.

Threads you opened in this section

  • StringBuilderStringBuffer locks every method. StringBuilder does not, and that is the whole difference.Phase XIV. Concurrency

StringBuilder will return in Phase X. The Collections Framework