Strings

Immutability, what a String is on the heap, the pool, StringBuilder, formatting — and why == on strings is a bug that passes tests.

14 min read Java Fundamentals

Strings are the type you use most and think about least. Four facts about them explain a surprising number of bugs and performance problems: they are immutable, they are objects with a specific shape in memory, literals are pooled, and building them in a loop is quadratic if you do it the obvious way. This lesson goes one level further than the usual list of rules: what a String actually is on the heap, why == passes every test and fails in production, what a loop of += costs in bytes, and where length() stops telling the truth.

Immutable

A String cannot be changed. Every method that looks like it modifies one — toUpperCase, trim, replace, substring — returns a new String and leaves the original alone.

java
String s = "  hello ";
s.trim();                 // returns "hello", which is thrown away
System.out.println(s);    // "  hello " — unchanged
s = s.trim();             // this is the version that does something

Immutability is why strings are safe to share between threads, safe as HashMap keys (the hash cannot change under the map), and safe to pass to code you do not trust. It is enforced, not conventional: the class is final, its character array is private final, and no method exposes it. It is also why the hash code can be cached: computed once, stored in a field, never recomputed.

Under the hood: what a String is

Open java.lang.String in the JDK and the whole object is three fields:

java.lang.String, the fieldsjava
private final char[] value;     // the characters, two bytes each
private int hash;               // cached hashCode(); 0 until first computed

Two fields, and the first is a char[]: two bytes per character, whatever the text. A later Java stores Latin-1 text one byte per character with a flag to say which — it halves the memory of most strings and is covered with the rest of what that release added. On this baseline every character costs two bytes, including the ninety-odd percent of strings that are ASCII.

hash is worth its own sentence. It is computed on first call and cached, so hashCode() on a long string is expensive once and free after — which is why a String is a good HashMap key. The cache has a hole: 0 is the "not computed yet" marker and a legitimate hash value, so a string whose hash really is 0 is recomputed on every call. The empty string is the one you will meet.

The memory cost of a short string is mostly overhead. "hello" on a 64-bit JVM with compressed pointers:

ObjectBytesWhat
String2412-byte header + value reference (4) + hash (4), padded to 8
char[5]3216-byte array header + 5 × 2 bytes, padded to 8
Total56for five characters of text

Measured over 2,000,000 distinct five-character strings, not derived: 56.0 bytes. An empty String is 40; a char[16] is 48.

Ten million short strings is over half a gigabyte, most of it headers rather than text — 56 bytes to hold ten characters' worth of data. That is why a service that holds a large in-memory cache of strings feels the size of its keys, and why -XX:+UseStringDeduplication (G1, JEP 192) exists: it finds String objects whose byte[] contents are equal and makes them share one array, at the cost of a background scan.

stack String a String b String c heap String (pooled) value ──▶coder = LATIN1hash = 99162322 String (new) value ──▶coder = LATIN1hash = 0 (not yet) byte[5] h e l l obyte[5] h e l l o a == b true a == c false a.equals(c) true
Two literals, one object. new String("hello") is a second object with an equal array; equals reads the arrays, == compares the arrows.

== versus equals, and the pool

== compares references: are these the same object? equals compares content: same length, same coder, same bytes. Two strings with the same characters are usually different objects:

java
String a = "java";
String b = "java";
String c = new String("java");
String d = a.substring(0, 4);
final String ja = "ja";
String e = ja + "va";                 // constant expression: folded by javac into "java"
String f = a.substring(0, 2) + "va";  // computed at runtime
 
a == b            // true  — both literals, both from the pool
a == c            // false — new String allocates
a == d            // false — substring allocates
a == e            // true  — javac folded it to the literal "java"
a == f            // false — built at runtime
a.equals(c)       // true, and so are all the others

javac puts every string literal, and every constant expression that evaluates to one (JLS §15.28: literals, final variables initialised with constants, and + between them), into the class's constant pool. At class load the JVM interns them into a JVM-wide table, the string pool, so identical literals across your whole application are one object. Since Java 7 the pool lives on the heap, is garbage-collected, and is a fixed-size hash table (-XX:StringTableSize, 65,536 buckets by default) that String.intern() can add to at runtime.

That is why line one of the trap works. Literal comparisons with == succeed, so the bug passes every unit test that uses literals and fails in production where the string came from a request body, a database row or a file, all of which are built at runtime and never pooled. The rule has no exceptions: equals, always. For a possibly-null left side, Objects.equals(a, b) or "literal".equals(variable).

intern() is occasionally useful for deduplicating a few million repeated runtime strings by hand, at the cost of a lookup per string in a table that does not grow. It is never a reason to use ==, because the one caller who forgets to intern breaks every comparison.

