DEV Community

Vishnu B
Vishnu B

Posted on Fully Autonomous

How Java Strings Work: A Quick Walkthrough

Every Java developer meets this bug once: two strings that look identical, but == says they are different. The fix is always "use .equals()" - but why == behaves that way is a much better story, and it runs through a hidden JVM structure called the string pool. Let's open it up with real experiments, real bytecode, and the exact spec language.

The rule, straight from the spec

The Java Language Specification is unusually direct here (JLS 3.10.5): "a string literal always refers to the same instance of class String. This is because string literals - or, more generally, strings that are the values of constant expressions - are 'interned' so as to share unique instances, as if by execution of the method String.intern."

Two literals with the same text are not two objects that happen to match. They are the same object. And that is only safe because of the other rule, from the String JavaDoc: "Strings are constant; their values cannot be changed after they are created." Sharing one object across your whole program works precisely because nobody can ever edit it.

Here is the whole story, animated:

Animated walkthrough of the Java string pool: two literals share one pooled instance, new String makes a separate copy, intern folds it back

The experiment

Enough spec - make the JVM prove it. Every claim below is real output from JDK 21 (Temurin 21.0.13):

public class PoolDemo {
    public static void main(String[] args) {
        String a = "hello";                 // literal -> string pool
        String b = "hello";                 // same literal -> SAME pool entry
        System.out.println("a == b (two literals):            " + (a == b));

        String c = new String("hello");     // explicit new -> fresh heap object
        System.out.println("a == c (literal vs new):          " + (a == c));
        System.out.println("a.equals(c):                      " + a.equals(c));

        String d = c.intern();              // fold back into the pool
        System.out.println("a == c.intern() (after intern):   " + (a == d));

        String e = "he" + "llo";            // constant expression, folded at compile time
        System.out.println("a == (\"he\" + \"llo\"):            " + (a == e));

        String he = "he";
        String f = he + "llo";              // runtime concat -> new object, NOT pooled
        System.out.println("a == (he + \"llo\") runtime concat: " + (a == f));
        System.out.println("a == f.intern():                  " + (a == f.intern()));
    }
}
Enter fullscreen mode Exit fullscreen mode
a == b (two literals):            true
a == c (literal vs new):          false
a.equals(c):                      true
a == c.intern() (after intern):   true
a == ("he" + "llo"):            true
a == (he + "llo") runtime concat: false
a == f.intern():                  true
Enter fullscreen mode Exit fullscreen mode

Walk it line by line:

  • a == b is true. Two literals, one pooled object. == compares references, and both references point at the same instance.
  • a == c is false, but equals is true. new String("hello") is a plain constructor call: it must produce a fresh object with its own identity, pool or no pool. Same text, different objects - == sees identity, equals sees content.
  • a == c.intern() is true. The intern() JavaDoc: "A pool of strings, initially empty, is maintained privately by the class String... if the pool already contains a string equal to this String object as determined by the equals(Object) method, then the string from the pool is returned." It even gives you the exact test: "s.intern() == t.intern() is true if and only if s.equals(t) is true."
  • Compile-time folding is real. "he" + "llo" involves no runtime concatenation at all - the compiler folds constant expressions into one literal before your program ever runs, so e is the pooled "hello".
  • Runtime concatenation is not pooled. he + "llo" builds a genuinely new object each time it executes, which is why a == f is false - until f.intern() folds it back to the pooled copy.

The bytecode does not lie

Where does the sharing actually happen? In the class file's constant pool - a table of constants (including string constants) that travels inside every .class. Two different methods returning the same literal:

public class ByteString {
    static String first()  { return "shared"; }
    static String second() { return "shared"; }   // same text, different method
    public static void main(String[] args) {
        System.out.println(first() == second());
    }
}
Enter fullscreen mode Exit fullscreen mode

javap -v shows there is exactly one entry for the text, and both methods load the same one:

  #7 = String             #8             // shared

  static String first();
    0: ldc           #7                  // String shared

  static String second();
    0: ldc           #7                  // String shared
Enter fullscreen mode Exit fullscreen mode

Both methods execute ldc #7 - "load constant #7 onto the operand stack" - and the program prints true. (On the spec's operand stack, that is; remember from the stack walkthrough that the JIT is free to keep such values in registers instead. The constant-pool sharing itself, though, is a language-level guarantee, not an optimization.) The class file contains the text once; the JVM resolves that constant to one interned instance; every ldc #7 anywhere in the class hands out the same reference.

Where the memory actually is

Three locations, kept distinct on purpose:

  • The constant pool is class metadata - loaded and managed with the class, in the JVM's class-data area (the spec's "method area"; HotSpot calls its version Metaspace).
  • The String objects, pooled or not, are ordinary objects allocated on the heap.
  • The string pool is a JVM bookkeeping structure that maps string contents to those heap objects. In HotSpot its entries live in the heap too - but that placement is implementation detail, not a language guarantee, and it has moved between JDK versions before. The guarantee is the JLS one: same literal, same instance.

And the kernel? Comparing references, interning, and concatenating are pure user-space JVM work - ordinary reads and writes inside memory the process already owns, no system call in sight. The operating system shows up only at the usual boundaries: if heavy allocation grows the heap the JVM must map or commit more memory, fresh pages arrive through page faults, and the garbage collector's decisions interact with OS memory when the JVM commits or returns it. String code itself never asks the kernel for anything.

Why immutability is the point

The pool is the payoff, but immutability is the price of admission - and it buys more than sharing:

  • Safe sharing. Any code anywhere can hold a reference to the pooled "hello" and know it can never change under them.
  • Fast, safe HashMap keys. String caches its hash code after the first computation - possible only because the contents can never change. That cache is part of why HashMap lookups on string keys stay fast (we walked the full HashMap flow in an earlier walkthrough).
  • Thread safety for free. Immutable objects need no synchronization to share between threads.

Habits worth keeping

  • Compare contents with .equals(), never ==. == asks "same object?", which is almost never your question.
  • Never write new String("literal"). It only makes a redundant copy.
  • Reach for intern() rarely. Deliberate deduplication of a few hot values is fine; interning unbounded user input pours it into a shared JVM-wide table. Modern JVMs do garbage-collect pool entries that become unreachable, but volume still costs memory and lookup work.
  • For heavy concatenation in loops, use StringBuilder - the mutable companion the String JavaDoc itself points to - and go back to immutable String when you're done.

Sources

Written with AI assistance; every code example, comparison result and bytecode listing above comes from real compiles and runs on JDK 21 (Temurin 21.0.13). The animation is an illustration of the pool's logical behavior, not a map of physical memory layout, which is implementation- and version-specific.

Top comments (0)