Skip to content

Strings and Text Processing

String is the most used class in the Java platform. It is finalImplements Serializable Comparable<String>And CharSequenceAnd its instances are immutable. Every character in a String is stored internally as UTF-16 code units in a byte[] (since JDK 9, compact strings use byte[] with a coder flag for LATIN1 vs UTF-16).

Once constructed, a String object cannot be modified. Every “mutating” operation — substring concat``replace``toUpperCase``trim — returns a new String object. The original remains Unchanged. This is not a suggestion; the String class has no public mutating methods, and the Backing byte[] (the value field) is private.

String original = "hello";
String upper = original.toUpperCase();
System.out.println(original); // "hello" — unchanged
System.out.println(upper); // "HELLO"
System.out.println(original == upper); // false

Immutability is enforced by design:

  • All fields are private final.
  • No method on String modifies internal state.
  • The class is final — you cannot subclass it to introduce mutability.

Why immutable? Thread safety without synchronization, safe sharing across threads, secure use as HashMap keys and HashSet elements (hash code is cached at construction and never changes), and String literal interning.

The JVM maintains a pool of unique String literals. When the bytecode contains a string literal, The JVM looks up the pool; if the literal already exists, it reuses the reference. This means two Variables assigned the same literal may be the same object at runtime:

String a = "hello";
String b = "hello";
System.out.println(a == b); // true — same object from the pool
String c = new String("hello");
System.out.println(a == c); // false — new object on the heap
System.out.println(a.equals(c)); // true — same content

The new String(...) constructor always creates a new object on the heap, bypassing the pool. This Is almost always the wrong thing to do.

String.intern() returns a canonical representation from the pool. If the string is not already in The pool, it is added. Interning can reduce memory when you have many duplicate strings, but the Pool lives in the heap (since JDK 7) and is managed by the GC. Over-interning can cause GC pressure And pool bloat.

String s1 = new String("hello").intern();
String s2 = "hello";
System.out.println(s1 == s2); // true
### Compact Strings (JDK 9+)

Before JDK 9, every String stored its characters in a char[] — 2 bytes per character. JDK 9 Introduced compact strings (-XX:+CompactStringsEnabled by default since JDK 9). If all Characters fit in the LATIN1 range (code points 0-255), the string uses a byte[] with 1 byte per Character. If any character exceeds LATIN1, it switches to UTF-16 encoding (2 bytes per character). This reduces memory usage by roughly 50% for most real-world strings.

// LATIN1 encoding — 1 byte per char
String latin1 = "Hello, World!";
// UTF-16 encoding — 2 bytes per char (contains 世, U+4E16, outside LATIN1 range)
String utf16 = "世界";

The coder flag is stored in the coder field of the String object. You cannot control which Encoding is used; it is determined automatically at construction time.

OperationStringStringBuilderStringBuffer
MutabilityImmutableMutableMutable
Thread safetyN/A (immutable)Not thread-safeSynchronized (thread-safe)
Append performanceO(n) per append (copies array)Amortized O(1)Amortized O(1) + sync cost
Memory overheadNew object per operationSingle buffer, resizes as neededSingle buffer + sync overhead

String — Use for values that do not change. Literals, constants, method return values for Immutable data, keys in maps, and any case where immutability is desired. The JVM”s escape analysis And JIT can sometimes optimize string concatenation into StringBuilder automatically.

StringBuilder — Use for building strings in a single thread. This covers the vast majority of Use cases: constructing SQL queries, building JSON, accumulating log messages, reading file Contents.

StringBuffer — Use only when multiple threads need to append to the same buffer concurrently. This is rare. In practice, you almost always want StringBuilder and handle thread safety at a Higher level.

// BAD — creates many intermediate String objects
String result = "";
for (int i = 0; i < 1000; i++) {
result += "item" + i + ",";
}
// Each += creates a new StringBuilder, appends, and creates a new String
// GOOD — single StringBuilder
StringBuilder sb = new StringBuilder(10000); // pre-size if you know the approximate length
for (int i = 0; i < 1000; i++) {
sb.append("item").append(i).append(',');
}
String result = sb.toString();

The Java compiler translates string concatenation with the + operator into StringBuilder Operations at compile time (JLS §15.18.1). However, this optimization only applies within a single Expression:

// Single expression — compiler optimizes to one StringBuilder
String s = a + b + c + d;
// Loop — each iteration creates a new StringBuilder (pre-JDK 9)
// JDK 9+ uses invokedynamic with StringConcatFactory for better performance
String s = "";
for (String part : parts) {
s += part; // pre-JDK 9: new StringBuilder each iteration
}

JDK 9+ uses invokedynamic with StringConcatFactory for string concatenation, which generates Optimized bytecode at runtime. This can outperform the StringBuilder approach , Especially for concatenations involving non-string types.