literalsame literalnew Stringc.intern()
objects so far1a == b-a == c-

literal. String a = "hello". The literal is in the class file's constant pool; on first use the JVM interns it, and every later literal with the same text gets that instance.

1 / 4

Walkthrough: the status check that passed every test

A payment service marks orders paid:

OrderService.javajava
public void onPaymentEvent(PaymentEvent e) {
    Order order = orders.find(e.orderId());
    if (order.status() == "PENDING") {          // the bug
        order.markPaid();
        orders.save(order);
    }
}

Trace what order.status() is in each environment:

  1. Unit test. The test builds new Order(..., "PENDING") with a literal. The literal is interned at class load; the == compares two references to the pooled "PENDING"; true. Test passes. Coverage says the branch was taken.
  2. Integration test with H2. The JDBC driver reads the status column and calls new String(bytes, charset), which allocates a fresh object. The == is false. But the integration test seeded the row through the same Order object and checked save was called, which it was, because the test framework's stub returned the literal. Passes.
  3. Production. PostgreSQL returns the row, the driver allocates a runtime string, == is false, markPaid never runs, and every paid order stays PENDING. No exception, no log line, no stack trace. The first signal is a customer.

The fix is one token, "PENDING".equals(order.status()), and the lesson is the shape of the bug: identity comparison looks like equality exactly when the data comes from the code, and the data in production never does. Modern compilers and IDEs warn on == between strings; make that warning an error in the build.

Building strings

+ on strings is not a String operation at all. javac rewrites it:

javap -c on a + b + n + "!"plaintext
new           java/lang/StringBuilder
invokespecial StringBuilder."<init>"
invokevirtual StringBuilder.append   (once per piece)
invokevirtual StringBuilder.toString

A StringBuilder you did not write, allocated at capacity 16 — so a result longer than sixteen characters reallocates and copies, inside what looked like one expression. (A later Java replaces this with a single invokedynamic that sizes the result exactly; that is the enhancement, not the baseline.)

For one expression that is fine. In a loop it is not one expression, it is one expression per iteration:

java
String csv = "";
for (String field : fields) csv += field + ",";   // O(n²): copies the whole string every time

Each += creates a new String containing everything so far plus a little more. Put numbers on it: 10,000 fields of 10 characters. Iteration k copies a string of about 11k bytes. The total copied is 11 × (1 + 2 + … + 10,000) ≈ 11 × 50 million = 550 MB of copying, and 10,000 short-lived arrays for the collector, to produce a 110 KB string. At 100,000 fields it is 55 GB. Use a builder, or better, the method that exists for it:

java
StringBuilder sb = new StringBuilder(fields.size() * 12);   // pre-size when you can guess
for (String field : fields) sb.append(field).append(',');
String csv = sb.toString();
 
String csv = String.join(",", fields);                    // clearer, same cost
String csv = fields.stream().collect(Collectors.joining(","));

StringBuilder grows its array by doubling, so the total copying is linear, and toString() copies once more into the final String. It is not thread-safe; StringBuffer is, and you almost never want it — a builder shared between threads is a design mistake, not a synchronisation problem.

substring, and the leak that used to live here

substring is O(n): it copies. That is worth knowing because it was not always true, and the reason it changed is the best memory-leak story in the JDK.

Before Java 7u6, a substring shared its parent's char[] and kept an offset and a length into it. Taking a five-character substring was O(1) and free — and it held the entire parent array alive for as long as you kept it. Parse a one-megabyte log line, keep the eight-character request id, and you have kept the megabyte:

java
String id = hugeLine.substring(0, 8);   // pre-7u6: retains all of hugeLine

A cache of a million such ids was a gigabyte of log lines nobody could see in a heap dump, because the Strings all looked tiny. The workaround people learned was new String(hugeLine.substring(0, 8)), which forced a copy.

Since 7u6 substring copies, so the leak is gone and the workaround is now pointless — but the cost moved: a loop that walks a string by repeatedly taking substrings is now quadratic. Use indices and charAt, or StringBuilder, or a single split.

verified on this baselineplaintext
big.value.length   = 1000000
small.value.length = 5          a copy

Formatting

java
String.format("%s ordered %d items for %.2f", name, count, total);
"%s ordered %d items".formatted(name, count);           // Java 15+
MessageFormat.format("{0} ordered {1} items", name, count);

String.format parses the pattern on every call and allocates a Formatter; in a hot log statement that matters, which is why logging frameworks use {} placeholders that are only formatted if the level is enabled:

java
log.debug("Processing order {} for {}", orderId, customerId);  // no work if debug is off
log.debug("Processing order " + orderId);                        // concatenates regardless

