skip to content

the string api

StringBuilder, formatting, compact strings and the methods worth knowing.

chapter
chapter 04.01
reading time
10 min read

a String never changes, so every "edit" builds a new one. when you build text piece by piece, use a StringBuilder: a byte[] that grows by roughly doubling. and since java 9 strings are compact: one byte per char whenever every char fits in latin-1.

the rest is knowing about fifteen methods, and that split and replaceAll take a regex.

the picture

a String is a printed page. you can read it, copy bits of it, staple two pages together, but you can't edit it. every "change" prints a fresh page. (why they're immutable is in strings, the basics.)

a StringBuilder is a whiteboard. you write on it as much as you like. when it's full, you grab a bigger whiteboard, copy everything over, and keep going. when you're done, toString() prints the page.

that difference is the whole performance story:

don't+= in a loop
java
String csv = "";
for (String name : names) {
    csv += name + ",";   // a brand new String, every time
}

iteration i copies everything built so far. that's 1 + 2 + 3 + … + n copies: quadratic.

doone builder
java
var sb = new StringBuilder();
for (String name : names) {
    sb.append(name).append(',');
}
String csv = sb.toString();

appends write into spare room. a copy only happens when the buffer grows, and it grows by doubling.

+= × 10,0001.8 ms
StringBuilder × 10,0000.16 ms
+= × 100,000135 ms
StringBuilder × 100,0000.12 ms
log scale
appending one "x" n times, best of 5 runs after warm-up. temurin 25 on an M4 Pro. at 100,000 appends, += is about 1,000× slower than a StringBuilder, and it gets worse with every extra zero.

