Strings
Immutability, what a String is on the heap, the pool, StringBuilder, formatting — and why == on strings is a bug that passes tests.
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.
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 somethingImmutability 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:
private final char[] value; // the characters, two bytes each
private int hash; // cached hashCode(); 0 until first computedTwo 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:
| Object | Bytes | What |
|---|---|---|
String | 24 | 12-byte header + value reference (4) + hash (4), padded to 8 |
char[5] | 32 | 16-byte array header + 5 × 2 bytes, padded to 8 |
| Total | 56 | for 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.
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:
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 othersjavac 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.
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.
same literal. String b = "hello", anywhere in the program. No new object -- the pool already has it, so b is the very same instance as a.
new String. String c = new String("hello"). You asked for an object, so you get one: a second String with its own char[] holding equal text.
c.intern(). intern() looks the text up in the pool and returns what is already there -- so c.intern() == a is true. Note what did NOT happen: c is unchanged and a == c is still false. intern() returns a reference, it does not move an object into the pool.
Walkthrough: the status check that passed every test
A payment service marks orders paid:
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:
- 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. - 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 sameOrderobject and checkedsavewas called, which it was, because the test framework's stub returned the literal. Passes. - Production. PostgreSQL returns the row, the driver allocates a runtime string,
==is false,markPaidnever runs, and every paid order staysPENDING. 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:
new java/lang/StringBuilder
invokespecial StringBuilder."<init>"
invokevirtual StringBuilder.append (once per piece)
invokevirtual StringBuilder.toStringA 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:
String csv = "";
for (String field : fields) csv += field + ","; // O(n²): copies the whole string every timeEach += 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:
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:
String id = hugeLine.substring(0, 8); // pre-7u6: retains all of hugeLineA 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.
big.value.length = 1000000
small.value.length = 5 a copyFormatting
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:
log.debug("Processing order {} for {}", orderId, customerId); // no work if debug is off
log.debug("Processing order " + orderId); // concatenates regardlessCharacters, 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) | Result | Why |
|---|---|---|
length() | 4 | two code points, each a surrogate pair |
codePointCount(0, length()) | 2 | U+1F44D and U+1F3FD |
charAt(0) | \uD83D | half a character, meaningless alone |
substring(0, 1) | a lone surrogate | broken text if it is ever written out |
new StringBuilder(s).reverse() | still valid | reverse knows about surrogate pairs |
| grapheme clusters | 1 | what 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
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
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.equalson two strings of different length is one comparison;internis 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. - "
StringBufferis the safe choice." It is the synchronised one, and a builder shared between threads is a design problem that synchronisation hides. UseStringBuilderand keep it local.
Going deeper
- JLS §3.10.5 (string literals) and §15.28 (constant expressions): the exact rules for what
javacfolds and pools. -XX:+UseStringDeduplicationwith G1 (JEP 192, available here from 8u20): it makes equalchar[]s share one array, at the cost of a background scan.new BigDecimal(aDouble).toPlainString()on any float you do not trust, andjavap -con a method that concatenates.- The source:
java/lang/String.javain the JDK, where every method above is a few dozen readable lines —hashCode,substringandsplitespecially. - 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).