🔒 Раздел 13 · Вопрос #4

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

В Java класс java.lang.String спроектирован неизменяемым (Immutable) по фундаментальным архитектурным причинам: 4. Кэширование hashCode(): неизменяемое состояние позволяет вычис...


🟢 Junior Level

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

В Java класс java.lang.String спроектирован неизменяемым (Immutable) по фундаментальным архитектурным причинам:

  1. Экономия памяти (String Pool): строковые литералы с одинаковым текстом разделяют один и тот же объект в памяти (паттерн Flyweight). Без иммутабельности изменение строки в одном месте привело бы к незаметной порче данных во всех остальных частях программы.
  2. Безопасность (Security): строки используются для передачи URL, путей к файлам, параметров подключения к БД, сетевых сокетов и имен классов в ClassLoader. Неизменяемость предотвращает атаки подмены данных.
  3. Потокобезопасность (Thread Safety): строки можно безопасно передавать между потоками без блокировок, volatile и ключевого слова synchronized.
  4. Кэширование hashCode(): неизменяемое состояние позволяет вычислить хэш-код ровно один раз и кэшировать его, обеспечивая максимальную скорость работы в HashMap и HashSet.

Любой метод класса String, визуально изменяющий строку (concat(), replace(), toLowerCase(), substring()), в действительности создаёт и возвращает совершенно новый объект String в куче, оставляя исходный объект нетронутым.


Практический пример

public class StringImmutabilityDemo {
    public static void main(String[] args) {
        String original = "Java";
        
        // Метод concat() возвращает НОВУЮ строку, исходная не меняется!
        original.concat(" 21");
        System.out.println("original: " + original); // Выведет "Java"

        // Чтобы сохранить результат, необходимо явно переприсвоить ссылку:
        String modified = original.concat(" 21");
        System.out.println("modified: " + modified); // Выведет "Java 21"
    }
}

Наглядная схема в памяти (Heap):

[Стек (Stack)]               [Куча (Heap)]
original  ───────►  [ String Object: "Java" ]
                                ▲
                                │ (не затронут!)
modified  ───────►  [ String Object: "Java 21" ]

🟡 Middle Level

1. Архитектура String Pool и экономия памяти

Пул строк (String Constant Pool) — специальная область в куче (Heap), реализующая паттерн Flyweight (Приспособленец):

String s1 = "interview";
String s2 = "interview";

System.out.println(s1 == s2); // true — обе ссылки указывают на один и тот же объект в пуле!

Если бы String был мутабельным, выполнение вызова s1.setCharAt(0, 'I') привело бы к тому, что переменная s2 (а также любые системные библиотеки, загрузившие этот строковый литерал) внезапно увидели бы "Interview", что привело бы к катастрофическим трудноуловимым ошибкам.


2. Защита от атак Time-of-Check to Time-of-Use (TOCTOU)

Строки лежат в основе всей подсистемы безопасности Java:

public void writeFile(String filePath, byte[] data) {
    // 1. Time-of-Check: валидация прав доступа
    if (!securityManager.isAccessAllowed(filePath)) {
        throw new SecurityException("Access denied to: " + filePath);
    }

    // Если бы String был мутабельным, параллельный поток мог бы 
    // изменить filePath на "/etc/shadow" прямо в этот микромомент!

    // 2. Time-of-Use: фактическое выполнение операции
    osFileSystem.write(filePath, data);
}

Благодаря иммутабельности filePath гарантированно указывает на тот же неизменный путь, который прошёл проверку безопасности. Аналогично ClassLoader защищён от подмены имени загружаемого класса (Class.forName(className)).


3. Кэширование хэш-кода для ассоциативных коллекций

Внутри класса String хэш-код кэшируется в приватном поле:

public final class String {
    private int hash; // По умолчанию 0

    public int hashCode() {
        int h = hash;
        if (h == 0 && !isEmpty()) {
            h = isLatin1() ? StringLatin1.hashCode(value) 
                           : StringUTF16.hashCode(value);
            hash = h;
        }
        return h;
    }
}
  • При первом вызове hashCode() сложность составляет $O(N)$ (вычисляется по полиномиальному алгоритму $s[0]\cdot 31^{n-1} + \dots$).
  • Все последующие вызовы возвращают кэшированное значение за $O(1)$.
  • Это делает String идеальным и самым часто используемым ключом для HashMap и HashSet.

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

