Course contents

Essential String methods

The String API you use every day, how a String is laid out in memory since Java 9, and the methods that quietly take a regex or allocate.

FreeLesson12 min readCoreReviewed Oct 2026 · Java 21
On this page
  1. Picture it first: what a String actually is
  2. How it works under the hood: the everyday methods
  3. Modern methods (Java 11 to 15)
  4. StringBuilder vs +
  5. Real world
  6. Advanced: edge cases interviewers like
  7. How to say it in the interview

🧵 What is a Java String?

A String is an immutable sequence of characters. Once "hello" exists, no method can change it: toUpperCase(), replace() and substring() all hand you a new String. Since Java 9 most strings store one byte per character internally, so "hello" costs about 48 bytes on the heap instead of 56.

You'll call String methods more than any other API in Java, which is exactly why interviewers use them as a filter. Nobody expects you to recite the Javadoc. They want to see that you know which methods quietly allocate, which take a regex when you thought they took plain text, and which changed meaning in Java 11.

What you’ll learn

  • How a String is stored: immutable, a byte[] plus a LATIN1/UTF16 coder (compact strings, Java 9)
  • Why length() counts UTF-16 code units and when to use codePoints()
  • The split traps: regex arguments like "." and dropped trailing empty strings
  • strip() vs trim(), plus isBlank, repeat, lines, formatted and text blocks
  • When + is fine (invokedynamic concat, Java 9) and when a loop needs a StringBuilder

For this lesson, we'll focus on the methods you use every day and the internals behind them: how a String is laid out in memory, why split(".") returns nothing, why strip() is not trim(), and when + is fine versus when you need a StringBuilder.

Below the line (out of scope)

The 30-second answer

A String is an immutable, final class backed by a byte[] plus a coder flag: LATIN1 (one byte per char) or UTF16 (two bytes), thanks to compact strings in Java 9. length() counts UTF-16 code units, not the characters a human sees. substring copies since 7u6, split takes a regex and drops trailing empty strings, and strip() is the Unicode-aware replacement for trim(). A single a + b + c compiles to one invokedynamic call, which is fast, but += inside a loop creates a new String every iteration, so loops get a StringBuilder.

01Picture it first: what a String actually is

Think of a String as a sealed envelope. You can read the letter, photocopy it, or write a new letter based on it, but you can never edit the one inside. Because nobody can change it, everyone can safely share it: two threads, a HashMap key, a class name inside the class loader.

Here's what String s = "hello"; looks like on a typical 64-bit HotSpot JVM with compressed references:

Fig. 1

A String object holds a reference to a byte array, a coder flag and a cached hash; with LATIN1 each character of hello takes one byte

A String object holds a reference to a byte array, a coder flag and a cached hash; with LATIN1 each character of hello takes one byte

Notice three things.

  • Two objects, not one. The String itself is a small 24-byte wrapper. The characters live in a separate byte[].
  • The coder decides the width. If every character fits in Latin-1 (code points 0 to 255), the coder is LATIN1 and each char is one byte. A single € or emoji flips the whole string to UTF16, two bytes per char.
  • The hash is cached. hashCode() is computed on the first call and stored in hash. That's one reason Strings make cheap HashMap keys. hashIsZero (Java 13+) covers the rare string whose hash really is 0, so it isn't recomputed on every call.
Note

Compact strings arrived with JEP 254 in Java 9. Before that, value was a char[]: two bytes per character no matter what. Strings are often the largest share of live data in a server heap, so halving ASCII strings was a real memory win. You can turn it off with -XX:-CompactStrings, though there's rarely a good reason to.

02How it works under the hood: the everyday methods

Try it yourself first

Before reading on, predict the result of each line. Pay special attention to the last two.

String s = "Hello, World";

s.length();              // 12
s.charAt(7);             // 'W'
s.indexOf('o');          // 4    (first match)
s.indexOf('o', 5);       // 8    (search starts at index 5)
s.lastIndexOf('o');      // 8
s.indexOf("xyz");        // -1   (not found, never an exception)
s.substring(7);          // "World"
s.substring(0, 5);       // "Hello"  (end index is exclusive)
s.contains("World");     // true
s.startsWith("Hell");    // true
s.toUpperCase();         // "HELLO, WORLD"  (s itself is unchanged)
s.replace('l', 'L');     // "HeLLo, WorLd"

"😀".length();               // 2  (two UTF-16 code units)
"😀".codePointCount(0, 2);   // 1  (one actual character)

length() counts code units, not characters

A Java char is a 16-bit UTF-16 code unit. Characters outside the Basic Multilingual Plane, like most emoji, need two of them (a surrogate pair). So "😀".length() is 2, and charAt(0) returns half an emoji. If you're counting what a user typed, for a 280-character limit say, use codePoints().count() or codePointCount.

