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

Почему String является иммутабельным

Любая операция, которая визуально «модифицирует» строку (concat(), replace(), substring(), toUpperCase()), на самом деле не меняет исходную строку. Она всегда создает и возвраща...


🟢 Junior Level

Иммутабельность (Immutability, неизменяемость) класса String означает, что после того как объект строки был создан в памяти JVM, его внутреннее состояние и символы не могут быть изменены ни при каких обстоятельствах.

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

Любая операция, которая визуально «модифицирует» строку (concat(), replace(), substring(), toUpperCase()), на самом деле не меняет исходную строку. Она всегда создает и возвращает абсолютно новый объект String в памяти с новым содержимым!

String original = "hello";

// Попытка "изменить" строку:
original.toUpperCase(); 

// Исходная строка осталась прежней:
System.out.println(original); // Выведет: "hello"

// Чтобы сохранить результат, нужно присвоить ссылку на новый объект:
String upper = original.toUpperCase();
System.out.println(upper);    // Выведет: "HELLO"

4 фундаментальные причины, почему String сделан иммутабельным

  1. String Pool (Пул строк): Возможность повторного использования строк экономит десятки мегабайт RAM. Если бы строки были мутабельными, изменение строки s1 = "Hello" в одном модуле немедленно испортило бы данные во всех других местах приложения, ссылающихся на этот же литерал.
  2. Безопасность (Security): Строки используются в качестве аргументов для подключения к базам данных (логины/пароли), сетевых URL, имен загружаемых классов (ClassLoader) и путей к файлам на диске. Неизменяемость гарантирует, что проверенные параметры не могут быть подменены другим потоком.
  3. Потокобезопасность (Thread Safety): Неизменяемые объекты абсолютно потокобезопасны «из коробки» (Thread-safe by design). Строку можно свободно читать из сотен параллельных потоков без синхронизации и блокировок.
  4. Кэширование хеш-кода (HashMap/HashSet): Поскольку символы строки не меняются, ее hashCode() вычисляется ровно один раз при первом обращении и кэшируется в объекте. Это делает String идеальным и сверхбыстрым ключом для хеш-таблиц.

🟡 Middle Level

Как иммутабельность реализована в коде JDK

В исходном коде OpenJDK неизменяемость обеспечивается сочетанием строгих языковых конструкций:

// java.lang.String (OpenJDK 17/21)
public final class String implements Serializable, Comparable<String>, CharSequence {

    @Stable
    private final byte[] value; // 1. Приватный и final массив байт
    private final byte coder;   // 2. Кодировка (LATIN1 или UTF16)
    private int hash;           // 3. Кэшированный хеш-код
    private boolean hashIsZero; // 4. Флаг для строк с hash == 0

    // Отсутствуют какие-либо методы-мутаторы (нет setCharAt, append и т.д.)
}
  1. final класс: Запрещает создание подклассов (extends String). Никакой злоумышленник не может переопределить методы строки и внедрить мутабельное поведение.
  2. private final byte[] value: Ссылка на массив символов не может быть переприсвоена после выхода из конструктора, а private скрывает прямой доступ к байтам.
  3. Отсутствие мутаторов: В классе String нет ни одного метода, изменяющего данные «по месту» (in-place).
  4. Защитное копирование (Defensive Copying): Все методы модификации (substring, replace, concat) создают новые массивы байт и возвращают новые экземпляры String.

Обратная сторона медали: Нагрузка на Garbage Collector

Главная цена иммутабельности — высокий Garbage Allocation Rate при частых операциях конкатенации:

// АНТИПАТТЕРН: катастрофическое выделение временных объектов в куче:
String result = "";
for (int i = 0; i < 10_000; i++) {
    result += i; // Каждая итерация создает новый объект String и массив байт!
}

При таком цикле создается ~10 000 промежуточных строковых объектов, которые мгновенно засоряют Eden Space и вызывают непрерывные паузы сборщика мусора. Для массовых модификаций обязательно используются мутабельные буферы: StringBuilder (в одном потоке) или StringBuffer (многопоточный).


🔴 Senior Level

Атака TOCTOU (Time-of-Check to Time-of-Use) и безопасность JVM

Критическая уязвимость, которая возникла бы в Java при мутабельности строк:

// Сценарий уязвимости TOCTOU, если бы String был мутабельным:
public void openSecureFile(String path) {
    // 1. Time-of-Check: проверка прав доступа
    if (!securityManager.isAccessAllowed(path)) { // path = "/data/public/report.txt"
        throw new SecurityException("Доступ запрещен");
    }

    // <-- В этот момент параллельный поток атакующего вызывает: path.setValue("/etc/passwd")!

    // 2. Time-of-Use: открытие файла
    fileSystem.open(path); // Открывается защищенный системный файл в обход проверки!
}

Благодаря иммутабельности String ссылка path гарантированно указывает на неизменный контент, и атака подмены состояния между проверкой и использованием (TOCTOU) физически невозможна.

Аннотация @Stable и оптимизации JIT C2 Compiler

Обратите внимание на аннотацию @Stable над полем private final byte[] value в исходном коде String:

  • Обычное поле final в Java защищает ссылку на массив, но не элементы самого массива (байты в массиве теоретически можно изменить).
  • Специальная внутренняя аннотация HotSpot @Stable сообщает JIT-компилятору (C2):

    «Элементы этого массива байт никогда не изменятся после инициализации».

  • Это позволяет компилятору C2 применять агрессивные низкоуровневые оптимизации: свертку констант в машинном коде (Constant Folding), инлайнинг операций charAt(), векторизацию циклов через инструкции SIMD (AVX-512) и удаление проверок границ массива (Escape Analysis & Bounds Check Elimination).

