String identity, interning and concatenation

Core Java · lesson 42 of 42 · 6 min read

Why == sometimes works on strings, which is worse than never working.

Open this lesson in the learning hub

Key points

  • String literals are interned: identical literals in the same JVM refer to one object, so == appears to work. That is exactly what makes it dangerous.
  • A string built at runtime - from concatenation of variables, a substring, or input - is a different object, so the same comparison suddenly fails with no visible change in the code.
  • A concatenation of two compile-time constants is folded by the compiler into a literal, so it is interned. Involve one non-final variable and it is not.
  • intern() returns the canonical instance from the string pool. It was a memory optimisation in the permanent-generation era; today its cost usually outweighs the benefit outside specific deduplication cases.
  • Since Java 9, strings holding only Latin-1 characters are stored one byte per character rather than two. A single non-Latin-1 character switches the whole string to two bytes, which is a real memory consideration.
  • Since Java 9, + on strings compiles to an invokedynamic call rather than a StringBuilder chain, which lets the JVM optimise it. Explicit StringBuilder is still what you want inside a loop.

Example

public class StringIdentity {

    static final String PREFIX = "hello";     // compile-time constant

    public static void main(String[] args) {
        String a = "hello";
        String b = "hello";
        System.out.println("two literals        a == b : " + (a == b));

        String c = new String("hello");
        System.out.println("new String          a == c : " + (a == c));
        System.out.println("                a.equals(c): " + a.equals(c));
        System.out.println("interned       a == c.intern(): " + (a == c.intern()));

        // Constant folding: this becomes the literal "hello world".
        String folded = PREFIX + " world";
        String literal = "hello world";
        System.out.println();
        System.out.println("constant-folded  folded == literal : " + (folded == literal));

        // One non-final variable and the folding stops.
        String prefixVar = "hello";
        String runtime = prefixVar + " world";
        System.out.println("built at runtime runtime == literal: " + (runtime == literal));
        System.out.println("                 .equals           : " + runtime.equals(literal));

        // Compact strings: Latin-1 costs 1 byte/char, anything else costs 2.
        String latin = "abcdefghij";
        String unicode = "abcdefghi\u00e9";      // one accented char
        System.out.println();
        System.out.println("latin length   : " + latin.length());
        System.out.println("unicode length : " + unicode.length()
                + "  (same length, twice the bytes internally)");

        // In a loop, build explicitly - do not concatenate in place.
        long start = System.nanoTime();
        StringBuilder sb = new StringBuilder(1024);
        for (int i = 0; i < 1000; i++) {
            sb.append(i).append(',');
        }
        long ns = System.nanoTime() - start;
        System.out.println();
        System.out.println("built " + sb.length() + " chars in " + ns / 1000 + " us");

        System.out.println();
        System.out.println("RULE: compare strings with equals(), always.");
    }
}

Literals are interned so == appears to work until a string is built at runtime - which is why it is a bug that hides in testing.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the Core Java course, and every lesson in it is listed on the Core Java contents page.