Java strings never change, so every edit builds a new one. Learn the String methods and their traps, why += in a loop copies everything, StringBuilder, text blocks, Unicode, and arrays with the Arrays helpers.
A Java String can’t be changed after it’s made. Every method that looks like it edits one gives you a new string instead. That fact explains a classic bug, a classic slow loop, and why StringBuilder exists.
This post covers everyday String methods and their traps, comparing strings, StringBuilder, Unicode, text blocks, and arrays with the Arrays helpers. Every program below was run on Java 25, and its output is pasted from the run. To run one yourself, save it as Main.java and run java Main.java.
Strings are immutable
A String object holds its characters for life, and no method changes them. toUpperCase doesn’t turn a string into capitals. It returns a new, capitalised string, and if you don’t keep that return value, it’s gone:
void main() {
String name = "ada";
name.toUpperCase();
IO.println(name);
String shout = name.toUpperCase();
IO.println(shout);
IO.println(name);
name = name.toUpperCase();
IO.println(name);
}
It prints:
ada
ADA
ada
ADA
The second line of main is the bug. It compiles, runs and does nothing, and javac doesn’t warn about it, even with -Xlint:all. Store the result, as the last lines do. Even then, name = name.toUpperCase() doesn’t change "ada". It makes name refer to a different string.
The everyday String methods
Most string work is finding a position, cutting out a piece, or checking what’s inside. Positions, called indexes, start at 0:
void main() {
String s = "hello, world";
IO.println(s.length());
IO.println(s.charAt(0));
IO.println(s.charAt(s.length() - 1));
IO.println(s.substring(7));
IO.println(s.substring(0, 5));
IO.println(s.indexOf("o"));
IO.println(s.lastIndexOf("o"));
IO.println(s.indexOf("z"));
IO.println(s.contains("world"));
IO.println(s.replace("l", "L"));
IO.println(s.startsWith("hell") + " " + s.endsWith("!"));
}
It prints:
12
h
d
world
hello
4
8
-1
true
heLLo, worLd
true false
A few rules to learn from that output:
- The last index is
length() - 1. A 12-character string has indexes 0 to 11. substring(begin, end)includesbeginand stops beforeend. The result’s length is alwaysend - begin.substring(7)runs to the end.indexOfreturns -1 when there’s no match. Check for it before using the result as an index.replacechanges every match of plain text.replaceAlllooks similar but takes a regular expression.
An index out of range throws
substring and charAt check their indexes, and a bad one stops the program:
void main() {
String word = "hello";
IO.println(word.substring(1, 5));
IO.println(word.substring(2, 9));
}
It prints, then stops:
ello
Exception in thread "main" java.lang.StringIndexOutOfBoundsException: Range [2, 9) out of bounds for length 5
substring(1, 5) is fine, because an end equal to the length means “up to the end”. The message writes the bad range in maths notation: [2, 9) includes 2 and excludes 9, which is the end-exclusive rule written out.
split takes a regular expression
split cuts a string at every match of a separator and returns an array of the pieces. The separator is a regular expression, not plain text, and that catches almost everyone once:
void main() {
IO.println(Arrays.toString("a,b,c".split(",")));
IO.println(Arrays.toString("1.2.3".split(".")));
IO.println(Arrays.toString("1.2.3".split("\\.")));
IO.println(Arrays.toString("red|green".split("|")));
IO.println(Arrays.toString("red|green".split(Pattern.quote("|"))));
IO.println(Arrays.toString("a,b,,".split(",")));
IO.println(Arrays.toString("a,b,,".split(",", -1)));
IO.println(Arrays.toString(",a".split(",")));
}
It prints:
[a, b, c]
[]
[1, 2, 3]
[r, e, d, |, g, r, e, e, n]
[red, green]
[a, b]
[a, b, , ]
[, a]
Three surprises, in order:
split(".")returns an empty array..means “any character”, so every piece is empty, and empty pieces at the end are dropped. Escape the dot as"\\.".split("|")splits between every character.|means “or”, and “nothing or nothing” matches everywhere.Pattern.quotemakes any text match literally.- Trailing empty strings vanish.
"a,b,,"has four fields, butsplit(",")returns two. Leading ones stay, as",a"shows. Pass a limit of -1 to keep every field, for example in a CSV row with empty last columns.
Trimming, blank checks, repeating, joining and formatting
These methods clean up and build strings. strip, isBlank and repeat arrived in Java 11, and formatted in Java 15:
void main() {
String spaces = " total ";
IO.println("[" + spaces.trim() + "] [" + spaces.strip() + "]");
String emSpaces = "\u2003total\u2003";
IO.println(emSpaces.trim().length() + " " + emSpaces.strip().length());
IO.println("".isEmpty() + " " + " ".isEmpty() + " " + " ".isBlank());
IO.println("ab".repeat(3));
IO.println(String.join(", ", "Ana", "Ben", "Chen"));
IO.println(String.join("/", List.of("usr", "local", "bin")));
IO.println(String.format("%-6s|%5d|%.2f", "tea", 42, 3.14159));
IO.println("%s scored %d%% on %,d questions".formatted("Ana", 95, 1200));
}
It prints:
[total] [total]
7 5
true false true
ababab
Ana, Ben, Chen
usr/local/bin
tea | 42|3.14
Ana scored 95% on 1,200 questions
trim and strip agree on ordinary spaces. They disagree on \u2003, an em space, which often arrives in text pasted from a web page. trim removes only characters up to the ordinary space in the character table, so it kept both and returned 7 characters. strip knows all Unicode whitespace. In new code, use strip.
isBlank is true for a string of only whitespace, which is usually the check you want for user input. isEmpty is true only for length 0.
formatted does the same job as String.format, called on the pattern itself. %s is a string, %d a whole number, %.2f two decimal places, %-6s left-aligned in six columns, %,d a number with separators, and %% a percent sign.
Comparing strings
equals compares two strings character by character. The part on values and references showed why == is wrong for strings: it checks for the same object, not the same text. Two more methods cover case and order:
void main() {
String a = "Java";
IO.println(a.equals("java"));
IO.println(a.equalsIgnoreCase("java"));
IO.println("apple".compareTo("banana"));
IO.println("banana".compareTo("apple"));
IO.println("pear".compareTo("pear"));
IO.println("app".compareTo("apple"));
IO.println("Zebra".compareTo("apple"));
IO.println("Zebra".compareToIgnoreCase("apple"));
String missing = null;
IO.println("Java".equals(missing));
}
It prints:
false
true
-1
1
0
-2
-7
25
false
compareTo orders two strings. Only the sign matters: negative means the first string comes first, zero means equal, positive means after. The number is the difference between the first characters that differ, or between the lengths, as with "app" and "apple".
"Zebra" comes before "apple", because every capital letter has a smaller character code than every lower-case letter. compareToIgnoreCase gives the order a person expects.
The last line is a habit worth copying. "Java".equals(missing) returns false for null, while missing.equals("Java") would throw NullPointerException.
Java keeps one shared copy of each string literal in a string pool, so two identical literals are the same object and == on them happens to be true. Strings built at run time, from input or a StringBuilder, aren’t pooled. That’s why == on strings passes a quick test and fails on real data.
Why += in a loop copies everything
Because a string can’t grow, s += "!" makes a new string one character longer, copies every character of the old one into it, adds "!", and points s at the result. The old string becomes garbage. In a loop, each step copies more than the last.
This program counts the copying instead of timing it. For +=, each step copies the whole current string. For StringBuilder, it counts how often the builder moved to a bigger buffer, and what it copied then:
void main() {
int n = 10_000;
String s = "";
long copiedByPlus = 0;
for (int i = 0; i < n; i++) {
copiedByPlus += s.length();
s += "!";
}
var sb = new StringBuilder();
long copiedByBuilder = 0;
int regrowths = 0;
for (int i = 0; i < n; i++) {
int before = sb.capacity();
sb.append('!');
if (sb.capacity() != before) {
regrowths++;
copiedByBuilder += sb.length() - 1;
}
}
IO.println("+= made " + n + " strings and copied " + copiedByPlus + " characters");
IO.println("StringBuilder grew " + regrowths + " times and copied "
+ copiedByBuilder + " characters");
IO.println("same text: " + s.contentEquals(sb));
}
It prints:
+= made 10000 strings and copied 49995000 characters
StringBuilder grew 10 times and copied 18394 characters
same text: true
Both loops build the same 10,000 characters. The += loop copied 0, then 1, then 2, up to 9,999, which is about n² / 2. Double n and the copying goes up four times. That’s O(n²).
A StringBuilder keeps its characters in a buffer with spare room, and append writes into the next free slot. When the buffer fills, the builder allocates one about twice as big and copies across once. That happened only 10 times, so the copying stays close to n.
A single expression such as "Hi " + name + ", you have " + n + " messages" doesn’t need a StringBuilder. Since Java 9, javac compiles the whole expression into one call that builds the string once. The cost appears only when a loop repeats +=, because each pass starts again from the full string.
Using a StringBuilder
A StringBuilder is a string you’re allowed to change. You build it up, then call toString once to get an ordinary String:
void main() {
var sb = new StringBuilder("world");
sb.insert(0, "hello ");
sb.append('!').append(" x").append(3);
IO.println(sb);
sb.setCharAt(0, 'H');
sb.deleteCharAt(sb.length() - 1);
String done = sb.toString();
IO.println(done + " has " + done.length() + " characters");
IO.println(new StringBuilder("stressed").reverse());
var one = new StringBuilder("abc");
var two = new StringBuilder("abc");
IO.println(one.equals(two));
IO.println(one.toString().equals(two.toString()));
IO.println("abc".contentEquals(one));
}
It prints:
hello world! x3
Hello world! x has 14 characters
desserts
false
true
true
append takes strings, characters and numbers and returns the same builder, so calls chain. insert puts text at an index, and reverse flips the builder in place.
The last three lines hold a trap. StringBuilder doesn’t override equals, so two builders with the same text aren’t equal. Compare their strings, or use String.contentEquals. You may also meet StringBuffer, an older, synchronized version. Use StringBuilder.
Explain it like I’m ten
A String is a printed sign. Nobody can change its letters. To make “Sale” say “Sale!”, you print a whole new sign and throw the old one away.
Now add one letter a day. Every day you print a new sign with all the old letters plus one, and the recycling bin fills up. By the end of the month you’ve printed the first letter 30 times.
A StringBuilder is a whiteboard. You write the next letter after the last one, and nothing gets reprinted. When you’re done, you print one sign from the whiteboard.
The precise version
A String holds a private character array that nothing writes to after construction. replace, toUpperCase and + all allocate a new String with a new array.
A StringBuilder holds an array whose capacity is at least its length. append writes into the unused part. When a write won’t fit, the builder allocates a larger array, twice the old capacity plus 2 on this JDK (16, 34, 70, 142 and so on), and copies the characters across. Because the size doubles, each character is copied only a few times on average, so n appends cost O(n). toString copies the used part into a new String, so later changes to the builder can’t reach it.
Where the analogy breaks: a real whiteboard has a fixed size. A StringBuilder swaps itself for a bigger one when it fills, and that swap does copy everything, just rarely. If you know the final size, new StringBuilder(10_000) skips the swaps.
Watching += copy and append fill a buffer
The animation runs s += "!" twice, then does the same work with a StringBuilder:
Each s += “!” makes a new string, copies every character of the old one, and leaves the old one as garbage. A StringBuilder writes each ! into a free slot of one buffer, and toString copies once at the end.
Here are those steps in words, in case the animation doesn’t play for you:
srefers to aStringholding"go".s += "!"allocates a three-character string, copiesgandointo it, and adds!.snow refers to it, and the old"go"is garbage. Two characters copied.s += "!"again allocates four characters and copies all three of"go!"before adding!."go!"is garbage too. That’s 5 characters copied to build four.- A
StringBuilderholds"go"in a buffer of 8 slots. sb.append('!')twice writes!into slot 3, then slot 4. It’s the same buffer, and nothing moves.sb.toString()copies the 4 characters into one newString, the builder’s only copy.
A char is not always a character
A Java char is a 16-bit UTF-16 code unit, not a letter as a person sees it. Most letters, accented ones included, fit in one char. Emoji take two, called a surrogate pair, and length() counts chars:
void main() {
String word = "café";
String thumb = "👍";
IO.println(word.length() + " " + thumb.length());
IO.println(thumb.codePointCount(0, thumb.length()));
IO.println(Character.isHighSurrogate(thumb.charAt(0)));
IO.println(Integer.toHexString(thumb.codePointAt(0)));
IO.println(Character.toString(0x1F44D));
String text = "ok👍";
IO.println(text.length() + " " + text.codePointCount(0, text.length()));
IO.println(new StringBuilder(text).reverse());
IO.println(text.substring(0, 3));
}
It prints:
4 2
1
true
1f44d
👍
4 3
👍ko
ok?
A code point is the number Unicode gives a character, here U+1F44D. The thumb is one code point stored as two chars, and charAt(0) returns only the first half, the “high surrogate”.
The last two lines surprised us. StringBuilder.reverse keeps surrogate pairs together, so the emoji survived. substring(0, 3) cut the pair in half, and a lone surrogate has no UTF-8 encoding, so Java printed ?. Cutting user text at a fixed index can corrupt it this way.
Even code points aren’t the whole story. A flag such as 🇮🇳 is two code points that a reader sees as one symbol. Keep this in mind when you truncate names or count characters against a limit.
Text blocks: multi-line strings without the escapes
A text block is a string literal over several lines, between two sets of three double quotes. It became final in Java 15, and javac --release 14 rejects it with text blocks are not supported in -source 14. It makes JSON, SQL and HTML readable:
void main() {
String json = """
{
"name": "Ana",
"score": 95
}
""";
IO.print(json);
String shifted = """
{
"name": "Ana"
}
""";
IO.print(shifted);
String noNewline = """
one line""";
IO.println("[" + noNewline + "]");
}
It prints:
{
"name": "Ana",
"score": 95
}
{
"name": "Ana"
}
[one line]
The quotes inside needed no backslashes. The indentation needs a closer look, because the source is indented 8 spaces and the first result isn’t:
- Incidental indentation is removed. Java finds the smallest indentation among the content lines and the closing
"""line, and strips that much from every line. The indentation of"name"relative to{survives. - The closing
"""sets the left edge. Inshifted, it sits 4 spaces left of the content, so each line keeps 4 spaces. - The opening
"""must end its line."""hello"""on one line fails withillegal text block open delimiter sequence, missing line terminator. - A closing
"""on its own line adds a final newline. Put it right after the text, as innoNewline, to avoid one.
Line continuation with \ and keeping spaces with \s
Two escapes exist only for text blocks. A \ at the end of a line joins it to the next, and \s is a space that Java won’t strip:
void main() {
String sql = """
SELECT name, score \
FROM results \
WHERE score > %d \
ORDER BY score DESC""".formatted(90);
IO.println(sql);
String padded = """
red \s
green\s
""";
IO.print(padded.replace(' ', '.'));
IO.println("""
She said "hi", then wrote \""" on the board.
""".strip());
}
It prints:
SELECT name, score FROM results WHERE score > 90 ORDER BY score DESC
red...
green.
She said "hi", then wrote """ on the board.
The SQL spans four lines in the source and comes out as one, because each \ removed a line break. formatted works on a text block like any string.
Java strips trailing spaces from each line, because editors add and remove them invisibly. \s is a space that marks the line’s end, so the spaces before it survive, as the dots after red show. To put three quotes inside, escape the first as \""".
Arrays: a fixed number of slots
An array holds a fixed number of values of one type, chosen when you create it. New slots start at the type’s zero value, the same defaults fields get:
void main() {
int[] counts = new int[4];
double[] prices = new double[2];
boolean[] seen = new boolean[3];
String[] names = new String[3];
IO.println(Arrays.toString(counts));
IO.println(Arrays.toString(prices));
IO.println(Arrays.toString(seen));
IO.println(Arrays.toString(names));
int[] scores = {72, 95, 88};
String[] days = new String[] {"Mon", "Tue"};
scores[0] = 75;
IO.println(scores.length + " " + days.length);
IO.println(Arrays.toString(scores) + " " + Arrays.toString(days));
}
It prints:
[0, 0, 0, 0]
[0.0, 0.0]
[false, false, false]
[null, null, null]
3 2
[75, 95, 88] [Mon, Tue]
{72, 95, 88} creates and fills an array, and the compiler counts the length. The long form, new String[] {...}, is needed when you aren’t assigning straight to a declared variable, such as when you pass the array to a method.
scores.length has no brackets: for an array, length is a field, and for a string it’s a method. An array can’t grow. For a fifth count, you’d make a new array and copy across.
Printing an array directly shows a hash
Arrays don’t override toString, so IO.println(scores) doesn’t print the contents. It prints [I@ followed by some hex digits. [I is Java’s internal name for “array of int“, and the digits come from an identity hash code, which can change between runs. A String[] prints as [Ljava.lang.String;@ and digits.
One detail surprised us. System.out.println has a special version for char[] that prints the letters. IO.println takes any Object, so a char[] prints as [C@ and a hash. Use new String(letters).
An index out of range throws
Java checks every array access, and an index below 0 or at length or above throws ArrayIndexOutOfBoundsException:
void main() {
int[] scores = {72, 95, 88};
IO.println("first: " + scores[0]);
IO.println("last: " + scores[scores.length - 1]);
IO.println("before first: " + scores[-1]);
}
It prints, then stops:
first: 72
last: 88
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index -1 out of bounds for length 3
Negative indexes don’t count from the end as they do in Python. The part on control flow shows the other common cause, a loop that uses <= instead of <.
The Arrays helper class
java.util.Arrays holds what arrays don’t have as methods: sorting, filling, copying and comparing. A compact source file needs no import for it:
void main() {
int[] scores = {88, 72, 95, 61};
int[] sorted = Arrays.copyOf(scores, scores.length);
Arrays.sort(sorted);
IO.println(Arrays.toString(scores) + " -> " + Arrays.toString(sorted));
String[] words = {"pear", "apple", "Banana"};
Arrays.sort(words);
IO.println(Arrays.toString(words));
int[] bigger = Arrays.copyOf(scores, 6);
int[] smaller = Arrays.copyOf(scores, 2);
IO.println(Arrays.toString(bigger) + " " + Arrays.toString(smaller));
IO.println(Arrays.toString(Arrays.copyOfRange(scores, 1, 3)));
char[] line = new char[10];
Arrays.fill(line, '-');
IO.println(new String(line));
int[] a = {1, 2, 3};
int[] b = {1, 2, 3};
int[] c = a;
IO.println((a == b) + " " + a.equals(b) + " " + Arrays.equals(a, b) + " " + (a == c));
}
It prints:
[88, 72, 95, 61] -> [61, 72, 88, 95]
[Banana, apple, pear]
[88, 72, 95, 61, 0, 0] [88, 72]
[72, 95]
----------
false false true true
Line by line:
Arrays.sortsorts in place and returns nothing, so the program copiedscoresfirst.- Strings sort by
compareTo, so"Banana"comes first because of its capitalB. copyOfpads with zero values or cuts off the end.copyOfRangeuses the same end-exclusive rule assubstring.fillsets every slot to one value.==anda.equals(b)both compare references, because arrays inheritequalsfromObject.Arrays.equalscompares the elements.a == cistruebecausec = acopied the reference.
Two-dimensional arrays
Java has no true grid type. A two-dimensional array is an array whose elements are arrays, and that’s why its rows can have different lengths:
void main() {
int[][] grid = new int[2][3];
grid[1][2] = 7;
IO.println(grid.length + " rows, " + grid[0].length + " columns");
IO.println(Arrays.toString(grid[1]));
IO.println(Arrays.deepToString(grid));
int[][] triangle = {{1}, {1, 1}, {1, 2, 1}};
for (int[] row : triangle) {
IO.println(row.length + ": " + Arrays.toString(row));
}
int[][] first = {{1, 2}, {3, 4}};
int[][] second = {{1, 2}, {3, 4}};
IO.println(Arrays.equals(first, second) + " " + Arrays.deepEquals(first, second));
}
It prints:
2 rows, 3 columns
[0, 0, 7]
[[0, 0, 0], [0, 0, 7]]
1: [1]
2: [1, 1]
3: [1, 2, 1]
false true
grid[1][2] means row 1, then column 2 of that row. grid.length counts rows, and grid[0].length is the length of row 0.
The last line catches people. Arrays.equals compares the outer elements, which are row arrays, with ==, so it says false. Nested arrays need Arrays.deepEquals, just as they print with Arrays.deepToString.
Arrays or List?
Arrays suit a size that’s fixed and known: 64 chess squares, pixel data, or the String[] that split returns. For a collection that grows and shrinks, use a List. It resizes, prints its contents and has a working equals:
void main() {
String[] fixed = {"Ana", "Ben"};
var names = new ArrayList<String>(List.of(fixed));
names.add("Chen");
IO.println(names + " " + names.size());
List<String> view = Arrays.asList(fixed);
view.set(0, "Zoe");
IO.println(fixed[0]);
view.add("Dev");
}
It prints, then stops:
[Ana, Ben, Chen] 3
Zoe
Exception in thread "main" java.lang.UnsupportedOperationException
new ArrayList<>(List.of(fixed)) copies the array into a growable list. Arrays.asList copies nothing. It wraps the array in a List view, so set changed fixed[0] too, and add throws because the array can’t grow.
A List<int> isn’t allowed, so a list of numbers holds boxed Integer objects, while an int[] holds the numbers directly. The part on equals, hashCode and collections covers List, Set and Map properly.
What to remember
- A
Stringnever changes.s.toUpperCase()on its own does nothing, so store the result. substring(begin, end)stops beforeend.splittakes a regular expression and drops trailing empty strings.- Compare strings with
equals.compareToputs capitals before lower case. +=in a loop copies the whole string every pass, which is O(n²). Use aStringBuilderand calltoStringonce.- A
charis a UTF-16 code unit. An emoji haslength()2, andsubstringcan cut it in half. - Text blocks strip indentation up to the closing
""".\joins lines, and\skeeps a trailing space. - Arrays have a fixed length. Print them with
Arrays.toString, compare them withArrays.equals, and use aListwhen the size changes.
Every change to a String makes a new String, so build text that grows in a StringBuilder.