Text blocks, standardized in JDK 15 (JEP 378), provide a way to write multi-line strings without Escape sequences. They are delimited by triple double quotes:

String html = """
&lt;html&gt;
&lt;body&gt;
&lt;p&gt;Hello, %s&lt;/p&gt;
&lt;/body&gt;
&lt;/html&gt;
""".formatted(name);
String json = """
{
"name": "%s",
"age": %d
}
""".stripIndent();
  1. The opening """ must be followed by a line terminator (the content starts on the next line).
  2. The closing """ can be on its own line or at the end of the last content line.
  3. Incidental white space is determined by the position of the closing """.
  4. Two trailing spaces on a line are preserved (otherwise trailing spaces are stripped).
// Incidental whitespace removal
String query = """
SELECT id, name, email
FROM users
WHERE status = 'ACTIVE'
ORDER BY name
""";
// Equivalent to: "SELECT id, name, email\nFROM users\nWHERE status = 'ACTIVE'\nORDER BY name\n"

Most escape sequences work normally. The \ at the end of a line prevents a line break, and \s Produces a single space (useful for preserving trailing whitespace):

String text = """
This is a single \
line because the backslash prevents the line break.\
""";
// Result: "This is a single line because the backslash prevents the line break."
String s = "Hello, World!";
String sub = s.substring(7, 12); // "World"
### Split and Join
String csv = "one,two,three,four";
String[] parts = csv.split(",");
// parts: ["one", "two", "three", "four"]
// split with limit
String[] limited = csv.split(",", 3);
// limited: ["one", "two", "three,four"]
// join (JDK 8+)
String joined = String.join(", ", parts);
// joined: "one, two, three, four"

split(String regex) compiles the regex pattern every call. If you split the same pattern Repeatedly in a hot path, compile the Pattern once and reuse it:

private static final Pattern COMMA = Pattern.compile(",");
String[] parts = COMMA.split(csv);
String s = " Hello, World! ";
s.trim(); // "Hello, World!" — removes ASCII whitespace (<= U+0020)
s.strip(); // "Hello, World!" — removes Unicode whitespace (JDK 11+)
s.stripLeading(); // "Hello, World! "
s.stripTrailing(); // " Hello, World!"
s.stripIndent(); // removes incidental indentation (JDK 15+)
String s = "Hello, World!";
s.replace("World", "Java"); // "Hello, Java!"
s.replaceAll("\\d+", "NUM"); // regex-based replacement
s.replaceFirst("\\d+", "NUM"); // replace first match only
s.chars()
.filter(Character::isDigit)
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
.toString();
String.format("User %s has %d points (balance: %.2f)", name, points, balance);
// Positional arguments
String.format("%2$s is %1$d years old", age, name);

Character wraps a single char value. It provides static utility methods for classification and Conversion:

// Classification
Character.isDigit('5'); // true
Character.isLetter('A'); // true
Character.isUpperCase('a'); // false
Character.isWhitespace(' '); // true
Character.isDefined('\u20AC');// true (€)
// Conversion
char upper = Character.toUpperCase('a'); // 'A'
int codePoint = Character.codePointAt("", 0); // 8364
char[] chars = Character.toChars(0x1F600); // 😀 (surrogate pair)

Java’s char is a UTF-16 code unit (16 bits). Characters outside the Basic Multilingual Plane (BMP) — emoji, rare CJK characters, mathematical symbols — require surrogate pairs (two char Values). Working with char directly on such strings will produce incorrect results. Use code point APIs instead:

String emoji = "Hello 🌍";
System.out.println(emoji.length()); // 8 (surrogate pair counts as 2)
System.out.println(emoji.codePointCount(0, emoji.length())); // 7
// Iterate by code point
emoji.codePoints().forEach(cp -> {
System.out.printf("U+%04X %n", cp);
});
// U+0048 U+0065 U+006C U+006C U+006F U+0020 U+1F30D
## Regular Expressions

Java’s regex engine is in java.util.regex. The two primary classes are Pattern (compiled Representation) and Matcher (stateful engine that performs match operations against input).

import java.util.regex.Pattern;
import java.util.regex.Matcher;
Pattern pattern = Pattern.compile("\\b(\\d{3})[-.]?(\\d{3})[-.]?(\\d{4})\\b");
Matcher matcher = pattern.matcher("Call 555-123-4567 or 555.987.6543");
while (matcher.find()) {
String areaCode = matcher.group(1);
String exchange = matcher.group(2);
String subscriber = matcher.group(3);
System.out.printf("Phone: (%s) %s-%s%n", areaCode, exchange, subscriber);
}
// Phone: (555) 123-4567
// Phone: (555) 987-6543
ExpressionMeaning
.Any character (except line terminators)
\dDigit [0-9]
\DNon-digit
\sWhitespace
\SNon-whitespace
\wWord character [a-zA-Z_0-9]
\WNon-word character
QuantifierGreedyReluctantPossessiveMeaning
Zero or oneX?X??X?+
Zero or moreX*X*?X*+
One or moreX+X+?X++
Exactly nX{n}X{n}?X{n}+
n to mX{n,m}X{n,m}?X{n,m}+
// Email (basic, not RFC 5322 compliant)
Pattern EMAIL = Pattern.compile("[\\w.+-]+@[\\w-]+\\.[\\w.]+");
// IPv4 address
Pattern IPV4 = Pattern.compile(
"(?:(?:25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}(?:25[0-5]|2[0-4]\\d|[01]?\\d\\d?)"
);
// Named groups (JDK 7+)
Pattern LOG = Pattern.compile(
"(?&lt;timestamp&gt;\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}) " +
"(?&lt;level&gt;\\w+) " +
"(?&lt;message&gt;.*)"
);
Matcher m = LOG.matcher("2024-01-15 10:30:00 ERROR Connection timeout");
if (m.matches()) {
System.out.println(m.group("level")); // "ERROR"
System.out.println(m.group("message")); // "Connection timeout"
}

Compiling a Pattern is expensive. Always compile once and reuse:

// BAD — compiles on every call
public boolean isValidEmail(String input) {
return input.matches("[\\w.+-]+@[\\w-]+\\.[\\w.]+");
// String.matches() compiles and discards the pattern every time
}
// GOOD — compile once
private static final Pattern EMAIL_PATTERN =
Pattern.compile("[\\w.+-]+@[\\w-]+\\.[\\w.]+");
public boolean isValidEmail(String input) {
return EMAIL_PATTERN.matcher(input).matches();
}
EncodingVariable widthBytes per char (typical)Notes
UTF-8Yes1-4Dominant on the web, wire
UTF-16Yes2-4Java internal representation
UTF-32No4Fixed width, memory-heavy
ISO-8859-1No1Latin-1, lossy for non-Latin
ASCIINo1Subset of UTF-8 and Latin-1

Java’s String stores text as UTF-16 code units internally. When converting to/from bytes (for I/O, Network, storage), you must specify the charset.

// Always specify the charset explicitly
byte[] utf8Bytes = "Hello".getBytes(StandardCharsets.UTF_8);
String decoded = new String(utf8Bytes, StandardCharsets.UTF_8);
// Using Charset directly
Charset charset = Charset.forName("UTF-8");
// Better: use the constant
Charset charset2 = StandardCharsets.UTF_8;
// List available charsets
SortedMap&lt;String, Charset&gt; available = Charset.availableCharsets();
// PITFALL: using platform default charset
byte[] bytes = "Hello".getBytes(); // uses platform default — non-portable
String s = new String(bytes); // same problem
// PITFALL: silent replacement of unencodable characters
CharsetEncoder encoder = StandardCharsets.ISO_8859_1.newEncoder()
.onMalformedInput(CodingErrorAction.REPLACE)
.onUnmappableCharacter(CodingErrorAction.REPLACE);
// "€" becomes "?" in ISO-8859-1 — data loss with no error
// SAFE: fail on encoding errors
CharsetEncoder strict = StandardCharsets.UTF_8.newEncoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
## String Formatting

String.format uses Formatter internally and supports format specifiers similar to printf in C:

String s = String.format("Name: %s, Age: %d, Balance: $%,.2f", "Alice", 30, 12345.678);
// "Name: Alice, Age: 30, Balance: $12,345.68"
SpecifierMeaningExample
%sString"hello"
%dDecimal integer42
%fDecimal floating point3.141593
%,dDecimal with comma separator1,234,567
%xHexadecimalff
%oOctal377
%bBooleantrue
%cCharacterA
%nPlatform-specific line separator
%%Literal percent%
%20sRight-pad string to width 20" hello"
%-20sLeft-pad string to width 20"hello "
%05dZero-pad integer to width 500042

MessageFormat is useful for localization because it supports positional arguments and ChoiceFormat:

String pattern = "On {0, date, long}, {1} found {2, choice, 0#no files|1#one file|1&lt;{2} files}.";
String result = MessageFormat.format(pattern, new Date(), "Alice", 3);
// "On January 15, 2024, Alice found 3 files."
// Instance method on String — cleaner syntax
String template = "Hello, %s! You have %d new messages.";
String result = template.formatted("Alice", 5);

StringTokenizer predates String.split() and Pattern. It is retained for backward compatibility But should not be used in new code. It does not support regex, cannot handle empty tokens, and has No way to limit splits.

// LEGACY — do not use
StringTokenizer st = new StringTokenizer("one,two,three", ",");
while (st.hasMoreTokens()) {
System.out.println(st.nextToken());
}
// MODERN — use split or Pattern
String[] parts = "one,two,three".split(",");