substring copies (since 7u6)

Before Java 7 update 6, substring returned a new String that shared the parent's array using an offset and a count. That was O(1), but it caused a famous leak: take a 10-character substring of a 5 MB HTTP response, keep it in a cache, and the whole 5 MB array stays alive. Since 7u6 substring copies only the characters it needs. It costs O(n) in the length of the result, and the leak is gone.

Tip

If you see new String(s.substring(...)) in an old codebase, that was the workaround for the leak. On any modern JDK it only adds an extra copy, so you can remove the wrapper.

split takes a regex, and drops trailing empties

This is the method that bites people most. The argument is a regular expression, and by default trailing empty strings are removed from the result.

"a.b.c".split(".");        // []  : "." matches every char, every piece is empty, all dropped
"a.b.c".split("\\.");      // [a, b, c]
"a|b".split("|");          // [a, |, b]  : "|" is an alternation of two empty patterns
"a,b,,".split(",");        // [a, b]       trailing empties removed
"a,b,,".split(",", -1);    // [a, b, , ]   a negative limit keeps them
",a".split(",");           // [, a]        a leading empty is kept
"".split(",");             // [""]         one element, not zero

// When the delimiter comes from config or user input, quote it:
line.split(Pattern.quote(delimiter));
Where candidates slip

Many candidates parse CSV with line.split(",") and silently lose columns: the row id,name,, comes back with 2 fields instead of 4, and code that reads parts[3] throws ArrayIndexOutOfBoundsException, but only on rows whose last columns are empty. Use split(",", -1) when the column count matters, and a real CSV parser once quoted fields are involved.

split has a fast path: a single character that isn't a regex metacharacter (like ","), or a backslash plus one non-alphanumeric character (like "\\."), skips the regex engine entirely. Anything else, including "\\s+", compiles a new Pattern on every call. In a hot loop, compile it once with private static final Pattern WS = Pattern.compile("\\s+"); and call WS.split(line).

join: the other direction

String.join(", ", List.of("a", "b", "c"));   // "a, b, c"
String.join("/", "usr", "local", "bin");     // "usr/local/bin"
// With a prefix and suffix, use the stream Collector:
names.stream().collect(Collectors.joining(", ", "[", "]"));   // "[a, b, c]"

03Modern methods (Java 11 to 15)

Try it yourself first

Before reading on: what do "\u2003hi\u2003".trim() and "\u2003hi\u2003".strip() return? (\u2003 is an "em space", common in text pasted from a word processor or a web page.)

String padded = "\u2003hi\u2003";
padded.trim();          // "\u2003hi\u2003"  trim only removes chars <= U+0020
padded.strip();         // "hi"              strip uses Character.isWhitespace (Java 11)
"  ".isBlank();         // true    only whitespace (Java 11)
"  ".isEmpty();         // false   length is 2
"ab".repeat(3);         // "ababab" (Java 11)
"a\nb\r\nc".lines();    // Stream of "a", "b", "c" (Java 11), any line terminator
"%s=%d".formatted("x", 5);   // "x=5" (Java 15), same as String.format
"hello".chars()              // IntStream of UTF-16 code units (Java 8)
       .filter(c -> c == 'l')
       .count();             // 2

strip() vs trim(). trim() dates from Java 1.0 and removes every character whose value is at most U+0020 (the space plus ASCII control characters). strip(), stripLeading() and stripTrailing() arrived in Java 11 and remove whatever Character.isWhitespace treats as whitespace, which includes Unicode spaces like U+2003. Default to strip() in new code.

Watch out

Neither method removes the non-breaking space U+00A0, because Java deliberately doesn't classify it as whitespace. If you clean scraped HTML, you'll need s.replace('\u00A0', ' ').strip() or a regex with \\h.