Из-за неизменяемости строк и String Pool строковый объект с паролем "SuperSecret123" остаётся в оперативной памяти (Heap) на неопределённый срок, пока сборщик мусора не соберёт его и не перетрёт память. В случае аварийного дампа памяти (heap dump) или инспекции через /proc/kcore пароль будет легко прочитан в открытом виде. Безопасный подход: использовать массив символов char[] или байтов byte[], который можно гарантированно обнулить сразу после использования:

char[] password = console.readPassword("Enter password: ");
try {
    authenticate(password);
} finally {
    Arrays.fill(password, '0'); // Физическое стирание конфиденциальных данных из RAM
}

🔴 Senior Level

Внутреннее устройство: Compact Strings (Java 9+, JEP 254)

До Java 8 строки представлялись массивом двухбайтовых символов:

// Java 8
private final char[] value; // UTF-16: 2 байта на каждый символ всегда

В Java 9+ введено сжатие строк (Compact Strings), сократившее потребление кучи типичного enterprise-приложения на 15–30%:

// Java 9+
private final byte[] value; // 1 байт на символ для Latin-1, 2 байта для UTF-16
private final byte coder;   // 0 = LATIN1, 1 = UTF16
private int hash;           // Кэшированный хэш-код (0 = не вычислен)
private boolean hashIsZero; // Для строк, у которых реальный hashCode == 0 (Java 13+)

Иммутабельность позволяет JVM гарантировать целостность байтового массива и флага кодировки coder на протяжении всего жизненного цикла объекта.


Гарантии JMM §17.5 и ленивая инициализация hash

Поле hash в String не является final и не является volatile. Как при этом обеспечивается потокобезопасность?

  1. В JMM (§17.7) запись 32-битного значения int атомарна на всех JVM.
  2. Алгоритм вычисления хэша является чистой детерминированной функцией от неизменяемого массива value.
  3. Даже если два потока параллельно вызовут hashCode() у нового объекта:
    • Оба вычислят ровно одно и то же 32-битное число $H$.
    • Оба запишут это число в поле hash.
    • Это так называемая безобидная гонка данных (Benign Data Race). Никакой поток никогда не увидит «испорченное» значение.

Аппаратные векторные интринсики (SIMD)

JIT-компилятор HotSpot C2 имеет специальные правила подстановки интринсиков (intrinsics) для методов String:

  • equals() и compareTo() заменяются специализированными ассемблерными инструкциями AVX-2 / AVX-512 (на x86-64) и Neon (на ARM64).
  • JVM сравнивает строки 32- или 64-байтовыми векторными регистрами за 1 машинный такт CPU вместо посимвольного цикла.
  • Иммутабельность даёт C2 компилятору право полагаться на неизменность границ и байтового содержимого массива value, безопасно применяя развёртку циклов (loop unrolling) и векторизацию.

Escape Analysis и устранение аллокаций

При цепочечных вызовах методов, создающих новые строки:

public String formatName(String first, String last) {
    return (first + " " + last).trim();
}

C2-компилятор выполняет анализ побега объектов (Escape Analysis). Если промежуточный объект не выходит за пределы стекового фрейма, HotSpot задействует механизм Scalar Replacement (скалярное замещение), полностью исключая аллокацию промежуточных строк в куче и размещая их данные прямо в регистрах CPU.


Унифицированная дедупликация строк (JEP 192, JDK-8267186)

Начиная с Java 8u20 для G1, а с Java 18+ — для всех основных GC (G1, Parallel, Serial, ZGC) доступна опция -XX:+UseStringDeduplication:

  1. GC в фоновом режиме анализирует строки, пережившие несколько сборок мусора (Tenured/Old Gen).
  2. Если обнаружены разные объекты String с одинаковым хэшем и идентичным байтовым массивом value, ссылка value одного объекта перенаправляется на массив другого.
  3. Дублирующий массив byte[] освобождается из памяти. Это возможно исключительно благодаря неизменяемости массива value.

4 Tricky Questions

1. Как работает ленивая инициализация hash в String без volatile и синхронизации, и почему это допустимо по JMM?

