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

Чи можна використовувати == для порівняння String

Технічно оператор == застосувати до об'єктів String можна (код успішно скомпілюється), але для порівняння текстового вмісту рядків використовувати його категорично заборонено. У...


🟢 Junior Level

Технічно оператор == застосувати до об’єктів String можна (код успішно скомпілюється), але для порівняння текстового вмісту рядків використовувати його категорично заборонено. У 99.9% випадків це є грубою логічною помилкою.

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

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

Єдиний коректний сценарій застосування оператора == з рядками — це перевірка посилання на null: if (str == null) або if (str != null).

// 1. Два рядки з однаковим текстом, але в різних об'єктах:
String s1 = new String("Java");
String s2 = new String("Java");

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

// 2. Ілюзія рівності через пул рядків (String Pool):
String s3 = "Java";
String s4 = "Java";

System.out.println(s3 == s4); // true (обидва посилання вказують на один об'єкт у пулі)
// Але покладатися на це НЕ МОЖНА: дані з бази, файлу чи мережі не потрапляють у пул автоматично!

🟡 Middle Level

Підступність String Pool та ілюзія правильності ==

Головна небезпека оператора == полягає в тому, що в простих юніт-тестах зі строковими літералами він повертає true, створюючи у розробників-початківців хибне відчуття коректності:

// Тест проходить успішно:
String status = "ACTIVE";
if (status == "ACTIVE") { // true! Оскільки обидва літерали взяті зі String Pool
    System.out.println("Status OK");
}

Але щойно рядок надходить із зовнішнього джерела (JSON-десеріалізатор Jackson, HTTP-запит, JDBC ResultSet або Scanner), під нього виділяється новий об’єкт у купі, і перевірка status == "ACTIVE" повертає false, що призводить до критичного багу в продакшені.

Матриця порівняння рядків

| Сценарій | Вираз | Результат == | Результат .equals() | Пояснення | | :— | :— | :— | :— | :— | | Літерали | "test" == "test" | true | true | Обидва посилання вказують на один запис у String Pool. | | Літерал і new | "test" == new String("test") | false | true | new примусово виділяє новий об’єкт у Heap поза пулом. | | Два new | new String("A") == new String("A") | false | true | Два ізольованих об’єкти в Heap із різними адресами. | | Згортання констант | "AB" == ("A" + "B") | true | true | Компілятор склеює константи під час збирання (Constant Folding). | | Динамічна конкатенація | String a = "A"; "AB" == (a + "B") | false | true | Конкатенація зі змінною відбувається в рантаймі зі створенням нового об’єкта. | | Після .intern() | s1.intern() == s2.intern() | true | true | Метод intern() повертає канонічне посилання зі String Pool. |

Безпечні патерни порівняння рядків

  1. Константа ліворуч (Yoda Condition):
    // Захист від NullPointerException, якщо userInput == null
    if ("ADMIN".equals(userInput)) { ... }
    
  2. Використання утилітарного методу Objects.equals():
    // Коректно обробляє випадки, коли обидва рядки null або один із них null
    if (Objects.equals(str1, str2)) { ... }
    

🔴 Senior Level

Реалізація String.equals() та оптимізації HotSpot

У HotSpot JVM метод equals класу String спроєктований із декількома рівнями раннього виходу (Fast Path):

public boolean equals(Object anObject) {
    // 1. Fast Path: перевірка ідентичності посилань за адресою (identity check)
    if (this == anObject) {
        return true;
    }
    // 2. Перевірка типу
    return (anObject instanceof String aString)
            && (!COMPACT_STRINGS || this.coder == aString.coder)
            && StringLatin1.equals(value, aString.value); // або StringUTF16
}
  1. Перший крок this == anObject: Метод equals() сам використовує == як першу перевірку. Якщо передано той самий об’єкт (або два посилання на один запис у String Pool), equals() завершується миттєво за 1 такт процесора.
  2. Перевірка кодування (coder): Якщо в одного рядка coder = LATIN1 (0), а в іншого coder = UTF16 (1), вони гарантовано не рівні, порівняння масивів навіть не починається.
  3. Векторизоване порівняння (SIMD Intrinsic): JIT-компілятор C2 замінює виклик StringLatin1.equals на асемблерний intrinsic. На сучасних процесорах порівняння масивів байтів виконується векторними інструкціями (AVX-2 / AVX-512) блоками по 32 або 64 байти за один машинний такт. Тому різниця в часі між == та equals() для рядків помірної довжини вимірюється частками наносекунди.

Байт-код: if_acmpeq проти invokevirtual

  • Оператор == для посилальних типів компілюється в інструкцію if_acmpeq (Branch if address compare equal) або if_acmpne. Це атомарна регістрова операція порівняння 32-бітних (при -XX:+UseCompressedOops) або 64-бітних адрес.
  • Перевірка str == null транслюється в інструкцію ifnull / ifnonnull, яка порівнює вказівник із 0x0.
  • Виклик str.equals(...) компілюється в invokevirtual, який при прогріванні JIT-компілятором інлайниться безпосередньо в тіло викликаючого методу.

Чому == після intern() — небезпечний антипатерн навіть у Highload?

