Lesson 2 of 71
Strings and Text
This lesson covers how Java stores and processes text. After it, a reader can compare strings correctly, build them without wasted allocation, handle characters outside the basic Unicode range, and convert between text and bytes without depending on the platform. It builds on Types, Variables, and Expressions.
| Topic | Release |
|---|---|
| Compact strings (Latin-1 storage) | Java 9 |
strip, isBlank, lines, repeat |
Java 11 |
indent, transform |
Java 12 |
Text blocks, formatted, stripIndent, translateEscapes |
Java 15 |
HexFormat |
Java 17 |
| UTF-8 as the default charset | Java 18 |
splitWithDelimiters, StringBuilder.repeat |
Java 21 |
Example 1: immutability, the pool, and equality
A String is immutable: no method changes its characters, and every "modifying" method returns a new string. The string pool is a JVM-wide table of strings; all string literals with the same content share one pooled object.
public class StringEquality {
public static void main(String[] args) {
String a = "order";
String b = "order";
String c = new String("order");
String d = "ord" + "er"; // constant expression, folded by javac
String part = "ord";
String e = part + "er"; // computed at run time
System.out.println("a == b: " + (a == b));
System.out.println("a == c: " + (a == c));
System.out.println("a == d: " + (a == d));
System.out.println("a == e: " + (a == e));
System.out.println("a.equals(e): " + a.equals(e));
System.out.println("a == e.intern(): " + (a == e.intern()));
String upper = a.toUpperCase();
System.out.println(a + " " + upper);
System.out.println("equalsIgnoreCase: " + a.equalsIgnoreCase(upper));
}
}a == b: true
a == c: false
a == d: true
a == e: false
a.equals(e): true
a == e.intern(): true
order ORDER
equalsIgnoreCase: true== compares references, as for any object, so its result depends on where each string came from. Literals and constant expressions are pooled; new String and run-time concatenation create new objects. intern() returns the pooled instance with the same content, adding it if missing. Content comparison always uses equals. Immutability makes strings safe to share between threads and to use as HashMap keys, and lets String cache its hash code after the first hashCode() call.
Since Java 9, compact strings (JEP 254) store a string as a byte[] plus a coder flag. If every character fits in Latin-1 (U+0000 to U+00FF), each takes one byte; otherwise the whole string uses UTF-16 with two bytes per character. One non-Latin-1 character doubles the storage of the whole string. The JVM flag -XX:-CompactStrings turns this off.
Example 2: concatenation and StringBuilder
StringBuilder is a mutable sequence of characters with an internal array that grows as needed. A + inside a loop creates a new String on every iteration and copies all previous characters, so the total work grows with the square of the length.
public class Building {
public static void main(String[] args) {
String slow = "";
for (int i = 0; i < 5; i++) {
slow += i + ","; // new String each iteration
}
System.out.println(slow);
StringBuilder sb = new StringBuilder(32);
for (int i = 0; i < 5; i++) {
if (sb.length() > 0) sb.append(',');
sb.append(i);
}
System.out.println(sb);
sb.setLength(0); // reuse the same buffer
sb.append("BUY ").append(100).append(" @ ").append(99.5);
sb.insert(0, '[').append(']');
System.out.println(sb + " length=" + sb.length());
System.out.println(sb.reverse());
String single = "qty=" + 7 + " px=" + 99.5; // one expression: fine
System.out.println(single);
}
}0,1,2,3,4,
0,1,2,3,4
[BUY 100 @ 99.5] length=16
]5.99 @ 001 YUB[
qty=7 px=99.5A single concatenation expression is compiled efficiently: since Java 9 (JEP 280) javac emits one invokedynamic call that sizes the result once, so + outside loops needs no StringBuilder. The constructor argument 32 sets the initial capacity, which avoids array growth when the final size is known. setLength(0) clears the builder and keeps its array, a common pattern in code that formats many messages. StringBuffer is the older synchronized variant; StringBuilder is used when one thread owns the builder.
Example 3: text blocks and formatted
A text block (Java 15) is a multi-line string literal between """ delimiters. The compiler removes the common leading indentation, normalizes line endings to \n, and strips trailing spaces.
public class TextBlocks {
public static void main(String[] args) {
String json = """
{
"symbol": "%s",
"qty": %d
}
""".formatted("ABC", 100);
System.out.print(json);
String oneLine = """
SELECT id, name \
FROM users \
WHERE active""";
System.out.println(oneLine);
String spaces = """
a\s
b \s
"quoted" and \"""triple\"""
""";
for (String line : spaces.split("\n")) {
System.out.println("[" + line + "]");
}
System.out.println(String.format("%-6s|%6.2f|%05d", "px", 3.14159, 42));
}
}{
"symbol": "ABC",
"qty": 100
}
SELECT id, name FROM users WHERE active
[a ]
[b ]
["quoted" and """triple"""]
px | 3.14|00042The closing """ on its own line ends the block with a newline; placing it after the last word omits that newline. A \ at the end of a line joins the next line without a newline, and \s is a single space that is not stripped. Indentation is measured from the leftmost line, including the closing delimiter. formatted(args) is the instance form of String.format; both take %s, %d, %f, width, flags, and precision.
Example 4: String methods added since Java 11
String gained methods in several releases. The table shows the ones common in production code.
| Method | Release |
|---|---|
isBlank, strip, stripLeading, stripTrailing, lines, repeat |
11 |
indent, transform |
12 |
formatted, stripIndent, translateEscapes |
15 |
splitWithDelimiters, indexOf(ch, from, to); StringBuilder.repeat |
21 |
import java.util.Arrays;
public class StringAdditions {
public static void main(String[] args) {
System.out.println(" ".isBlank() + " " + " ".isEmpty());
String padded = " trade "; // em spaces around the word
System.out.println("[" + padded.strip() + "]");
System.out.println("trim kept em spaces: " + (padded.trim().length() == padded.length()));
System.out.println("a\nb\r\nc".lines().count());
System.out.println("-".repeat(10));
System.out.print("line1\nline2".indent(4));
System.out.println("42".transform(Integer::parseInt) + 1);
System.out.println(Arrays.toString("a,b;c".splitWithDelimiters("[,;]", 0)));
System.out.println(new StringBuilder().repeat("ab", 3));
}
}true false
[trade]
trim kept em spaces: true
3
----------
line1
line2
43
[a, ,, b, ;, c]
abababstrip removes all Unicode whitespace, while trim removes only characters up to U+0020, so the em space U+2003 survives trim. lines treats \n, \r, and \r\n as terminators and returns a stream of lines. indent(n) adds n spaces to every line and ends the result with a newline. transform applies a function to the string, which keeps a chain of calls readable; Integer::parseInt is a method reference, a short way to pass a method as a function, taught in Lambdas and Functional Interfaces. lines and codePoints return streams, taught in Streams and Collectors; here only count() and toArray() are used. splitWithDelimiters returns the delimiters as elements between the parts.
Example 5: chars, code points, and Character
chars() returns the UTF-16 code units of a string as an IntStream, and codePoints() returns whole Unicode code points. Text that can contain emoji or rare scripts is processed by code point.
public class CodePoints {
public static void main(String[] args) {
String s = "Go🚀!"; // "Go", U+1F680 (rocket), "!"
System.out.println("chars: " + s.chars().count() + ", code points: " + s.codePoints().count());
StringBuilder reversedChars = new StringBuilder(s).reverse();
System.out.println("reverse keeps pair: " + reversedChars.toString().contains("🚀"));
String truncated = s.substring(0, 3); // cuts the surrogate pair
System.out.println("broken: " + Character.isHighSurrogate(truncated.charAt(2)));
int end = s.offsetByCodePoints(0, 3); // 3 code points
System.out.println("safe prefix length: " + end);
for (int cp : s.codePoints().toArray()) {
System.out.println("U+" + Integer.toHexString(cp).toUpperCase()
+ " letter=" + Character.isLetter(cp)
+ " digit=" + Character.isDigit(cp)
+ " name=" + Character.getName(cp));
}
System.out.println(Character.toChars(0x1F680).length);
System.out.println(Character.isWhitespace(' ') + " " + Character.isDigit('٣'));
System.out.println(Character.getNumericValue('٣'));
}
}chars: 5, code points: 4
reverse keeps pair: true
broken: true
safe prefix length: 4
U+47 letter=true digit=false name=LATIN CAPITAL LETTER G
U+6F letter=true digit=false name=LATIN SMALL LETTER O
U+1F680 letter=false digit=false name=ROCKET
U+21 letter=false digit=false name=EXCLAMATION MARK
2
true true
3substring and charAt index code units, so cutting at an arbitrary index can split a surrogate pair and produce an invalid string. offsetByCodePoints finds a safe cut. StringBuilder.reverse keeps surrogate pairs in order. Character methods take an int code point, so they work for every character; the char overloads cannot see characters above U+FFFF. Character.isDigit accepts digits from all scripts, such as the Arabic-Indic three U+0663, which matters when validating input that must be ASCII digits.
Example 6: HexFormat and StringJoiner
java.util.HexFormat (Java 17) converts between bytes and hexadecimal text. StringJoiner builds a delimited string with an optional prefix and suffix.
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.StringJoiner;
public class HexAndJoin {
public static void main(String[] args) {
byte[] frame = {0x01, (byte) 0xAB, 0x7F, 0x00};
HexFormat hex = HexFormat.of();
System.out.println(hex.formatHex(frame));
System.out.println(HexFormat.ofDelimiter(":").withUpperCase().formatHex(frame));
System.out.println(Arrays.toString(hex.parseHex("cafe")));
System.out.println(hex.toHexDigits((byte) 10) + " " + hex.toHexDigits(255));
System.out.println(HexFormat.fromHexDigits("ff"));
byte[] text = "FIX".getBytes(StandardCharsets.US_ASCII);
System.out.println(HexFormat.ofDelimiter(" ").withPrefix("0x").formatHex(text));
StringJoiner joiner = new StringJoiner(", ", "[", "]");
joiner.setEmptyValue("none");
System.out.println(joiner);
joiner.add("AAPL").add("MSFT").add("GOOG");
System.out.println(joiner + " (" + joiner.length() + " chars)");
System.out.println(String.join("|", "a", "b", "c"));
}
}01ab7f00
01:AB:7F:00
[-54, -2]
0a 000000ff
255
0x46 0x49 0x58
none
[AAPL, MSFT, GOOG] (18 chars)
a|b|cHexFormat instances are immutable and thread-safe, so one instance can be shared as a constant. toHexDigits(int) always prints 8 digits; the byte overload prints 2. parseHex returns signed bytes, so 0xca prints as -54. String.join is a shortcut over StringJoiner for a fixed delimiter with no prefix or suffix.
Example 7: regular expressions
A Pattern is a compiled regular expression, and a Matcher applies it to one input. Compiling is the expensive step, so a pattern used repeatedly is compiled once and stored in a static final field.
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Regex {
private static final Pattern ORDER =
Pattern.compile("(?<side>BUY|SELL) (?<qty>\\d+) (?<sym>[A-Z]{1,5}) @ (?<px>\\d+(?:\\.\\d+)?)");
public static void main(String[] args) {
String[] lines = {"BUY 100 AAPL @ 189.5", "SELL 20 MSFT @ 410", "HOLD 5 X @ 1"};
for (String line : lines) {
Matcher m = ORDER.matcher(line);
if (m.matches()) {
System.out.println(m.group("side") + " " + Integer.parseInt(m.group("qty"))
+ " " + m.group("sym") + " " + m.group("px"));
} else {
System.out.println("no match: " + line);
}
}
Matcher numbers = Pattern.compile("\\d+").matcher("a1b22c333");
int sum = 0;
while (numbers.find()) {
sum += Integer.parseInt(numbers.group());
}
System.out.println("sum = " + sum);
System.out.println("2026-10-03".replaceAll("(\\d+)-(\\d+)-(\\d+)", "$3/$2/$1"));
System.out.println(String.join("|", "a, b,,c".split(",\\s*")));
System.out.println("a.b".split(".").length + " " + "a.b".split(Pattern.quote(".")).length);
}
}BUY 100 AAPL 189.5
SELL 20 MSFT 410
no match: HOLD 5 X @ 1
sum = 356
03/10/2026
a|b||c
0 2matches requires the whole input to match; find searches for the next match and can be called in a loop. Named groups (?<name>...) make extraction readable. String.matches, replaceAll, and split compile the pattern on every call, so in a hot loop they cost more than a stored Pattern. split(".") returns an empty array because . matches every character and trailing empty strings are removed; Pattern.quote treats the argument literally.
Example 8: charsets and the UTF-8 default
A charset maps characters to bytes. Since Java 18 (JEP 400) the default charset is UTF-8 on every platform; before that it depended on the operating system locale, so the same program could write different bytes on Windows and Linux.
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
public class Charsets {
public static void main(String[] args) {
System.out.println("default: " + Charset.defaultCharset());
String word = "café"; // "café"
byte[] utf8 = word.getBytes(StandardCharsets.UTF_8);
byte[] latin1 = word.getBytes(StandardCharsets.ISO_8859_1);
byte[] utf16 = word.getBytes(StandardCharsets.UTF_16BE);
System.out.println("UTF-8 " + utf8.length + " " + Arrays.toString(utf8));
System.out.println("Latin-1 " + latin1.length + " " + Arrays.toString(latin1));
System.out.println("UTF-16BE " + utf16.length);
String wrong = new String(utf8, StandardCharsets.ISO_8859_1);
System.out.println("mojibake length: " + wrong.length());
System.out.println("round trip: " + new String(utf8, StandardCharsets.UTF_8).equals(word));
byte[] ascii = "é".getBytes(StandardCharsets.US_ASCII);
System.out.println("unmappable becomes: " + (char) ascii[0]);
}
}default: UTF-8
UTF-8 5 [99, 97, 102, -61, -87]
Latin-1 4 [99, 97, 102, -23]
UTF-16BE 8
mojibake length: 5
round trip: true
unmappable becomes: ?é takes two bytes in UTF-8 and one in Latin-1. Decoding UTF-8 bytes as Latin-1 gives one wrong character per byte (mojibake), with no exception. getBytes replaces characters the charset cannot represent with ?. The StandardCharsets constants avoid the checked UnsupportedEncodingException of the name-based overloads. Code that reads files or sockets passes the charset explicitly, so behavior does not depend on the JDK version or on the file.encoding property.
Interview points
- Compare strings with
equals;==depends on pooling and istrueonly for the same object. - Literals and compile-time constants are interned;
new Stringand run-time concatenation are not. - Immutability gives thread safety, safe map keys, and a cached hash code.
- Compact strings store Latin-1 text in one byte per character; any wider character switches the string to UTF-16.
+in a loop is quadratic; a single+expression is compiled withinvokedynamicand is fine.length()counts UTF-16 code units; usecodePoints()andoffsetByCodePointsfor user-visible characters.stripis Unicode-aware,trimis not.- Compile a
Patternonce;String.splitandreplaceAllcompile on each call. - Default charset is UTF-8 since Java 18; pass charsets explicitly anyway.
Summary
Stringis immutable and caches its hash.intern()returns the pooled instance.StringBuilderis mutable, unsynchronized, and reusable withsetLength(0).- Text blocks strip common indentation;
\joins lines,\skeeps a space. HexFormat(Java 17) formats and parses hex; instances are thread-safe.StringJoinerandString.joinbuild delimited text.Matcher.matcheschecks the whole input,findsearches.- Unmappable characters become
?ingetBytes; wrong decoding gives mojibake without an error.