Ответ: Поле hash объявлено как private int hash;. Согласно спецификации JMM (§17.7), операции чтения и записи примитивных типов размером до 32 бит (включая int) строго атомарны. Вычисление хэш-кода детерминировано: оно зависит только от неизменяемого массива byte[] value. Если два потока вызовут hashCode() одновременно:

  1. Они оба параллельно вычислят одно и то же число.
  2. Они оба запишут его в hash.
  3. Читающие потоки увидят либо 0 (если запись ещё не видна в их кэше процессора), либо итоговый правильный хэш. Если поток прочитал 0, он просто пересчитает хэш заново и получит то же самое число. Такая гонка называется benign race condition (безобидная гонка) и разрешена спецификацией JMM для оптимизации производительности без затрат на барьеры volatile.

2. Что произойдёт при вызове new String("hello") по сравнению с литералом "hello"? Будет ли дублироваться внутренний массив byte[] в современных версиях Java?

Ответ:

  • Литерал "hello" помещается в String Constant Pool.
  • Вызов new String("hello") принудительно создаёт новый отдельный объект String в обычной куче (Heap), игнорируя прямое использование ссылки из пула.
  • В современных версиях Java (начиная с Java 9 Compact Strings): конструктор String(String original) проверяет, является ли исходная строка компактной или разделяемой, но сам объект-обёртка String всегда выделяется заново. Массив value в большинстве современных реализаций HotSpot либо копируется, либо дедуплицируется сборщиком мусора, однако создание строк через new String() является антипаттерном, напрасно засоряющим кучу.

3. Каким образом мутабельность строк могла бы разрушить изоляцию загрузчиков классов (ClassLoader)?

Ответ: Загрузчики классов в JVM вызывают метод ClassLoader.loadClass(String name). Имя класса ("java.lang.SecurityManager", "com.app.User") передаётся в виде строки. Если бы String был мутабельным:

  1. Поток передаёт строку name = "com.app.SafeClass" в ClassLoader.
  2. ClassLoader сверяет права безопасности и определяет, что класс безопасен.
  3. Параллельный вредоносный поток в этот же момент изменяет содержимое строки на "java.lang.System" или имя системного привилегированного класса.
  4. ClassLoader загружает или переопределяет критический системный класс, ломая sandbox-изоляцию всей виртуальной машины.

4. Зачем в Java 13 в класс String было добавлено приватное поле hashIsZero?

Ответ: До Java 13 для проверки того, был ли вычислен хэш-код, использовалось условие if (hash == 0). Однако существуют строки, математический хэш-код которых действительно равен 0 (например, пустая строка "" или специально подобранные строки вроде "DA" и "EB" с коллизией в 0). Для таких строк метод hashCode() каждый раз видел hash == 0, думал, что хэш ещё не посчитан, и заново выполнял полный цикл пересчёта по массиву за $O(N)$ при каждом вызове! В Java 13+ было добавлено булево поле hashIsZero: если хэш посчитан и он равен нулю, выставляется hashIsZero = true, что гарантирует сложность $O(1)$ для абсолютно любых строк.


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

4 столпа иммутабельности String

| Столп | Почему это критично | Что сломается, если сделать String мутабельным | | :— | :— | :— | | String Pool | Экономия памяти (Flyweight) | Изменение одного литерала изменит его во всех модулях программы | | Security (TOCTOU) | Защита путей к файлам, сокетов, URL | Подмена пути к ресурсу после проверки прав доступа | | Thread Safety | Чтение без блокировок и synchronized | Гонки данных, необходимость synchronized в каждом обращении | | HashMap / HashCode | Быстрый кэшированный поиск за $O(1)$ | Потеря ключей в Map при изменении хеша на месте |

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

  • ❌ «Метод concat() или substring() модифицирует исходный объект String» — грубая ошибка, всегда возвращается новый объект.
  • ❌ «String Pool расположен в PermGen» — устарело с Java 7 (пул перенесён в обычную кучу Heap, PermGen ликвидирован в Java 8).
  • ❌ «Пароли безопасно передавать и хранить в String» — нет, строки оседают в Heap Dump; пароли хранят в char[] и зануляют.
  • ❌ «Поле hash в String является volatile» — нет, оно обычное int, потокобезопасность достигается за счёт атомарности записи int и идемпотентности вычислений.

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