Characters, code points and encoding

A char is a 16-bit UTF-16 code unit, not a character. Unicode has over a million code points and UTF-16 has 65,536 units, so everything beyond the first 65,536 (emoji, many CJK characters, historic scripts) is two units, a surrogate pair. Walk one string:

Expression on "👍🏽" (thumbs up, medium skin tone)ResultWhy
length()4two code points, each a surrogate pair
codePointCount(0, length())2U+1F44D and U+1F3FD
charAt(0)\uD83Dhalf a character, meaningless alone
substring(0, 1)a lone surrogatebroken text if it is ever written out
new StringBuilder(s).reverse()still validreverse knows about surrogate pairs
grapheme clusters1what a person sees; java.text.BreakIterator counts these

codePoints(), codePointAt and Character.isLetter(int) handle this correctly; charAt, length and index arithmetic do not. A "truncate to 20 characters" that slices at charAt will eventually cut an emoji in half and send an invalid string to a client.

Bytes are a separate concern. getBytes() without a charset uses the platform default, which differed between a Windows laptop and a Linux container until Java 18 made UTF-8 the default everywhere (JEP 400). Always say which: s.getBytes(StandardCharsets.UTF_8), new String(bytes, StandardCharsets.UTF_8).

Try it yourself

Predict the output

java
String a = "code";
String b = "co" + "de";
String c = new StringBuilder("co").append("de").toString();
String d = c.intern();
final String co = "co";
String e = co + "de";
System.out.println((a == b) + " " + (a == c) + " " + (a == d) + " " + (a == e));
Answer

true false true true. b and e are constant expressions, folded by javac into the literal "code", so they are the pooled object. c is built at runtime and is a new object. d is c.intern(), which returns the pooled "code" because the literal a already put one there. Change final String co to String co and e becomes a runtime concatenation: a == e turns false.

Estimate before you fix

A log line builds a message from 2,000 fragments with += inside a loop, average fragment 25 characters. Roughly how many bytes does the loop copy, and what does it become with a StringBuilder?

Answer

Iteration k copies about 25k bytes; the sum over 2,000 iterations is 25 × (2,000 × 2,001 / 2) ≈ 50 MB copied to produce a 50 KB string, plus 2,000 garbage arrays. A StringBuilder that doubles copies at most about twice the final size, roughly 100 KB, and one final copy in toString(). The ratio is about 500 to 1, and it grows with the fragment count.

Cut it safely

Write truncate(String s, int maxCodePoints) that never splits a surrogate pair.

Answer
java
static String truncate(String s, int maxCodePoints) {
    int count = s.codePointCount(0, s.length());
    if (count <= maxCodePoints) return s;
    int end = s.offsetByCodePoints(0, maxCodePoints);   // index of the (max+1)th code point
    return s.substring(0, end);
}

offsetByCodePoints walks the string by code points and returns a char index that always sits on a boundary, so substring cannot land inside a pair. It still can split a grapheme cluster (the skin-tone modifier from its base); when what a person sees matters, walk with BreakIterator.getCharacterInstance() instead.

Misconceptions

  • "Strings are immutable, so += is fine — Java optimises it." It optimises a single expression into one concatenation. A loop is many expressions, and each one copies everything so far. The optimisation is real and it is not the one you need.
  • "== works on strings; I have seen it work." It works on literals, because literals are pooled. It fails on anything built at runtime, which is all real data. The times you saw it work were tests.
  • "intern() makes == safe, and it is faster." Only if every string on both sides was interned, and one caller who forgets breaks it silently. equals on two strings of different length is one comparison; intern is a hash-table lookup and an insert. It is a memory tool, not a speed tool.
  • "length() is the number of characters." It is the number of UTF-16 units. For ASCII they coincide; for emoji and many scripts they do not.
  • "StringBuffer is the safe choice." It is the synchronised one, and a builder shared between threads is a design problem that synchronisation hides. Use StringBuilder and keep it local.

Going deeper

  • JLS §3.10.5 (string literals) and §15.28 (constant expressions): the exact rules for what javac folds and pools.
  • -XX:+UseStringDeduplication with G1 (JEP 192, available here from 8u20): it makes equal char[]s share one array, at the cost of a background scan.
  • new BigDecimal(aDouble).toPlainString() on any float you do not trust, and javap -c on a method that concatenates.
  • The source: java/lang/String.java in the JDK, where every method above is a few dozen readable lines — hashCode, substring and split especially.
  • Where this goes next, when you reach the releases that added it: JEP 254 (compact strings), JEP 280 (indified concatenation), JEP 378 (text blocks), JEP 400 (UTF-8 by default).
Progress is saved on this device and to your account when signed in.