9.1
Strings: The Pool and Immutability
A String is a character array with a wrapper around it. Two design choices make it behave unlike anything else in Java, and they finally explain the == answer that surprised you in Phase III.
Previously on
Two things have been left open for a long time.
In Section 3.2 you compared two Strings with ==. Sometimes it said true, sometimes false, for the same text. You were told the reason was coming in this phase.
In Section 8.2 you built an immutable class yourself, and you were told that String is one, and that the String Pool depends on it.
Both answers are here.
The problem
You have used String since your first program. Here are three things it does that no other type does.
One. Two Strings with the same text are sometimes the same object and sometimes not.
String a = "hello";
String b = "hello";
String c = new String("hello");
System.out.println(a == b); // true
System.out.println(a == c); // falseTwo. You can create one without new.
String s = "hello"; // no new anywhere. Yet it is an object.Every other object in Java needs new. This one does not.
Three. Methods that look like they change it do not.
String name = "atlas";
name.toUpperCase();
System.out.println(name); // atlasNothing happened. No error either.
All three come from two design decisions, and both were made for good reasons.
The idea
What a String actually is
A String is a sequence of characters. And a sequence of things, all the same type, is an array.
So a String is a char array with a class wrapped around it.
That raises a fair question: why not use char[] everywhere and skip the class?
Because a bare array gives you nothing. To compare two of them you would write the loop yourself. To join them, another loop. To pull out part of one, another. To search inside one, another. Every program would rewrite the same handful of algorithms.
String wraps the array and gives you all of that. You lose direct access to the characters and you gain about fifty methods.
Decision one: a String can never change
String is an immutable class, built exactly the way you built one in Section 8.2. The class is final. The array inside is private final. There are no setters.
So every method that seems to modify a String actually builds a new one:
String name = "atlas";
name.toUpperCase(); // makes a new String. Result thrown away.
name = name.toUpperCase(); // now name points at the new oneDecision two: the String Pool
Think about how many Strings a normal program creates. Every URL. Every password. Every key in every map. Every line read from a file. Millions of them, and a lot of them identical.
Making a separate object for each identical one would waste enormous memory. So Java set aside a special area of the heap called the String Pool, and reuses what is already there.
String s1 = "hello";
String s2 = "hello";
String s3 = "hello";Stack
main
Heap: the String Pool
String (in the pool)@1a2b
Now compare that with new:
String s4 = new String("hello");
String s5 = new String("hello");Stack
main
Heap: ordinary area
String@1a2b
String@283c
Now the Phase III mystery is solved. == compares reference identity. Pooled String literals can refer to one object, so == says true. Strings created separately with new are distinct objects, so == says false. Neither comparison examines the text.
Under the hood
Going deeperThe rule that removes all confusion
Learn this one sentence and Strings stop being confusing.
Only compile time constants go in the pool. Anything built while the program runs goes on the ordinary heap.
A compile time constant is something javac can work out for itself, before your program starts.
| Code | Where the String ends up | |
|---|---|---|
| "hello" | the compiler can see it | pool |
| "hel" + "lo" | the compiler joins them itself | pool |
| new String("hello") | you said new | ordinary heap |
| part + "lo" | part is a normal variable | ordinary heap |
| scanner.nextLine() | built while running | ordinary heap |
Which explains this. Run it yourself:
String hello = "hello";
String joined = "hel" + "lo";
System.out.println(joined == hello); // true. javac folded it into "hello".
String part = "hel";
String built = part + "lo";
System.out.println(built == hello); // false. Built at run time.
System.out.println(built.equals(hello)); // true. The text matches.Putting a run time String into the pool
If you have a String built at run time and you want the pooled one, ask for it:
String typed = scanner.nextLine(); // user types: hello
System.out.println(typed == "hello"); // false
System.out.println(typed.intern() == "hello"); // trueintern() looks in the pool. If the text is there, you get that object back. If not, it is added and you get that.
In practice you will almost never call it. Use .equals() instead, which is simpler and always correct.
Why immutability came first
The pool only works because Strings cannot change. Look at the picture again: three variables share one object. If any one of them could edit that object, the other two would change without being touched.
Sharing is only safe when nothing can be modified. Immutability is what makes the pool possible, not a separate feature.
The other reasons Java made this choice
Safety. Strings hold passwords, file paths and database URLs. You pass one to a method written by somebody else. If Strings could change, that method could rewrite your database URL and give it back. It cannot, so you never have to check.
Hashing. From Section 8.3, a mutable object used as a map key is a disaster. Change a field and the map loses the entry. A String cannot change, so it is always a safe key. That is why it is the most common HashMap key in Java.
A cached hash code. Because the text can never change, a String works out its hash once and remembers it. Every later lookup skips the calculation. Java’s String class does exactly this, and it is only safe because of immutability.
Where the pool lives
The pool used to sit in a separate memory area called PermGen. Since Java 7 it is part of the ordinary heap.
That change matters for one practical reason: the pool can now be garbage collected. Under the old design, a program that interned huge numbers of Strings could fill PermGen and crash in a way that was hard to diagnose.
What it costs
Immutability means every change makes a new object. Join a String to itself in a loop a thousand times and you have created a thousand objects, all but one of them garbage. This is the single most common performance mistake with Strings, and Section 9.2 is about the fix.
The pool costs a lookup. Every literal has to be checked against what is already there. Tiny, and it happens a lot.
Pooled Strings also live a long time. They are reachable as long as something refers to them, so a program that interns a large number of unique Strings holds all of that memory.
And == still looks like it works. That is the worst part. It gives the right answer for literals, so your tests pass, and then real input arrives and it quietly says false.
What you get is a type you can pass anywhere without fear, share without copying, and use as a map key without thinking. For the most used type in the language, that is worth the cost.
Check yourself
Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.
What is the one rule that decides whether a String goes in the pool or on the ordinary heap?
Show the answer
Only compile time constants go in the pool. Anything built while the program runs goes on the ordinary heap.
So
"hello"is pooled, because the compiler can see it. And"hel" + "lo"is pooled too, because the compiler joins those two before your program ever starts.But if
partis an ordinary variable, thenpart + "lo"is built while the program runs. It goes on the heap, and==against"hello"is false.One thing surprises people: a
finallocal variable counts as a compile time constant. Sofinal String part = "hel"; part + "lo"is pooled. The compiler knows the value can never change, so it folds it.You call `name.toUpperCase()` and print `name`. Nothing changed. Why?
Show the answer
Because a String cannot change. Not "should not". Cannot.
toUpperCase()did not touchname. It built a brand new String and handed it back. You threw the result away.Write
name = name.toUpperCase();and it works.Every String method behaves this way:
concat,trim,replace,substring. They all give you a new String and leave the old one alone. If you do not keep the result, nothing happened.Why did Java make String immutable? Give a reason that is about safety, and one that is about memory.
Show the answer
Safety. Strings hold passwords, file paths, database URLs and network addresses. You pass one to a method. If Strings could change, that method could quietly rewrite your database URL and hand it back. It cannot, so you never have to check.
Memory. The pool only works because Strings cannot change. Three variables share one
"hello"object. If any one of them could edit it, the other two would change without being touched. Sharing is only safe when nothing can be modified.There is a third reason you already know from Section 8.3. A String can cache its own hash code, because the answer can never go stale. That is why String is the most common
HashMapkey in Java.
Exercises
Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.
4 exercises80 pointsabout 90 minutes
Find the Pool Boundary
ex-9-1-aSix comparisons. Predict every one before you run anything.
The rule is one sentence: only compile time constants go in the pool. For each line, ask whether javac could work the value out before your program started.
Two of the six are the interesting ones. joinedAtRunTime and joinedFromFinal differ by a single word, final, and they give opposite answers.
Finish by showing .equals() is true for every pair. That is the point. The text was always the same. Only the addresses differed, and == was never looking at the text.
What to write
These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.
class Pool {
static boolean sameObject(String a, String b)
static String pooledLiteral()
static String builtWithNew()
static String joinedByCompiler()
static String joinedAtRunTime()
static String joinedFromFinal()
}Every one of the five builders returns the characters h e l l o. They differ only in where that object lives. sameObject answers with ==, never with equals.
What your program must do
- Predict all six results before running
- Say for each one whether the value is a compile time constant
- Explain why joinedAtRunTime and joinedFromFinal disagree
- Show that equals is true for every pair
public class Pool {
// Identity. Are these two names for one object?
static boolean sameObject(String a, String b) { return false; } // TODO
static String pooledLiteral() { return null; } // TODO: the literal "hello"
static String builtWithNew() { return null; } // TODO: new String("hello")
static String joinedByCompiler() { return null; } // TODO: "hel" + "lo"
// TODO: join an ordinary local holding "hel" with "lo"
static String joinedAtRunTime() { return null; }
// TODO: the same join, from a final local
static String joinedFromFinal() { return null; }
public static void main(String[] args) {
String hello = "hello";
// TODO: predict all six BEFORE running. Ask each time whether javac
// could work the value out before the program started.
// TODO: print sameObject(hello, x) for all five builders
// TODO: print sameObject(hello, joinedAtRunTime().intern())
// TODO: finish by showing .equals() is true for every one of them
}
}
Hint 1
Hint 2
joinedByCompiler returns "hel" + "lo". The compiler joins those two itself and writes "hello" into the class file, so it is pooled.Hint 3almost the answer
final means the value can never change, so the compiler folds it into a constant. Without final the join happens while the program runs, and run time Strings go on the ordinary heap.Methods That Change Nothing
ex-9-1-bFour method calls, and the String is unchanged at the end.
Run it first and see. Nothing failed, nothing warned, and every line looked like it did something.
Fix all four, then chain them into one line and notice that the chain reads better than four separate statements.
The habit to build: when you call a method on a String, ask immediately where the result is going. If the answer is nowhere, the line does nothing.
What to write
These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.
class NoChange {
static String ignored(String input)
static String kept(String input)
static String chained(String input)
static boolean survivesEveryCall(String input)
}The four calls happen in this order every time: trim, then replace "atlas" with "beacon", then toUpperCase, then concat " ready". ignored makes all four calls and keeps none of them, so it returns input untouched.
What your program must do
- Run ignored first and confirm nothing changed
- Fix all four so each change is kept
- Chain all four into a single expression
- Say what each of the four methods actually returned
public class NoChange {
// Call trim, replace("atlas", "beacon"), toUpperCase and concat(" ready")
// here and keep none of the results. Then return input.
static String ignored(String input) {
return ""; // TODO
}
// TODO: the same four calls, in that order, with each result assigned back
static String kept(String input) {
return ""; // TODO
}
// TODO: the same four calls again, chained into a single expression
static String chained(String input) {
return ""; // TODO
}
// TODO: call all four on input, then answer whether input still reads
// the way it did before
static boolean survivesEveryCall(String input) {
return false; // TODO
}
public static void main(String[] args) {
String name = " atlas unit ";
System.out.println("[" + ignored(name) + "]"); // what prints, and why?
// TODO: print kept and chained and compare them
// TODO: print name again afterwards
}
}
Hint 1
Hint 2
s = s.trim();. Do that for each of the four.Hint 3almost the answer
input.trim().replace("atlas", "beacon").toUpperCase().concat(" ready"). The String you started with is still untouched, which is what survivesEveryCall is asking about.The Input That Breaks ==
ex-9-1-cWrite the bug that passes every test and fails in production.
The broken check works when you call it with a literal. That is what makes this so dangerous: a test suite full of hardcoded values proves nothing.
Then feed it real typed input and watch it fail.
Finish by making the broken version pass with intern(), and then say clearly why that is the wrong fix. Understanding why a working fix is still bad is more useful than the fix itself.
What to write
These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.
class RealInput {
static boolean checkWithIdentity(String typed)
static boolean checkWithEquals(String typed)
static String typedLike(String text)
static boolean identityAfterIntern(String typed)
}checkWithIdentity stays wrong on purpose. typedLike stands in for the Scanner: it must return a String holding those characters that is not the pooled one. checkWithEquals must answer false for null rather than throwing.
What your program must do
- Show the broken check passing with a hardcoded literal
- Show the same check failing on text built at run time
- Explain why the hardcoded test passed
- Make the broken check pass with intern, then say why you would not ship that
import java.util.Scanner;
public class RealInput {
// Deliberately wrong. Compare with == and leave it wrong.
static boolean checkWithIdentity(String typed) {
return false; // TODO
}
// Correct, and safe when typed is null. Put the literal on the left.
static boolean checkWithEquals(String typed) {
return false; // TODO
}
// TODO: return a String with these characters, built while the program runs,
// the way a Scanner builds one. Not a literal.
static String typedLike(String text) {
return null; // TODO
}
// TODO: intern the input first, then compare with == against "secret"
static boolean identityAfterIntern(String typed) {
return false; // TODO
}
public static void main(String[] args) {
System.out.println("hardcoded test:");
System.out.println(" broken : " + checkWithIdentity("secret"));
System.out.println(" correct: " + checkWithEquals("secret"));
// TODO: run both checks on typedLike("secret") and compare
Scanner sc = new Scanner(System.in);
System.out.print("Type secret: ");
String typed = sc.nextLine();
// TODO: run both checks on the typed value
// TODO: then make the broken one pass with intern, and say why you would not ship that
}
}
Hint 1
== happens to be true.Hint 2
new StringBuilder(text).toString() produces the same kind of String, so you can reproduce the bug without a keyboard.Hint 3almost the answer
typed.intern() == "secret" works, and you would never ship it. It treats the symptom by forcing a pool lookup at every call site. The real fix is "secret".equals(typed), which stops comparing reference identity at all.Build Your Own Immutable Text
ex-9-1-dBuild the design that String uses, so it stops being magic.
Four things to get right: the pool, real immutability, a method that returns a new object, and a cached hash code.
The defensive copy matters more here than in Section 8.2. If somebody can modify the character array inside a pooled object, they change it for every variable pointing at it. That is the exact reason String had to be immutable before a pool was possible.
Prove all four claims in main with printed output, not comments. Then look at your class and at String together, and notice how little is left over.
What to write
These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.
class MiniText {
static MiniText of(String text)
static MiniText created(String text)
int length()
char charAt(int index)
char[] chars()
MiniText upper()
static int hashComputations()
boolean equals(Object o)
int hashCode()
String toString()
}hashComputations counts how many times the hash was really calculated, so three hashCode calls on one object must move it by one. chars returns the characters, and what you hand back there decides whether the pool can be corrupted.
What your program must do
- Implement of so identical text returns the same object
- Implement created so it always makes a new object
- Make upper return a new MiniText and leave the original alone
- Cache the hash code and prove it is computed only once
import java.util.HashMap;
import java.util.Map;
public final class MiniText {
private final char[] chars;
private int cachedHash; // 0 until computed
private static int hashComputations; // count the real calculations
private static final Map<String, MiniText> POOL = new HashMap<>();
private MiniText(char[] chars) { this.chars = chars; } // TODO: is this safe?
// TODO: return the pooled instance if it exists, otherwise create and pool it
public static MiniText of(String text) { return null; }
// TODO: always create a new one, never pooled. This is the new String() path.
public static MiniText created(String text) { return null; }
public int length() { return 0; } // TODO
public char charAt(int index) { return ' '; } // TODO
public char[] chars() { return null; } // TODO: what must you hand back here?
public MiniText upper() { return null; } // TODO: a NEW MiniText
public static int hashComputations() { return 0; } // TODO
@Override public boolean equals(Object o) { return false; } // TODO
@Override public int hashCode() { return 0; } // TODO: compute once, then cache
@Override public String toString() { return ""; } // TODO
public static void main(String[] args) {
// TODO: prove of("hi") == of("hi") but created("hi") != created("hi")
// TODO: prove upper() does not change the original
// TODO: prove hashCode is only computed once
// TODO: take the array from chars(), write into it, and print the object again
}
}
Hint 1
of, look in the map first. POOL.computeIfAbsent(text, t -> new MiniText(t.toCharArray())) does the whole thing in one line.Hint 2
cachedHash is still 0, then store it. Increment hashComputations inside that block so you can count the calculations rather than the calls.Hint 3almost the answer
chars(). Without that, a caller keeps a reference to your internals and the class is not immutable. Here it is worse than in Section 8.2, because rewriting a pooled object rewrites it for everybody holding it.After the credits
The cost above is about to become a measurement.
In Section 9.2 you will join 40,000 pieces of text two ways. Once with +, which makes a new String every time. Once with StringBuilder, which keeps one growing array and produces the String at the end.
The gap is large enough to see without a stopwatch. It is also the same idea as buffering from Section 8.1: stop doing the expensive thing per item, do it once at the end.
In Phase X, everything you just learned about immutability pays off in one line. Map<String, Integer> is the most common map in Java, and it is safe precisely because a String key can never change its hash.
Threads you opened in this section
- The String PoolString is the commonest map key in Java, and the pool is part of why.Phase X. The Collections Framework
The String Pool will return in 9.2 - String Methods and StringBuilder