Існує застаріла рекомендація для систем із наднизькою затримкою (HFT): інтернувати всі рядки через .intern() і порівнювати їх через == заради виграшу одного машинного такту $O(1)$. Чому від цього відмовилися в сучасній архітектурі?

  1. Катастрофічна крихкість: Якщо хоча б в одному місці системи розробник забуде викликати .intern(), оператор == поверне false без жодних винятків. Виникає складно вловимий плаваючий баг бізнес-логіки.
  2. Засмічення StringTable: Таблиця рядків зберігається в купі JVM у вигляді хеш-таблиці фіксованого початкового розміру. Масове інтернування рядків роздуває ланцюжки колізій, збільшуючи затримки GC та Safepoint.
  3. Альтернативи: Для замкнених наборів констант у Highload використовують Enum, числові ідентифікатори (ID), або спеціалізовані структури без створення об’єктів (наприклад, ByteSequence чи Agrona DirectBuffer).

Вплив String Deduplication в G1 GC

У G1 GC (при увімкненому прапорці -XX:+UseStringDeduplication) фоновий потік збирача сміття знаходить рядки з однаковим вмістом та об’єднує їхні внутрішні масиви byte[] value:

String A [Header] ──value──► [ 'H', 'e', 'l', 'l', 'o' ] (спільний масив)
                                       ▲
String B [Header] ──value──────────────┘

Критичний нюанс: Дедуплікація об’єднує лише масив байтів, але самі заголовки об’єктів String A та String B залишаються різними об’єктами з різними адресами в пам’яті! Тому навіть після успішної роботи String Deduplication: \(s1 == s2 \implies \text{false}\) \(s1.equals(s2) \implies \text{true}\)


4 Tricky Questions

1. Чому юніт-тест із порівнянням status == "SUCCESS" успішно проходить у JUnit, але в продакшені система перестає працювати?

Відповідь: У юніт-тесті змінна status ініціалізувалася жорстко заданою константою (строковим літералом "SUCCESS"). Обидва значення бралися з пулу рядків (String Pool), тому посилання збігалися (== повернув true). У продакшені значення рядка надходить із зовнішнього світу: десеріалізується з JSON через Jackson, вичитується з бази даних через JDBC-драйвер або надходить із мережевого сокета. У цих випадках JVM зчитує потік байтів і конструює новий незалежний екземпляр String у звичайній купі (Heap). Посилання на об’єкт у купі та на літерал у String Pool не збігаються, оператор == повертає false.

2. Що виведе вираз ("Java " + 21) == ("Java " + 21) і що зміниться, якщо винести 21 у змінну int ver = 21?

Відповідь:

  • У першому випадку ("Java " + 21) == ("Java " + 21) виведе true. Обидва операнди складаються з констант часу компіляції (літерал "Java " та числовий літерал 21). Компілятор javac застосовує Constant Folding (JLS §15.28) і перетворює обидва операнди на один літерал "Java 21", посилання на який береться зі String Pool.
  • Якщо написати int ver = 21; ("Java " + ver) == ("Java " + ver);, результат буде false. Змінна ver не є final-константою. Компілятор згенерує рантайм-виклик invokedynamic для кожної конкатенації. У рантаймі будуть створені два різних об’єкти String у купі, посилання на які не рівні.

3. Чи гарантує прапорець JVM -XX:+UseStringDeduplication, що дублікати рядків стануть рівними за оператором ==?

Відповідь: Ні, не гарантує і ніколи не зробить їх рівними за ==. String Deduplication у G1 GC працює виключно з полем byte[] value. Збирач сміття перенаправляє посилання value двох різних об’єктів String на один спільний масив байтів, звільняючи пам’ять від дубліката масиву. Самі об’єкти String (їхні заголовки Mark Word, Klass Word та кешовані хеш-коди) залишаються ізольованими об’єктами, розташованими за різними адресами в купі. Їхнє порівняння через == завжди повертатиме false.

4. Чому для перевірки на null оператор == є кращим порівняно з Objects.isNull(str)?

Відповідь: З точки зору виконання в байт-коді:

  • Вираз str == null транслюється рівно в одну процесорну інструкцію умовного переходу за нульовим вказівником ifnull / ifnonnull.
  • Метод Objects.isNull(str) є викликом статичного методу invokestatic. Хоча JIT-компілятор C2 майже завжди інлайнить цей метод, на ранніх етапах роботи застосунку (в режимі інтерпретатора та C1) або при вимкненому інлайнінгу виклик функції створює мікроскопічний оверхед на передачу аргументу через стек. Тому пряме використання str == null є ідіоматичним стандартом мови Java.

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

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

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

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

  1. Механіка ==: Порівняння 32/64-бітних покажчиків (інструкція if_acmpeq).
  2. Внутрішня будова equals(): Першим кроком виконує this == anObject (Fast Path), потім перевірку coder, а посимвольне порівняння масивів байтів оптимізовано через SIMD-векторизацію (AVX-512).
  3. String Deduplication: Об’єднує лише масиви byte[], зберігаючи різні посилання на об’єкти String (== залишається false).
  4. String Pool та Constant Folding: Єдині причини, чому == іноді повертає true для рядків з однаковим текстом.
  5. Безпечне порівняння: Патерн Yoda condition ("CONSTANT".equals(var)) запобігає виникненню NullPointerException.

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

  • ❌ Стверджувати, що «== порівнює рядки, якщо вони короткі, а довгі порівнюються через equals()».
  • ❌ Вважати, що рядки, отримані з REST/JSON чи бази даних, можна порівнювати через ==.
  • ❌ Думати, що String Deduplication робить об’єкти рядків ідентичними за посиланням ==.
  • ❌ Рекомендувати повсюдне використання .intern() для того, щоб порівнювати рядки через ==.

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