🔤 Розділ 12 · Питання #10

В чому різниця між == та equals() для String

У Java оператор == та метод .equals() вирішують два принципово різні завдання:


🟢 Junior Level

У Java оператор == та метод .equals() вирішують два принципово різні завдання:

  • Оператор == (перевірка ідентичності посилання): Порівнює адреси в пам’яті (фізичні покажчики). Він повертає true лише тоді, коли обидві змінні посилаються на один і той самий екземпляр об’єкта в купі.
  • Метод .equals() (перевірка логічної еквівалентності): Порівнює символьний вміст двох рядків. Він повертає true, якщо послідовності символів обох рядків ідентичні за довжиною та значенням, незалежно від того, де ці рядки фізично виділені в пам’яті.

Суть у 30 секундах

Якщо вам потрібно з’ясувати, чи містять два рядки однаковий текст — завжди використовуйте equals(). Використання == з рядками є коректним практично виключно для перевірки посилання на null (str == null).

String s1 = "Hello";                  // Об'єкт у String Pool
String s2 = "Hello";                  // Посилання на той самий об'єкт у String Pool
String s3 = new String("Hello");      // Примусово новий об'єкт у купі поза пулом

System.out.println(s1 == s2);         // true  (одна й та сама адреса в пулі)
System.out.println(s1 == s3);         // false (різні адреси в пам'яті)
System.out.println(s1.equals(s3));    // true  (однаковий текстовий вміст)

🟡 Middle Level

Внутрішній алгоритм методу String.equals()

У сучасних версіях Java (Java 9+, HotSpot) метод equals класу String реалізує суворо оптимізовану 5-крокову перевірку:

public boolean equals(Object anObject) {
    // Крок 1: Перевірка ідентичності посилань (Fast Path)
    if (this == anObject) {
        return true;
    }
    // Крок 2: Перевірка належності до класу String
    return (anObject instanceof String aString)
        // Крок 3: Порівняння кодування (Latin-1 проти UTF-16)
        && (!COMPACT_STRINGS || this.coder == aString.coder)
        // Кроки 4 та 5: Порівняння довжини та побайтове порівняння масивів
        && StringLatin1.equals(value, aString.value);
}
  1. this == anObject (Fast Path): Якщо обидві змінні вказують на одну й ту саму адресу (наприклад, інтерновані рядки з пулу), метод повертає true миттєво за $O(1)$, не витрачаючи час на порівняння масивів байтів.
  2. instanceof String: Захищає від передачі об’єктів інших типів та від null (якщо аргумент null, оператор instanceof безпечно поверне false).
  3. this.coder == aString.coder: Якщо один рядок складається лише з Latin-1 символів (coder = 0), а інший містить UTF-16 символи (coder = 1), вони гарантовано не рівні. Перевірка довжини та масивів навіть не запускається.
  4. Порівняння довжини: Якщо масиви value мають різну довжину — негайне повернення false.
  5. Векторизоване порівняння байтів: Побайтове порівняння масивів byte[].

Порівняльна таблиця

| Критерій | Оператор == | Метод equals() | | :— | :— | :— | | Що перевіряє | Ідентичність посилань (Memory Address) | Еквівалентність вмісту (State / Value) | | Часова складність | Завжди $O(1)$ (1 машинна інструкція) | $O(1)$ для однакових посилань, $O(N)$ для порівняння символів | | Поведінка при null ліворуч | Безпечно (null == s2 поверне false) | NullPointerException (null.equals(s2)) | | Поведінка при null праворуч | Безпечно (s1 == null) | Безпечно повертає false (s1.equals(null)) | | Строкові літерали | Повертає true через String Pool | Повертає true | | Динамічні рядки (DB/JSON) | Повертає false | Повертає true |

Типові помилки та граблі

  1. Виклик методу на потенційному null:
    // ПОГАНО: викине NullPointerException, якщо status == null
    if (status.equals("ACTIVE")) { ... }
    
    // ДОБРЕ: константа ліворуч гарантує null-safety
    if ("ACTIVE".equals(status)) { ... }
    
    // ВІДМІННО: універсальний метод із java.util.Objects
    if (Objects.equals(status, "ACTIVE")) { ... }
    
  2. Порівняння String та StringBuilder через equals(): Вираз "test".equals(new StringBuilder("test")) завжди поверне false, оскільки аргумент не проходить перевірку instanceof String! Для логічного порівняння різних реалізацій CharSequence слід використовувати метод contentEquals().