(yes, the builder got faster with more work. by the bigger run the JIT had compiled the loop. at that speed it's basically free either way.)

StringBuilder is a byte[] that grows

inside, a StringBuilder is a byte[], a count of how much of it is used, and a coder (more on that below). capacity() is the size of the array, length() is how much you've written. start empty and you get room for 16.

append =
length()0
capacity()16
valuebyte[16]
coderLATIN1
  1. new StringBuilder() → byte[16]
bytes copied by growth so far: 0one cell = one byte
append and watch the buffer. amber is what the last append wrote. when it doesn't fit, a new array is allocated, everything is copied, and the old one becomes garbage. then try '€': one char that doesn't fit in a byte, and every char now takes two.

the rule, straight from AbstractStringBuilder in jdk 25: the new capacity is old * 2 + 2, or exactly what you asked for if that's bigger. so 16 → 34 → 70 → 142 → 286. doubling means the total copying stays proportional to the final length (each byte gets copied about once on average), which is why appending is "amortized O(1)".

if you know roughly how big the result will be, say so up front and skip the copies entirely:

java
var sb = new StringBuilder(4096);   // one allocation, no growing
predict the output
java
var sb = new StringBuilder("hello");
IO.println(sb.capacity());

the handful you'll actually use:

Builder.java
void main() {
    var sb = new StringBuilder("hello");
    sb.insert(0, ">> ").append('!').reverse();   // every method returns the builder, so they chain
    IO.println(sb);
    sb.setLength(3);                             // truncate (or pad with \0 if longer)
    IO.println(sb);
    IO.println(new StringBuilder().repeat("ab", 3).repeat('-', 2));
}
output
!olleh >>
!ol
ababab--

repeat on a builder is java 21+. there's also deleteCharAt, replace(start, end, str), charAt/setCharAt and indexOf.

what a String actually stores

before java 9, a String was a char[]: two bytes per character, always. but most strings in most programs are ascii (keys, ids, json, log lines), so half of every string was zeros. JEP 254 changed the inside to a byte[] plus a one-byte coder: LATIN1 (one byte per char) when every char is ≤ 0xFF, UTF16 (two bytes per char) otherwise.

s =
or type =
coderUTF16 (1)
length()5
value.length10 bytes

one char above 0xFF and the whole string switches to 2 bytes per char. the zero bytes are the price.

pick a string or type one. these are the real bytes inside value, read with reflection on jdk 25. amber chars don't fit in one byte, and one of them is enough to make the whole string pay double. the emoji is two chars (a surrogate pair), so its length() counts it twice.

you never see the coder from the outside. charAt, length and friends behave exactly the same. it's just memory, and it's a lot of memory: strings are often the biggest thing on a heap.

the methods worth knowing

String has about 70 public methods. these are the ones that pull their weight, plus the java 11+ ones people don't know exist yet:

methodsincewhat it does
isBlank()11true for "" and whitespace-only. isEmpty() only checks ""
strip(), stripLeading(), stripTrailing()11trim, but unicode-aware
repeat(n)11"ab".repeat(3) → "ababab"
lines()11a Stream<String>, splitting on \n, \r or \r\n
formatted(args...)15"%d items".formatted(n), same as String.format
indent(n), stripIndent()12, 15add/remove leading spaces on every line
transform(f)12s.transform(f) is f.apply(s), for chaining
chars(), codePoints()9 on Stringan IntStream of chars / of real code points
indexOf(ch, from, to)21search inside a range without substring
splitWithDelimiters(regex, limit)21split, but keep the delimiters in the result
String.join(sep, parts...)8join("/", "usr", "bin") → "usr/bin"
contains, startsWith, endsWith, indexOfoldplain text, no regex
replace(a, b)oldplain text, replaces every match
split, replaceAll, replaceFirst, matchesoldregex. see below
Main.java
void main() {
    String s = "  hi  ";             //   is the ideographic space
    IO.println("[" + s.strip() + "]");         // trim() would leave the   alone
    IO.println("  ".isBlank() + " " + "  ".isEmpty());
    IO.println("ab".repeat(3));
    IO.println("a\nb\r\nc".lines().count());
    IO.println("%s is %d".formatted("x", 42));
    IO.println(String.join("/", "usr", "local", "bin"));
    IO.println("hi😀".length() + " " + "hi😀".codePointCount(0, 4));
    IO.println("hello".chars().filter(c -> c == 'l').count());
}
output
[hi]
true false
ababab
3
x is 42
usr/local/bin
4 3
2

trim() is from java 1.0 and removes everything <= ' ' (space and the ascii control chars). strip() asks Character.isWhitespace, so it also knows about the dozen-odd unicode spaces. new code: strip.

predict the output
java
IO.println("a.b.c".split(".").length);

formatting

String.format, "...".formatted(...) and printf all use the same little language from java.util.Formatter:

specgivesfor
%stoString()anything
%d42integers
%,d1,234,567grouping (locale decides the separator)
%05d00042zero-padded to width 5
%.2f3.142 decimals, rounded half-up
%-6s|ab |left-aligned in a 6-wide column
%x, %08Xff, 000000FFhex
%nthe OS line separatoruse it in printf instead of \n
%%%a literal percent
Fmt.java
import java.util.Locale;
 
void main() {
    IO.println(String.format(Locale.ROOT, "%,d|%-6s|%6.2f|%05d|%x", 1234567, "ab", Math.PI, 42, 255));
}
output
1,234,567|ab    |  3.14|00042|ff
one level deeper:what javac does with a + b + c+

a single expression like "id=" + id + ", name=" + name doesn't build a StringBuilder anymore. since java 9 (JEP 280), javac emits one invokedynamic instruction, and at first call the JVM's StringConcatFactory spins up a strategy that measures every part, allocates one exactly-sized byte[], and fills it. so + in one expression is as fast as anything you'd write by hand. it only goes wrong across statements, like += in a loop, where each statement is its own concatenation producing its own new string. the full story is in string concatenation.

keylines
  • strings are immutable. every "change" is a new string.
  • building in a loop? one StringBuilder. it grows as old * 2 + 2, so appends are amortized O(1).
  • strings store one byte per char when they can (latin-1), two when they can't. one emoji flips the whole string.
  • length() counts UTF-16 chars, not what a human calls characters.
  • split, replaceAll and matches take a regex. replace doesn't.
  • format for machines with Locale.ROOT.