🔤 Раздел 12 · Вопрос #9

Можно ли использовать == для сравнения String

Технически оператор == применить к объектам String можно (код успешно скомпилируется), но для сравнения текстового содержимого строк использовать его категорически нельзя. В 99....


🟢 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() для того, чтобы сравнивать строки через ==.

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