🔤 Раздел 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 предоставляет специализированный метод:

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 / AVX2), сравнивая до 64 байт за такт.
  3. Безопасность и Timing Attacks: equals() уязвим к тайминг-атакам из-за раннего выхода. Для токенов и паролей используется MessageDigest.isEqual() (constant-time comparison).
  4. contentEquals(): Единственный корректный способ сравнить String со StringBuilder или StringBuffer.
  5. Контракт equals и hashCode: Равенство по equals() влечет равенство hashCode(), но не наоборот (коллизии).

Красные флаги (Чего говорить категорически нельзя)

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

Связанные вопросы