🔴 Senior Level

Апаратна оптимізація: SIMD-векторизація C2 JIT

Методи StringLatin1.equals та StringUTF16.equals позначені анотацією @IntrinsicCandidate. Під час C2 JIT-компіляції HotSpot замінює цикл побайтового порівняння на низькорівневий асемблерний intrinsic vectorizedMismatch:

  • Замість побайтового перебору в циклі процесор завантажує векторні регістри розміром 128, 256 або 512 бітів (SSE4.2, AVX-2, AVX-512 на архітектурі x86-64 або NEON на ARM64).
  • Інструкція VPXOR (векторний виключаючий АБО) порівнює 32 або 64 символи одночасно за один машинний такт.
  • Якщо векторний XOR дає нульовий результат, усі 32/64 байти ідентичні, і процесор переходить до наступного блоку.
  • Якщо знайдено розбіжність, інструкція VPMOVMSKB формує бітову маску, яка дозволяє за 1 інструкцію визначити точний індекс символу, що не збігся. Завдяки цьому порівняння навіть рядків довжиною в сотні символів займає одиниці наносекунд.

Атаки за часом (Timing Attacks) та Constant-Time порівняння

Стандартний метод String.equals() оптимізований для максимальної швидкодії: він негайно перериває виконання (Early Return), щойно зустрічає перший незбіжний символ.

Вразливість у безпеці: Якщо ви перевіряєте секретні токени, API-ключі, цифрові підписи чи паролі через String.equals(), зловмисник може вимірювати затримку відповіді сервера з точністю до наносекунд:

  • Якщо перший байт токена неправильний, метод поверне false за ~1 нс.
  • Якщо перші 15 байтів правильні, а помилковий 16-й байт, метод працюватиме довше (~4 нс). Це дозволяє зловмиснику підібрати секретний токен посимвольно (Side-Channel Timing Attack).

Захист: У криптографії та системах автентифікації порівняння має виконуватися за константний час (Constant-Time), незалежно від позиції першої помилки:

// Захист від timing attacks через MessageDigest.isEqual
byte[] secretExpected = expectedToken.getBytes(StandardCharsets.UTF_8);
byte[] userProvided = inputToken.getBytes(StandardCharsets.UTF_8);

boolean isValid = MessageDigest.isEqual(secretExpected, userProvided);

Усередині MessageDigest.isEqual використовується побітовий оператор | без умовних переходів, що гарантує однаковий час виконання для будь-яких вхідних даних однакової довжини.

Контракт equals() та hashCode()

Клас String суворо виконує фундаментальний контракт java.lang.Object:

  1. Якщо s1.equals(s2) == true, то значення s1.hashCode() == s2.hashCode() зобов’язані бути суворо рівними.
  2. У класі String хеш-код обчислюється за детермінованою формулою: \(s[0] \cdot 31^{n-1} + s[1] \cdot 31^{n-2} + \dots + s[n-1]\)
  3. Значення хеш-коду кешується в приватному полі private int hash після першого виклику.
  4. Різні за вмістом рядки (equals() == false) можуть випадково мати однаковий хеш-код (хеш-колізія, наприклад, "FB" та "Ea").

4 Tricky Questions

1. Чому вираз "abc".equals(new StringBuilder("abc")) успішно компілюється, але завжди повертає false? Який метод використовувати для правильного порівняння?

Відповідь: Метод equals(Object anObject) у класі String приймає будь-який об’єкт типу Object, тому вираз синтаксично коректний і компілюється без попереджень. Однак усередині методу першою перевіркою після адреси є: if (anObject instanceof String). Оскільки StringBuilder не є нащадком String (вони обидва успадковують Object і реалізують спільний інтерфейс CharSequence), перевірка instanceof повертає false. Для логічного порівняння вмісту рядка з будь-якими іншими реалізаціями CharSequence (StringBuilder, StringBuffer, CharBuffer) клас String надає спеціалізований метод contentEquals():

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

System.out.println(str.equals(sb));        // false (завжди!)
System.out.println(str.contentEquals(sb)); // true (посимвольне порівняння CharSequence)

2. Чи гарантує рівність хеш-кодів s1.hashCode() == s2.hashCode(), що s1.equals(s2) поверне true?

