🔤 Section 12 · Question #10

Difference Between == and equals() for String

In Java, the == operator and the .equals() method serve two fundamentally different purposes when working with String objects:


🟢 Junior Level

In Java, the == operator and the .equals() method serve two fundamentally different purposes when working with String objects:

  • The == Operator (Reference Identity): Compares memory addresses (heap pointers). It evaluates to true only when both variables reference the exact same physical object instance on the Java Heap.
  • The .equals() Method (Logical Equivalence): Compares the textual content (character sequences) of two strings. It evaluates to true if both strings contain an identical sequence of characters, regardless of where or when the objects were allocated.

The Essence in 30 Seconds

To check whether two strings contain identical text, always use .equals() (or Objects.equals()). Using == with strings in application logic is an anti-pattern and is reserved almost exclusively for testing against null (str == null).

String s1 = "Hello";                  // Stored in String Pool
String s2 = "Hello";                  // References the same String Pool instance
String s3 = new String("Hello");      // Brand new object allocated on Heap outside pool

System.out.println(s1 == s2);         // true  (identical memory address in pool)
System.out.println(s1 == s3);         // false (different heap addresses!)
System.out.println(s1.equals(s3));    // true  (identical character sequence)

🟡 Middle Level

The 5-Step Evaluation Pipeline of String.equals()

In modern Java (Java 9+, HotSpot JVM), String.equals() executes a carefully staged 5-step verification pipeline:

public boolean equals(Object anObject) {
    // Step 1: Fast-Path Identity Check
    if (this == anObject) {
        return true;
    }
    // Step 2: Class Type Check
    return (anObject instanceof String aString)
        // Step 3: Encoding Coder Verification (Latin-1 vs UTF-16)
        && (!COMPACT_STRINGS || this.coder == aString.coder)
        // Steps 4 & 5: Length Check & SIMD Vectorized Byte Array Comparison
        && StringLatin1.equals(value, aString.value);
}
  1. this == anObject (Fast Path): If both references share the same memory pointer (e.g., identical pooled literals), the method returns true immediately in $O(1)$ time, skipping character array traversal.
  2. instanceof String: Safely checks object type. If the argument is null or a different class, it returns false without throwing exceptions.
  3. this.coder == aString.coder: Under Compact Strings (JEP 254), if one string is Latin-1 (coder = 0) and the other is UTF-16 (coder = 1), they cannot be equal. Array comparisons are bypassed entirely.
  4. Length Comparison: If internal value array lengths differ, it aborts with false.
  5. SIMD Vectorized Array Comparison: Compares array bytes using hardware vector instructions.

Detailed Comparison Table

Criterion Operator == Method equals()
Comparison Target Reference Identity (Memory Pointer) Logical Equivalence (Character State)
Time Complexity Strictly $O(1)$ (single CPU instruction) $O(1)$ on identical references; $O(N)$ for character array traversal
Behavior with null on Left Safe (null == str evaluates to false) Throws NullPointerException (null.equals(str))
Behavior with null on Right Safe (str == null) Safely evaluates to false (str.equals(null))
String Literals Evaluates to true via String Pool Evaluates to true
Dynamic Strings (I/O, JSON, DB) Evaluates to false Evaluates to true

Common Traps & Antipatterns

  1. Unsafe Invocation on Potential null:
    // BAD: Throws NullPointerException if status is null!
    if (status.equals("ACTIVE")) { ... }
    
    // GOOD: Yoda condition guarantees null-safety:
    if ("ACTIVE".equals(status)) { ... }
    
    // BEST: Standard library utility handles all null combinations:
    if (Objects.equals(status, "ACTIVE")) { ... }
    
  2. Comparing String and StringBuilder with equals():
    "test".equals(new StringBuilder("test")) always returns false because StringBuilder fails the instanceof String check! To compare different CharSequence implementations, use contentEquals().

🔴 Senior Level

Hardware Optimization: SIMD Vectorization in C2 JIT

The underlying methods StringLatin1.equals and StringUTF16.equals are marked with @IntrinsicCandidate.
During C2 JIT compilation, HotSpot replaces byte comparison loops with the native assembly intrinsic vectorizedMismatch:

  • Instead of inspecting bytes one by one in a scalar loop, the CPU loads vector registers of 128, 256, or 512 bits (SSE4.2, AVX-2, AVX-512 on x86, or NEON on ARM64).
  • The VPXOR instruction compares 32 or 64 bytes simultaneously in a single CPU clock cycle.
  • If the vector XOR yields zero, all 32/64 bytes match, and execution proceeds to the next chunk.
  • If a mismatch occurs, VPMOVMSKB generates a bitmask that identifies the exact mismatch index in one instruction.
    Consequently, comparing even large strings takes just a few nanoseconds on modern hardware.

Side-Channel Security: Timing Attacks on String.equals()

Standard String.equals() is designed for speed: it short-circuits immediately (early return) upon encountering the first mismatched character.

The Security Vulnerability:
When validating cryptographic tokens, API keys, passwords, or HMAC signatures via String.equals(), an attacker can measure response latency over the network with nanosecond precision:

  • Guessing an API key that fails on byte 0 returns false in ~1 ns.
  • Guessing an API key whose first 15 bytes match and only byte 16 fails takes ~4 ns.
    This timing discrepancy allows attackers to brute-force secrets byte by byte (Side-Channel Timing Attack).

The Architectural Defense:
Cryptographic secrets must always be compared in Constant Time, ensuring execution latency remains invariant regardless of mismatch position:

// Constant-Time Comparison via MessageDigest.isEqual:
byte[] expected = expectedSecret.getBytes(StandardCharsets.UTF_8);
byte[] provided = userSuppliedSecret.getBytes(StandardCharsets.UTF_8);

boolean isValid = MessageDigest.isEqual(expected, provided);

MessageDigest.isEqual processes all bytes using bitwise | operations without conditional branching, ensuring uniform execution time.

The equals() and hashCode() Invariant

The String class adheres strictly to the fundamental contract defined in java.lang.Object:

  1. If s1.equals(s2) == true, then s1.hashCode() == s2.hashCode() must strictly evaluate to true.
  2. The hash code is computed deterministically: \(s[0] \cdot 31^{n-1} + s[1] \cdot 31^{n-2} + \dots + s[n-1]\)
  3. The computed hash code is permanently cached in private int hash.
  4. Distinct strings (equals() == false) can produce identical hash codes (hash collision, e.g., "FB" and "Ea").

4 Tricky Interview Questions

1. Why does "abc".equals(new StringBuilder("abc")) compile without errors but always return false? What method should be used instead?

Answer:
The signature of String.equals(Object anObject) accepts any Object, making the expression syntactically valid and compilation warning-free.
However, the second line of String.equals() performs if (anObject instanceof String). Because StringBuilder is an independent implementation of CharSequence and not a subclass of String, the instanceof check evaluates to false.
To logically compare a String with any other CharSequence implementation (StringBuilder, StringBuffer, CharBuffer), invoke contentEquals():

String str = "abc";
StringBuilder sb = new StringBuilder("abc");

System.out.println(str.equals(sb));        // false (always!)
System.out.println(str.contentEquals(sb)); // true (character sequence comparison)

2. Does equality of hash codes (s1.hashCode() == s2.hashCode()) guarantee that s1.equals(s2) evaluates to true?

Answer:
No, it does not.
A hash code is a 32-bit signed integer (int) with $2^{32} \approx 4.29 \times 10^9$ possible values, whereas the potential space of string permutations is infinite. By the Pigeonhole Principle, different strings inevitably collide on the same hash value.
A classic Java collision example:

String s1 = "FB";
String s2 = "Ea";

System.out.println(s1.hashCode() == s2.hashCode()); // true (both hash to 2236)
System.out.println(s1.equals(s2));                 // false ("FB" != "Ea")

Conversely, the reverse implication is absolute: if s1.equals(s2) is true, their hash codes are guaranteed to be identical.

3. What is the difference between s1.hashCode() and System.identityHashCode(s1), and how does each relate to == and equals()?

Answer:

  • s1.hashCode(): Computes a value based strictly on the string’s character contents. Two distinct String objects with identical text will produce identical hashCode() results. This powers bucket lookups in HashMap and HashSet.
  • System.identityHashCode(s1): Returns the object’s identity hash code generated by the HotSpot JVM based on the object’s memory address and Mark Word header (as if hashCode() were never overridden).
    • If s1 == s2 (same object reference), then System.identityHashCode(s1) == System.identityHashCode(s2).
    • If s1 and s2 are created via new String("test"), s1.hashCode() == s2.hashCode() evaluates to true, but System.identityHashCode(s1) == System.identityHashCode(s2) evaluates to false.

4. Under what condition can String.equals() return false before checking length or character array values in Java 9+?

Answer:
When there is a mismatch in the coder field (Compact Strings).
Starting with Java 9 (JEP 254), String stores character bytes in byte[] value alongside a 1-byte encoding identifier coder:

  • coder = 0 (LATIN1): every character fits within ISO-8859-1 (1 byte per char).
  • coder = 1 (UTF16): at least one character requires 2 bytes (Cyrillic, Asian scripts, emoji).
    When comparing two strings where one has coder == 0 and the other has coder == 1, HotSpot immediately returns false without inspecting array lengths or comparing bytes, because strings in different encodings cannot represent identical character sequences.

🎯 Interview Cheat Sheet

30-Second Summary

”== tests reference identity (whether two pointers point to the exact same heap address), whereas .equals() verifies logical equality (character content equivalence). For business logic string comparisons, always use .equals() or Objects.equals(). The == operator returns true only for shared references (like String Pool literals). The sole standard use case for == with strings is testing for null (str == null).”

Key Concepts Checklist

  1. 5-Step String.equals(): == identity fast-path $\to$ instanceof String $\to$ coder match $\to$ length match $\to$ SIMD byte comparison.
  2. SIMD Intrinsics: HotSpot C2 utilizes vector instructions (AVX-512 / NEON) to compare up to 64 bytes per clock cycle.
  3. Timing Attack Vulnerability: Standard equals() early return leaks timing side-channels; use MessageDigest.isEqual() for secret tokens.
  4. contentEquals(): The dedicated method to compare String with StringBuilder or StringBuffer.
  5. Hash Invariant: equals() equality implies hashCode() equality, but matching hash codes do not guarantee equals() == true.

Common Pitfalls & Red Flags

  • ❌ “Using == is fine as long as strings are shorter than 16 characters.” (False: == always compares memory addresses).
  • ❌ “Comparing String and StringBuilder via equals() works properly.” (False: it always returns false; use contentEquals()).
  • ❌ “Invoking var.equals("CONST") is safe.” (Causes NullPointerException if var is null; use "CONST".equals(var)).
  • ❌ “Equal hash codes guarantee that strings are equal.” (False: hash collisions are common and expected).