Чи можна використовувати == для порівняння 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. |
Безпечні патерни порівняння рядків
- Константа ліворуч (Yoda Condition):
// Захист від NullPointerException, якщо userInput == null if ("ADMIN".equals(userInput)) { ... } - Використання утилітарного методу
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
}
- Перший крок
this == anObject: Методequals()сам використовує==як першу перевірку. Якщо передано той самий об’єкт (або два посилання на один запис у String Pool),equals()завершується миттєво за 1 такт процесора. - Перевірка кодування (
coder): Якщо в одного рядкаcoder = LATIN1(0), а в іншогоcoder = UTF16(1), вони гарантовано не рівні, порівняння масивів навіть не починається. - Векторизоване порівняння (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)$.
Чому від цього відмовилися в сучасній архітектурі?
- Катастрофічна крихкість: Якщо хоча б в одному місці системи розробник забуде викликати
.intern(), оператор==повернеfalseбез жодних винятків. Виникає складно вловимий плаваючий баг бізнес-логіки. - Засмічення
StringTable: Таблиця рядків зберігається в купі JVM у вигляді хеш-таблиці фіксованого початкового розміру. Масове інтернування рядків роздуває ланцюжки колізій, збільшуючи затримки GC та Safepoint. - Альтернативи: Для замкнених наборів констант у Highload використовують
Enum, числові ідентифікатори (ID), або спеціалізовані структури без створення об’єктів (наприклад,ByteSequenceчи AgronaDirectBuffer).
Вплив 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-інтерв’ю
- Механіка
==: Порівняння 32/64-бітних покажчиків (інструкціяif_acmpeq). - Внутрішня будова
equals(): Першим кроком виконуєthis == anObject(Fast Path), потім перевіркуcoder, а посимвольне порівняння масивів байтів оптимізовано через SIMD-векторизацію (AVX-512). - String Deduplication: Об’єднує лише масиви
byte[], зберігаючи різні посилання на об’єктиString(==залишаєтьсяfalse). - String Pool та Constant Folding: Єдині причини, чому
==іноді повертаєtrueдля рядків з однаковим текстом. - Безпечне порівняння: Патерн Yoda condition (
"CONSTANT".equals(var)) запобігає виникненнюNullPointerException.
Червоні прапорці (Чого говорити категорично не можна)
- ❌ Стверджувати, що «
==порівнює рядки, якщо вони короткі, а довгі порівнюються черезequals()». - ❌ Вважати, що рядки, отримані з REST/JSON чи бази даних, можна порівнювати через
==. - ❌ Думати, що String Deduplication робить об’єкти рядків ідентичними за посиланням
==. - ❌ Рекомендувати повсюдне використання
.intern()для того, щоб порівнювати рядки через==.