Text blocks (final in Java 15) are multi-line literals. The closing """ sets the indentation baseline, incidental indentation is removed, and a trailing \ joins two lines into one:

String json = """
    {
      "id": 42,
      "name": "Atlas"
    }
    """;   // the closing delimiter's column sets the left margin

String sql = """
    SELECT id, name \
    FROM users \
    WHERE active = true""";   // one line: "SELECT id, name FROM users WHERE active = true"

A text block is an ordinary String at runtime. It's purely a compile-time convenience, so it lands in the string pool like any other literal.

04StringBuilder vs +

Try it yourself first

Before reading on, estimate roughly how many characters get copied when this loop builds a string of 10,000 numbers.

// Looks harmless. It is O(n²).
String csv = "";
for (int i = 0; i < 10_000; i++) {
    csv += i + ",";        // allocates a brand-new String every iteration
}

// Here's how I'd write it: one growing buffer, amortised O(n).
StringBuilder sb = new StringBuilder(50_000);   // pre-size when you can estimate
for (int i = 0; i < 10_000; i++) {
    sb.append(i).append(',');
}
String result = sb.toString();

Each += copies everything built so far into a new String. The final string is about 49,000 characters, so the average copy is around 24,000 characters, and 10,000 of them add up to roughly 240 million character copies plus 10,000 garbage Strings. The builder does a handful of array resizes. On a recent laptop with JDK 21+, the += loop takes 15 to 40 ms and the builder well under 1 ms, and the gap grows quadratically with the size.

What about + outside loops? Since Java 9 (JEP 280), javac compiles a + ":" + b + ":" + c to a single invokedynamic instruction bootstrapped by StringConcatFactory.makeConcatWithConstants. At runtime the JVM builds a strategy that computes the exact final length, allocates once and copies once. That's as good as a hand-written StringBuilder and often better, because it never over-allocates. So write + for one-off expressions, and keep StringBuilder for loops and for strings assembled across several method calls.

Note

StringBuffer is the old synchronized twin of StringBuilder. A string being built almost always lives in one thread, so the locking buys nothing. Use StringBuilder unless you're maintaining very old code.

05Real world

In the real world

Here's a pattern that shows up in profilers again and again: a batch job that tokenises log lines with line.split("\\s+"). Because "\\s+" is not on the fast path, every call compiles a fresh Pattern. At 50 million lines per run, Pattern.compile can become one of the top frames in the flame graph. Hoisting it into a static final Pattern is a one-line fix with a measurable win.

A few production habits that come from the same place:

  • Pre-size builders when you know the rough output size. The default capacity is 16 characters, and each resize roughly doubles the array and copies everything.
  • Prefer indexOf, startsWith(prefix, offset) and regionMatches over creating substrings only to compare them. Every substring is a fresh allocation now.
  • Pass a locale to case conversion of identifiers. In a Turkish locale, "TITLE".toLowerCase() gives "tıtle" with a dotless ı, which then fails to match the constant "title". Use toLowerCase(Locale.ROOT) for anything that isn't shown to a human.

06Advanced: edge cases interviewers like

Why is String immutable at all? There are four reasons, and naming all four is what sounds senior:

  1. Security. File paths, URLs and class names are Strings. If a caller could change "/safe/file" after a permission check had passed, the check would be meaningless.
  2. Hash caching. The content never changes, so the hash is computed once.
  3. The string pool. Literals can be shared across the whole JVM only because nobody can modify them.
  4. Thread safety for free. You can share a String between threads without any synchronization.

What does compareTo compare? UTF-16 code units, lexicographically. It returns the difference between the first pair of differing chars, or the difference in lengths if one is a prefix of the other: "apple".compareTo("banana") is -1 and "ab".compareTo("abcd") is -2. Uppercase sorts before lowercase ("B".compareTo("a") is -31). For sorting text people read, use a Collator.

Performance angle

String.hashCode() is s[0]*31^(n-1) + ... + s[n-1], computed once per instance and cached. Looking up the same String instance in a HashMap again costs one field read for the hash. A new String with the same content (from substring, say, or a network read) pays the O(n) computation on its first use.

Method Since Gotcha
split(regex) 1.4 regex argument, trailing empties dropped
substring 1.0 copies since 7u6, end index exclusive
strip, isBlank, repeat, lines 11 Unicode-aware, prefer strip over trim
formatted 15 uses the default locale
text blocks 15 closing """ sets the margin
chars() 8 code units, not code points

07How to say it in the interview

When I ask about String methods, I'm listening for three things: do you know a String is immutable and why, do you know which calls take a regex, and do you know the cost of + versus StringBuilder. Mentioning compact strings and the invokedynamic concatenation change tells me you've kept up since Java 8.

In the interview

"String is immutable and final. Since Java 9 it's backed by a byte array with a coder flag, so Latin-1 text takes one byte per character. length() counts UTF-16 code units, so an emoji counts as two. The methods I'm careful with are split, which takes a regex and drops trailing empty strings, and trim, which I replace with strip because strip understands Unicode whitespace. For concatenation, a single expression with plus compiles to an invokedynamic call that's as fast as a StringBuilder, but inside a loop I always use a StringBuilder, because += there is quadratic."

On the whiteboard

String = final, immutable
  value: byte[]   coder: LATIN1 (1 B) | UTF16 (2 B)   Java 9, JEP 254
  hash cached on first hashCode()
length() = UTF-16 units (emoji = 2) → codePoints()
substring → copies (7u6+)
split(regex): "." → []   use "\\." or Pattern.quote
              trailing "" dropped → split(s, -1)
strip() over trim()   isBlank, repeat, lines (11)   formatted, """ (15)
a + b + c → invokedynamic StringConcatFactory (9)
+= in a loop → O(n²) → StringBuilder
Practise this topicStrings basics: interview questions & scenarios
Further reading (6)