JAVA / STRINGS AND TEXT HANDLING
The string pool, interning and the == trap
Predict when two equal Java strings are the same object, explain why == passes for literals and fails for runtime data, and use intern() deliberately.
What you will learn
- Predict when two equal strings share one object: literals and folded constants do
- Use equals or Objects.equals for content; keep == for deliberate identity checks
- final constants fold at compile time; non-final variables concatenate at runtime
- Use intern() to collapse a bounded set of repeated strings, not arbitrary input
Understanding The string pool, interning and the == trap
Every string literal in a class file is a constant-pool entry that the JVM resolves to a single shared String object held in one process-wide table, the string pool. The first time a literal is resolved the JVM looks for an equal string already in that table: if it finds one it hands back that exact object, otherwise it adds this one. Sharing is only safe because String is immutable, so no code can change the characters under another holder. The practical consequence is that two identical literals anywhere in your program are the same object, which is exactly why == sometimes looks like it compares text.
The == operator on references asks "same object?" and never "same characters?". Anything the program computes while running allocates a fresh object outside the pool: new String(...), concatenation involving a non-constant variable, substring, trim, split, a line read from a file or socket, a value parsed out of JSON. The reason this bug survives testing is that javac folds compile-time constant expressions, so "ki" + "wi" is stored in the class file as the single literal "kiwi" and compares equal by reference. Your test uses literals and passes; production reads the same characters off a stream and the identical line of code returns false.
intern() is the manual door into the pool: it returns the pooled instance for that content, inserting the receiver if nothing equal is there yet. That makes identity comparisons meaningful and can collapse thousands of duplicate copies into one shared object, but each call is a lookup in a native hash table whose bucket count is fixed at JVM startup, so interning a large set of distinct values is wasted work and long chains. Reserve it for a small, repeating vocabulary such as HTTP verbs, column names or status codes, and in ordinary code compare with equals, or Objects.equals when either side may be null.
public class PoolIdentity {
public static void main(String[] args) {
String literal = "kiwi";
String sameLiteral = "kiwi";
String built = new String("kiwi");
String interned = built.intern();
String folded = "ki" + "wi"; // constant expression, folded by javac
String piece = "ki"; // not final, so not a constant
String runtime = piece + "wi"; // concatenated while the program runs
System.out.println("literal == sameLiteral: " + (literal == sameLiteral));
System.out.println("literal == built: " + (literal == built));
System.out.println("literal.equals(built): " + literal.equals(built));
System.out.println("literal == interned: " + (literal == interned));
System.out.println("literal == folded: " + (literal == folded));
System.out.println("literal == runtime: " + (literal == runtime));
System.out.println("literal.equals(runtime): " + literal.equals(runtime));
}
}== compares object identity, and the string pool alone decides when two equal strings happen to be one object: literals and compile-time constants are pooled, runtime-built strings are not.
Worked examples
One keyword flips the answer
Shows that final turns a local variable into a compile-time constant, so the concatenation is folded into a pooled literal instead of being built at runtime.
public class ConstantFolding {
public static void main(String[] args) {
final String prefix = "java"; // constant variable
String plain = "java"; // ordinary variable
String foldedAtCompileTime = prefix + "8";
String builtAtRuntime = plain + "8";
System.out.println(foldedAtCompileTime == "java8");
System.out.println(builtAtRuntime == "java8");
System.out.println(builtAtRuntime.intern() == "java8");
}
}Example explained
Line 1final String prefix = "java" makes prefix a constant variable, so prefix + "8" is a constant expression that javac replaces with the literal "java8".
Line 2plain is not final, so plain + "8" compiles into a concatenation performed at run time that allocates a brand new String outside the pool.
Line 3builtAtRuntime.intern() finds the entry the literal "java8" already put in the pool and returns that instance, so == succeeds.
Line 4Both variables hold the same seven characters throughout; only their identity differs, which equals would never notice.
Why == bugs are intermittent
Demonstrates that trim() returns a new object when it removes characters but returns the receiver when there is nothing to remove, so == fails or passes depending on the input.
public class TrimmedInput {
public static void main(String[] args) {
String typed = " yes ";
String cleaned = typed.trim();
System.out.println(cleaned == "yes");
System.out.println(cleaned.equals("yes"));
String already = "yes".trim(); // nothing to strip
System.out.println(already == "yes");
}
}Example explained
Line 1typed.trim() has whitespace to remove, so it allocates a new String; that object was never in the pool, so == is false.
Line 2equals compares the characters and reports true, which is the check the code actually wanted.
Line 3"yes".trim() has nothing to strip, so trim returns this, which is the pooled literal, and == accidentally reports true.
Line 4The same == line therefore passes for clean input and fails for padded input, which is why the bug escapes review.
Interning as deliberate canonicalisation
Shows two independently computed strings becoming one shared object after intern(), the legitimate use of the pool.
public class Canonicalize {
public static void main(String[] args) {
String[] rows = { "GET /a", "GET /b" };
String m1 = rows[0].substring(0, 3);
String m2 = rows[1].substring(0, 3);
System.out.println(m1 == m2);
System.out.println(m1.intern() == m2.intern());
System.out.println(m1.intern() == "GET");
}
}Example explained
Line 1Each substring call allocates its own String, so m1 and m2 are two objects holding identical characters.
Line 2intern() maps equal content to one canonical instance, so both calls return the same reference and == is true.
Line 3That canonical instance is the one the literal "GET" already registered, so interned values also match literals by reference.
Line 4This only pays off because the vocabulary is tiny and repeats; interning every distinct request line would just fill the pool.
Important notes
Since Java 7 the pool lives in the normal heap rather than PermGen and its entries are weakly referenced, so interned strings nobody else holds can still be collected; interning is not a permanent leak, but it is not free either.
switch on a String, HashMap keys and List.contains all go through hashCode and equals, not ==, so they work correctly with runtime-built strings; only your own == comparisons break.
Common mistakes
Testing only with literals: javac folds the constants so == passes in the unit test, then the same comparison fails on data read from a file, socket or form field, giving a defect that only shows up in production.
Calling s.intern() as if it changed s: intern returns the pooled reference and leaves the variable pointing at the old object, so s.intern(); if (s == "ok") is still false unless you write s = s.intern();
Using new String("ok") to "make a copy": it deliberately creates a second object, so new String("ok") == "ok" is false and you pay an extra allocation for no benefit.
Try it yourself
Change, predict, then run
Build the text port80 four ways in one main method: as a literal, as new String("port80"), as "port" + 80, and as "port" + n where int n = 80. Predict the four results of comparing each with == "port80" before running, then make the failing one true without changing how it is built.
Open the Java workspaceCheck your understanding
Given static String label(String suffix) { return "id-" + suffix; }, what does System.out.println(label("7") == "id-7") print, and why?
- false, because suffix is only known at run time, so the concatenation allocates a new String that is not in the pool
- true, because the pool guarantees exactly one String object per distinct sequence of characters
- true, because javac folds "id-" + suffix into the single literal "id-7"
- It depends on whether the JVM was started with -XX:+UseStringDeduplication
Show answer
The pool only guarantees a unique instance for literals and compile-time constant expressions. A method parameter has no value at compile time, so "id-" + suffix compiles to a concatenation performed while running and produces a fresh object, making == false while equals is true. Option 3 is tempting because javac really does fold "id-" + "7" when both operands are constants, but a parameter is not a constant. String deduplication only shares the backing byte arrays of equal strings, never merges the String objects, so it cannot change a == result.