Почему пароли нельзя хранить в String

Несмотря на все преимущества безопасности, хранить пароли, приватные ключи и токены в объектах String категорически запрещено:

  1. Объект String иммутабелен — его нельзя принудительно затереть нулями в памяти после проверки подлинности.
  2. Строка с паролем будет находиться в памяти кучи (Heap) до тех пор, пока ее не соберет Garbage Collector (а если строка попала в пул или Old Gen — это могут быть часы или дни).
  3. При снятии аварийного дампа памяти (Core Dump / Heap Dump) злоумышленник легко прочитает пароли открытым текстом.
  4. Senior Решение: Пароли всегда хранят в массиве символов char[] или байт byte[], которые сразу после валидации принудительно зануляются вызовом Arrays.fill(password, '\0').

4 Tricky Questions

1. Как работает кэширование хеш-кода в классе String, и почему для строк с коллизией hash == 0 в Java 13 добавили поле hashIsZero?

Ответ: Хеш-код строки кэшируется лениво в приватном поле private int hash;. При первом вызове hashCode() метод вычисляет значение по формуле $s[0]\cdot 31^{n-1} + \dots + s[n-1]$ и сохраняет в поле hash. При повторных вызовах сразу возвращается сохраненное число. Ловушка с нулем: До Java 13 логика проверки выглядела так: if (hash == 0 && value.length > 0) { hash = computeHash(); }. Если строка давала реальный хеш-код 0 (например, строка "DA" или пустая строка), проверка hash == 0 считала, что хеш еще не вычислен, и при каждом вызове заново пересчитывала хеш по всем символам! Для устранения этой деградации в Java 13 ввели булево поле private boolean hashIsZero;, сигнализирующее, что вычисленный хеш равен именно нулю, предотвращая повторные вычисления.

2. Что произойдет в современных версиях Java (17/21+), если попытаться изменить поле value объекта String через Reflection API?

Ответ: Код завершится аварийным исключением java.lang.reflect.InaccessibleObjectException. Начиная с Java 16/17 в рамках инициативы по строгой инкапсуляции внутренностей JDK (JEP 396 и JEP 403), доступ к приватным полям базовых модулей через field.setAccessible(true) жестко заблокирован: Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module. Обойти это можно только явной передачей опасного аргумента JVM при старте приложения: --add-opens java.base/java.lang=ALL-UNNAMED.

3. Почему для создания строк с модификациями используют StringBuilder, а не StringBuffer, и когда StringBuffer все еще может пригодиться?

Ответ:

  • StringBuilder (появился в Java 5) несинхронизирован и работает значительно быстрее, так как не тратит процессорное время на захват блокировок монитора (synchronized). В 99.9% сценариев модификация строк происходит локально внутри одного потока (в теле метода), поэтому синхронизация избыточна.
  • StringBuffer — устаревший потокобезопасный класс, у которого все ключевые методы помечены ключевым словом synchronized. Он необходим только в редких случаях, когда один и тот же мутабельный буфер разделяется между несколькими конкурирующими потоками без внешней синхронизации.

4. Гарантирует ли спецификация Java Memory Model (JMM) потокобезопасную публикацию строк без синхронизации?

Ответ: Да, абсолютно гарантирует! Согласно спецификации JMM (JLS §17.5 Final Field Semantics), любые поля с модификатором final имеют особые гарантии видимости при безопасной публикации:

  • Когда конструктор объекта String завершает выполнение, процессор выставляет барьер памяти (StoreStore / LoadStore barrier), гарантирующий, что все записи в final byte[] value будут физически видны любому другому потоку, получившему ссылку на объект String.
  • Ни один поток в многопоточной среде никогда не сможет увидеть частично инициализированную строку или значения null в поле value даже при отсутствии внешней синхронизации volatile или synchronized.

🎯 Шпаргалка для интервью

30-секундный ответ

«Иммутабельность String означает, что состояние объекта нельзя изменить после создания: любые методы модификации возвращают новую строку. Она реализована через объявление класса final, хранение данных в private final byte[] value, отсутствие мутаторов и защитное копирование. Иммутабельность критически необходима для работы String Pool, гарантирует потокобезопасность без блокировок (JLS §17.5), предотвращает атаки безопасности (TOCTOU) при работе с файлами и URL, а также позволяет кэшировать hashCode() для быстрой работы в HashMap».

Чек-лист ключевых понятий

  1. Реализация: final class, private final byte[] value, аннотация @Stable, отсутствие сеттеров.
  2. Безопасность (TOCTOU): Защита от подмены сетевых адресов и путей к файлам между валидацией и использованием.
  3. Хранение паролей: Пароли нельзя хранить в String (невозможно затереть в куче), нужно использовать char[].
  4. Кэширование хеша: hashCode() считается один раз. Поле hashIsZero защищает от деградации при нулевом хеше.
  5. JMM гарантии: Спецификация final-полей гарантирует потокобезопасную публикацию строк без мьютексов.

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

  • ❌ «Методы toUpperCase() или replace() меняют саму строку по месту» (Они всегда возвращают новый объект String).
  • ❌ «String является потокобезопасным, потому что его методы объявлены как synchronized» (В String нет ни одного synchronized-метода; он потокобезопасен благодаря неизменяемости).
  • ❌ «Пароли лучше хранить в String, потому что String неизменяем и надежен» (Это нарушение безопасности; пароли хранят в char[] для затирания).

Ссылки на связанные темы