В чому різниця між == та 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);
}
this == anObject(Fast Path): Якщо обидві змінні вказують на одну й ту саму адресу (наприклад, інтерновані рядки з пулу), метод повертаєtrueмиттєво за $O(1)$, не витрачаючи час на порівняння масивів байтів.instanceof String: Захищає від передачі об’єктів інших типів та відnull(якщо аргументnull, операторinstanceofбезпечно повернеfalse).this.coder == aString.coder: Якщо один рядок складається лише з Latin-1 символів (coder = 0), а інший містить UTF-16 символи (coder = 1), вони гарантовано не рівні. Перевірка довжини та масивів навіть не запускається.- Порівняння довжини: Якщо масиви
valueмають різну довжину — негайне поверненняfalse. - Векторизоване порівняння байтів: Побайтове порівняння масивів
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 |
Типові помилки та граблі
- Виклик методу на потенційному
null:// ПОГАНО: викине NullPointerException, якщо status == null if (status.equals("ACTIVE")) { ... } // ДОБРЕ: константа ліворуч гарантує null-safety if ("ACTIVE".equals(status)) { ... } // ВІДМІННО: універсальний метод із java.util.Objects if (Objects.equals(status, "ACTIVE")) { ... } - Порівняння
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:
- Якщо
s1.equals(s2) == true, то значенняs1.hashCode() == s2.hashCode()зобов’язані бути суворо рівними. - У класі
Stringхеш-код обчислюється за детермінованою формулою: \(s[0] \cdot 31^{n-1} + s[1] \cdot 31^{n-2} + \dots + s[n-1]\) - Значення хеш-коду кешується в приватному полі
private int hashпісля першого виклику. - Різні за вмістом рядки (
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-інтерв’ю
- 5 кроків
String.equals():==fast-path $\to$instanceof String$\to$ перевіркаcoder(Compact Strings) $\to$ перевіркаlength$\to$ SIMD-векторизоване порівняння байтів. - SIMD Intrinsic: C2-компілятор замінює посимвольний цикл на векторні інструкції (AVX-512 / AVX-2), порівнюючи до 64 байтів за такт.
- Безпека та Timing Attacks: Метод
equals()уразливий до таймінг-атак через ранній вихід. Для токенів, паролів і хешів використовуєтьсяMessageDigest.isEqual()(constant-time comparison). contentEquals(): Єдиний коректний спосіб порівнятиStringзіStringBuilder,StringBufferчиCharBuffer.- Контракт
equalsтаhashCode: Рівність заequals()тягне за собою рівністьhashCode(), але не навпаки (колізії).
Червоні прапорці (Чого говорити категорично не можна)
- ❌ Стверджувати, що «
==можна використовувати для коротких рядків, аequals()— для довгих». - ❌ Порівнювати
StringзіStringBuilderчерезstr.equals(sb)(завжди повертаєfalse). - ❌ Забувати про
NullPointerExceptionпри викликуvar.equals("CONST")замість"CONST".equals(var). - ❌ Вважати, що збіг
hashCode()гарантує рівність рядків.