Java Strings Deep Dive: Immutability, the String Pool, and StringBuilder
Strings are the most-used object type in almost every Java codebase โ and one of the most misunderstood. Why does == sometimes work on strings and sometimes not? Why does everyone warn against + in loops? What actually happens when you โmodifyโ a string?
All three questions have the same answer at their root: String is immutable. Once you really get that, the rest of Javaโs string behavior stops being trivia and starts being obvious.
Immutability: The Design Decision Everything Else Follows From
A Java String can never be changed after creation. Every method that looks like it modifies a string actually returns a new string:
String name = "java";name.toUpperCase();System.out.println(name); // "java" โ unchanged!
name = name.toUpperCase();System.out.println(name); // "JAVA" โ reassigned to a new objectCalling toUpperCase() and discarding the result is a classic beginner bug โ the IDE will usually warn โresult of toUpperCase is ignored,โ a warning worth respecting.
Why did Javaโs designers make strings immutable? Three practical reasons:
- Safety. Strings are used as map keys, file paths, class names, and security identifiers. If a string could mutate after being used as a
HashMapkey, the map would silently corrupt (the keyโs hash would no longer match its bucket). Immutability makes strings trustworthy. - Thread-safety for free. Immutable objects can be shared across threads without locks. In server code handling thousands of concurrent requests, that matters enormously.
- The string pool โ which deserves its own section.
The String Pool (and Why == Lies to You)
Because strings canโt change, the JVM can safely share them. Identical string literals in your code all point to a single pooled object:
code heap โโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโ String a = "cat"; โโโ โโโโถ "cat" (string pool) String b = "cat"; โโโ String c = new String("cat"); โโโถ "cat" (separate object, outside pool)Which explains this classic:
String a = "cat";String b = "cat";String c = new String("cat");
System.out.println(a == b); // true โ same pooled objectSystem.out.println(a == c); // false โ different objectsSystem.out.println(a.equals(c)); // true โ same characters== compares references. Literals share a reference, so == appears to work โ until a string arrives from user input, a file, or concatenation at runtime, and suddenly it doesnโt. The bug hides in testing (where you compare literals) and detonates in production.
The rule, permanently: compare string contents with .equals(), or .equalsIgnoreCase() when case doesnโt matter. And when the string might be null, put the literal first โ "yes".equals(input) canโt throw a NullPointerException; input.equals("yes") can.
(Also: never write new String("cat") โ it creates a pointless duplicate of an already-pooled string. Its main use in the wild is interview questions.)
The Methods Youโll Actually Use
String has dozens of methods; these cover 95% of real work:
String s = " Hello, Java World ";
s.length() // 21 (includes spaces)s.trim() // "Hello, Java World" โ or strip(), which is Unicode-awares.toLowerCase() // " hello, java world "s.contains("Java") // trues.startsWith(" He") // trues.indexOf("Java") // 9 (-1 if absent)s.replace("Java", "Java 21") // replaces ALL occurrences (plain text, not regex)s.substring(2, 7) // "Hello" โ start inclusive, end exclusives.charAt(2) // 'H's.isBlank() // false โ true for empty or whitespace-only (Java 11+)s.repeat(2) // doubles the string (Java 11+)Splitting and joining:
String csv = "red,green,blue";String[] parts = csv.split(","); // careful: split takes a REGEXString joined = String.join(" | ", parts); // "red | green | blue"The regex detail bites people: "a.b.c".split(".") returns an empty array, because . means โany characterโ in regex. You want split("\\."). Same for |, *, +. If your split behaves bizarrely, this is why.
substringโs half-open interval ([start, end)) follows the same convention as everything in Java โ substring(i, j) has length j - i, and s.substring(0, s.length()) is the whole string. Internalize half-open ranges once; theyโre everywhere.
Concatenation, and Why + in a Loop Is a Performance Bug
For a handful of pieces, + is fine โ the compiler optimizes single-expression concatenation:
String msg = "Order " + orderId + " shipped to " + city; // perfectly fineThe trouble is loops. Since strings are immutable, each += builds a brand-new string, copying everything so far:
String report = "";for (Order o : orders) { report += o.summary() + "\n"; // copies the ENTIRE report every iteration}Thatโs O(nยฒ) work โ 10,000 orders means roughly 50 million character copies. The fix is StringBuilder, a mutable character buffer:
StringBuilder report = new StringBuilder();for (Order o : orders) { report.append(o.summary()).append('\n');}String result = report.toString();Linear time, one final string. The rule of thumb: + for fixed, small concatenations; StringBuilder whenever you build a string incrementally in a loop. (You may see StringBuffer in old code โ itโs the synchronized, slower ancestor; effectively never needed today.)
Often the cleanest answer is neither โ itโs String.join or a stream collector:
String result = orders.stream() .map(Order::summary) .collect(Collectors.joining("\n"));Formatting
For anything with placeholders, String.format/formatted beats concatenation for readability:
String line = String.format("%-10s %8.2f %5d", name, price, qty);String msg = "Hello, %s. You have %d alerts.".formatted(user, count); // Java 15+%s string, %d integer, %.2f two-decimal float, %-10s left-pad to width 10. If youโre aligning columns or formatting currency-ish output, this is the tool.
Text Blocks: Multi-Line Strings Without the Pain (Java 15+)
Before text blocks, embedding JSON or SQL in Java was an escape-character nightmare:
String json = "{\n \"name\": \"Ada\",\n \"role\": \"engineer\"\n}";Text blocks fix it with triple quotes:
String json = """ { "name": "Ada", "role": "engineer" } """;The compiler strips the common leading whitespace (determined by the least-indented line, including the closing """), so your Java indentation doesnโt leak into the string. Quotes inside need no escaping. For SQL, JSON, HTML snippets, and test fixtures, text blocks are strictly better โ if youโre on Java 15+, stop writing \n chains.
char, Unicode, and a Warning About length()
A Java char is a 16-bit UTF-16 code unit โ which is not the same as โa characterโ in the human sense. Characters outside the Basic Multilingual Plane (most emoji, many historic scripts) occupy two chars:
String s = "Hi๐";System.out.println(s.length()); // 4, not 3System.out.println(s.codePointCount(0, s.length())); // 3 โ actual code pointsFor everyday ASCII-ish text, length() and charAt() behave as expected. But if your application handles emoji or international text (it does), be careful about slicing strings at arbitrary indexes โ you can cut a character in half. When correctness matters, iterate code points (s.codePoints()) rather than chars. Most developers learn this the day an emoji corrupts their output; now you know early.
Practical Patterns Worth Memorizing
Null-safe emptiness check:
if (input == null || input.isBlank()) { ... } // order matters โ short-circuitReverse a string (interview staple โ note it needs StringBuilder, since String has no reverse):
String reversed = new StringBuilder(s).reverse().toString();Count occurrences of a character:
long spaces = s.chars().filter(c -> c == ' ').count();Case-insensitive comparison without allocating: a.equalsIgnoreCase(b) โ not a.toLowerCase().equals(b.toLowerCase()), which creates two throwaway strings and mishandles some locales (the Turkish dotless-ฤฑ problem is real and has broken real systems).
Parsing numbers out of strings โ see the data types guide; remember Integer.parseInt throws on bad input, so guard user-provided data.
How Strings Show Up in Interviews and Code Review
A short field report from both sides of the table, because strings are disproportionately represented in both settings.
Interview patterns built on this chapter. Reverse a string (StringBuilder, or a two-pointer char array swap), check for palindromes (two pointers meeting in the middle), find the first non-repeating character (one pass to count with an array or map, second pass to find), group anagrams (sort each wordโs characters to form a canonical key for a HashMap), and string compression (โaaabbโ โ โa3b2โ โ StringBuilder plus a run counter). Every one of them is testing the same two things: do you know strings are immutable (so you build results in a StringBuilder or array, not by repeated concatenation), and can you index into a string without off-by-one errors. If you can implement substring semantics from memory โ start inclusive, end exclusive โ the index questions mostly solve themselves.
Code review patterns. The recurring string comments in real pull requests, in rough order of frequency: == where .equals() was meant; concatenation in a loop (flagged by any decent static analyzer these days); split() with an unescaped regex metacharacter; manual newline concatenation where a text block would be clearer; and toLowerCase().equals(...) where equalsIgnoreCase is both faster and locale-safer. None of these are exotic โ theyโre the everyday friction of the language, which is exactly why internalizing them early pays off daily.
A note on performance intuition. Developers new to Java sometimes micro-optimize strings in the wrong places โ interning everything, avoiding + even for two operands, pre-sizing every StringBuilder. Modern JVMs make simple concatenation of a few pieces essentially free (the compiler emits an optimized construction, not naive copying). The only string performance rule that matters in application code is the loop rule: incremental building goes through StringBuilder or a stream collector. Save the rest of your optimization budget for I/O and algorithms, where it buys something measurable.
Frequently Asked Questions
How much memory does a String use? Since Java 9โs compact strings, Latin-1-only strings store one byte per character; strings containing any character beyond Latin-1 use two. Add roughly 40 bytes of object overhead (header, length, coder flag, array header). For most applications this is trivia โ until you hold millions of strings in a cache, at which point deduplication (-XX:+UseStringDeduplication with G1) and interning strategies become real levers.
What does intern() do, and should I use it? It returns the pooled instance of a stringโs content, adding it to the pool if absent โ manually extending what literals get automatically. Legitimate uses are rare (massive datasets with heavy value repetition, where identity comparison becomes profitable); casual interning adds pool pressure and complexity for nothing. If youโre reaching for intern() to make == work, the actual fix is .equals().
Why is String final? So no subclass can override its methods to mutate behavior โ which would break the pool (shared instances must be safe to share), break HashMap keys, and break every security check that validates a string then uses it. Immutability guarantees are only as strong as the classโs inability to be subverted; final closes the subclassing door. Itโs a design lesson in miniature: the immutability guide generalizes it.
String.format vs concatenation vs text blocks โ which for building SQL? None of them, for values: parameterized queries (PreparedStatement placeholders) are non-negotiable, because string-built SQL is how injection vulnerabilities happen. Text blocks are excellent for the static SQL skeleton; parameters carry the data. The same principle applies to HTML and shell commands โ structure via strings, data via safe channels.
Are Java strings UTF-8? In memory theyโre conceptually UTF-16 (with the compact-strings optimization underneath); UTF-8 enters when you encode for the outside world: s.getBytes(StandardCharsets.UTF_8). Always name the charset explicitly at I/O boundaries โ the platform-default overloads have caused decades of mojibake, which is why Java 18 finally made UTF-8 the default everywhere.
Self-Check
Predict, then verify:
String a = "ja" + "va";String b = "java";String c = new StringBuilder("ja").append("va").toString();
System.out.println(a == b); // ?System.out.println(b == c); // ?System.out.println(b.equals(c)); // ?System.out.println("A.B.C".split(".").length); // ?String s = "hello";s.concat(" world");System.out.println(s); // ?Answers: true (compile-time constant folding pools "ja" + "va" as "java"), false (runtime-built string, not pooled), true, 0 (regex dot!), hello (immutability โ the concat result was discarded).
Five for five means you genuinely understand Java strings โ pool, immutability, and the regex trap included. Next up, the series moves into Classes and Objects, where you stop using Javaโs types and start designing your own.