Відповідь: Ні, категорично не гарантує. Хеш-код рядка — це 32-бітне ціле число (int), яке має всього $2^{32} \approx 4.29 \times 10^9$ можливих значень. Кількість можливих строкових комбінацій нескінченна. Відповідно до принципу Діріхле, хеш-колізії (різні рядки з однаковим хешем) неминучі. Класичний приклад колізії в Java:

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

System.out.println(s1.hashCode() == s2.hashCode()); // true (в обох hash = 2236)
System.out.println(s1.equals(s2));                 // false ("FB" != "Ea")

Проте зворотне твердження є абсолютно істинним: якщо рядки рівні за equals(), їхні хеш-коди гарантовано збігаються.

3. Чим відрізняється s1.hashCode() від System.identityHashCode(s1) і як це співвідноситься з == та equals()?

Відповідь:

  • s1.hashCode(): Обчислює хеш-код на основі значень символів (вмісту рядка). Два різних рядки з однаковим текстом повернуть однаковий hashCode(). Це основа коректної роботи HashMap та HashSet.
  • System.identityHashCode(s1): Повертає хеш-код ідентичності об’єкта, що генерується HotSpot JVM на основі адреси об’єкта в пам’яті або значення із заголовка Mark Word (так, ніби метод hashCode() не було перевизначено в класі String).
    • Якщо s1 == s2 (один і той самий об’єкт), то System.identityHashCode(s1) == System.identityHashCode(s2).
    • Якщо s1 та s2 створені через new String("test"), то s1.hashCode() == s2.hashCode() поверне true, але System.identityHashCode(s1) == System.identityHashCode(s2) з імовірністю 99.999% поверне false.

4. У якому випадку метод String.equals() може повернути false ще до перевірки довжини та вмісту рядка в Java 9+?

Відповідь: У разі неспівпадіння поля coder (прапорця кодування Compact Strings). Починаючи з Java 9, рядок зберігає масив byte[] value та однобайтне поле coder:

  • coder = 0 (LATIN1): усі символи вкладаються в діапазон ISO-8859-1 (1 байт на символ).
  • coder = 1 (UTF16): хоча б один символ потребує 2 байти (кирилиця, ієрогліфи, емодзі). Якщо порівнюються два рядки, в одного з яких coder == 0, а в іншого coder == 1, HotSpot JVM негайно повертає false, не виконуючи перевірку довжини масиву та побайтове порівняння, оскільки рядки в різних кодуваннях з різним набором допустимих символів апріорі не можуть бути еквівалентними.

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь

«Оператор == порівнює фізичні адреси об’єктів у пам’яті (ідентичність посилань), повертаючи true, лише якщо обидві змінні вказують на один і той самий об’єкт. Метод .equals() порівнює фактичний логічний вміст рядків (значення символів). Для перевірки тексту завжди використовується equals() або Objects.equals(). Оператор == зі строковими типами застосовується виключно для перевірки на null».

Ключові тези для Senior-інтерв’ю

  1. 5 кроків String.equals(): == fast-path $\to$ instanceof String $\to$ перевірка coder (Compact Strings) $\to$ перевірка length $\to$ SIMD-векторизоване порівняння байтів.
  2. SIMD Intrinsic: C2-компілятор замінює посимвольний цикл на векторні інструкції (AVX-512 / AVX-2), порівнюючи до 64 байтів за такт.
  3. Безпека та Timing Attacks: Метод equals() уразливий до таймінг-атак через ранній вихід. Для токенів, паролів і хешів використовується MessageDigest.isEqual() (constant-time comparison).
  4. contentEquals(): Єдиний коректний спосіб порівняти String зі StringBuilder, StringBuffer чи CharBuffer.
  5. Контракт equals та hashCode: Рівність за equals() тягне за собою рівність hashCode(), але не навпаки (колізії).

Червоні прапорці (Чого говорити категорично не можна)

  • ❌ Стверджувати, що «== можна використовувати для коротких рядків, а equals() — для довгих».
  • ❌ Порівнювати String зі StringBuilder через str.equals(sb) (завжди повертає false).
  • ❌ Забувати про NullPointerException при виклику var.equals("CONST") замість "CONST".equals(var).
  • ❌ Вважати, що збіг hashCode() гарантує рівність рядків.

Пов’язані питання