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

Как работает String pool и как это связано с иммутабельностью

Когда в коде объявляется строковый литерал (например, String s = "Java";):


🟢 Junior Level

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

String Constant Pool (Пул строк) — это специализированная область в оперативной памяти (Heap) виртуальной машины JVM, реализующая паттерн проектирования Flyweight (Приспособленец) для исключения дубликатов строк в памяти.

Когда в коде объявляется строковый литерал (например, String s = "Java";):

  1. JVM проверяет наличие строки с таким содержимым в пуле.
  2. Если строка уже существует, JVM возвращает ссылку на существующий объект в куче, не выделяя новую память.
  3. Если строки в пуле нет, создаётся новый объект, регистрируется в пуле, и ссылка возвращается вызывающему коду.

Почему String Pool физически невозможен без иммутабельности?

Представьте, что класс String был бы мутабельным (как StringBuilder):

String s1 = "admin";
String s2 = "admin"; // s1 и s2 указывают на ОДИН И ТОТ ЖЕ объект в пуле

// Если бы строки были мутабельными:
s1.setCharAt(0, 'u'); // Изменили "admin" на "udmin" через s1

В результате переменная s2 (а также все внутренние подсистемы безопасности JVM, библиотеки авторизации и другие потоки, использовавшие литерал "admin") внезапно получили бы значение "udmin". Это вызвало бы катастрофический каскад ошибок и полный паралич безопасности.

Вывод: Совместное использование одного объекта миллионами независимых потоков и модулей возможно исключительно благодаря тому, что строковый объект гарантированно неизменяем (Immutable).


Наглядный пример

public class StringPoolDemo {
    public static void main(String[] args) {
        String s1 = "Hello";
        String s2 = "Hello"; // Взято из String Pool
        String s3 = new String("Hello"); // Принудительное выделение нового объекта в Heap

        System.out.println(s1 == s2); // true — один и тот же объект в памяти
        System.out.println(s1 == s3); // false — разные ссылки в памяти
        System.out.println(s1.equals(s3)); // true — логическое равенство содержимого
    }
}

🟡 Middle Level

Байткод: Таблица констант и инструкция ldc

На этапе компиляции исходного кода компилятор javac помещает все литералы в таблицу констант .class-файла (Constant Pool в секции CONSTANT_Utf8_info). При выполнении метода JVM использует инструкцию ldc (Load Constant):

0: ldc           #2                  // Загружает ссылку на "Hello" из String Pool
2: astore_1                          // Сохраняет в локальную переменную s1
3: new           #3                  // class java/lang/String (выделяет память в куче)
6: dup
7: ldc           #2                  // Снова загружает "Hello" из пула для передачи в конструктор
9: invokespecial #4                  // Вызов конструктора String(String)
12: astore_3

Метод String.intern()

Метод intern() переносит строку, созданную динамически в рантайме (например, вычитанную из базы данных, файла или REST-запроса), в String Pool:

String dynamicString = new String("custom"); // Создан в куче вне пула
String pooledString = dynamicString.intern(); // Возвращает канонический экземпляр из пула

System.out.println(pooledString == "custom"); // true!

Если строка с таким содержимым уже была в пуле, intern() возвращает ссылку на неё. Если не было — регистрирует текущую строку в пуле.


Эволюция расположения String Pool в памяти HotSpot JVM

| Версия Java | Область памяти | Риски и особенности работы GC | | :— | :— | :— | | Java 6 и ранее | PermGen (Permanent Generation) | Фиксированный размер (обычно 64–128 МБ). Интенсивный вызов intern() быстро приводил к OutOfMemoryError: PermGen space. Сборка мусора в PermGen выполнялась редко и неохотно. | | Java 7+ | Основная куча (Heap Space) | String Pool перемещён в общую кучу. Неиспользуемые интернированные строки могут свободно и эффективно собираться обычным Garbage Collector. | | Java 9+ | Heap Space (Compact Strings) | Внедрён JEP 254: интернированные строки хранят byte[] вместо char[], экономя до 50% памяти для Latin-1 символов. |


🔴 Senior Level

Внутреннее устройство: Нативная StringTable в C++ коде HotSpot

Внутри JVM HotSpot пул строк представляет собой нативную C++ хеш-таблицу StringTable:

  • Исторически это была хеш-таблица с открытой адресацией или связными списками бакетов (Chaining).
  • Начиная с Java 13: нативная таблица переписана на Concurrent Hash Table (ConcurrentHashTable в C++), что устранило глобальные блокировки при параллельном вызове String.intern() из десятков потоков.
  • Параметр тюнинга: -XX:StringTableSize=N (по умолчанию $65536$ бакетов в 64-битных HotSpot Java 11/17/21).
  • Если приложение бездумно интернирует миллионы уникальных строк (например, UUID транзакций), размер бакетов увеличивается, и вызов intern() деградирует из $O(1)$ в $O(N)$ по времени, парализуя работу CPU.

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

В современных приложениях ручной вызов intern() считается антипаттерном. Для оптимизации памяти используют флаг -XX:+UseStringDeduplication:

  • Начиная с Java 18, функционал дедупликации строк вынесен из G1 в универсальный интерфейс сборщиков мусора и поддерживается в G1, Parallel, Serial и ZGC.
  • Как это работает: GC в фоновом режиме находит объекты String с одинаковым хэшем и идентичным байтовым массивом value. Ссылка value одного объекта перенаправляется на массив другого, а дублирующий массив байтов освобождается из памяти.
  • В отличие от intern(), это происходит асинхронно в фоне, не нагружает потоки приложения и не засоряет нативную таблицу StringTable.

4 Tricky Questions

1. Сколько объектов создаёт инструкция String s = new String("Java")? При каких условиях создаётся 1, 2 или 0 объектов?

Ответ:

  • В стандартном сценарии (первое появление литерала в коде): создаётся 2 объекта. Первый объект — строковый литерал "Java", который загружается в String Pool при загрузке класса. Второй объект — новый экземпляр String в обычной куче, создаваемый оператором new.
  • Если строка "Java" уже присутствовала в пуле строк ранее: создаётся ровно 1 объект (только новый экземпляр в куче через new, литерал берётся готовым из пула).
  • Если JIT-компилятор C2 выполняет Escape Analysis и Dead Code Elimination: если переменная s не используется и не покидает метод, оптимизатор HotSpot может полностью устранить выделение памяти, создав 0 объектов в куче.

2. Являются ли интернированные строки в String Pool корнями сборки мусора (GC Roots)? Может ли интернированная строка быть удалена GC?

Ответ: Начиная с Java 7, строки в String Pool НЕ являются безусловными GC Roots:

  • Сама нативная таблица StringTable удерживает объекты String через так называемые слабые ссылки или специальные дескрипторы (OopStorage / Weak References).
  • Если строка попала в пул через динамический вызов str.intern(), и во всей работающей программе не осталось ни одной живой сильной ссылки (Strong Reference) на этот объект, Garbage Collector имеет полное право удалить эту строку из кучи и очистить соответствующую запись в StringTable во время фазы очистки слабых ссылок.
  • Строковые литералы из загруженных классов удерживаются через константный пул соответствующего загрузчика классов (ClassLoader), поэтому они живут столько же, сколько живёт сам ClassLoader.

3. Чем с точки зрения JVM отличается склейка строк "a" + "b" от склейки s1 + s2?

Ответ:

  • "a" + "b": является константным выражением времени компиляции (Compile-Time Constant Expression). Компилятор javac ещё на этапе сборки объединяет их в один литерал "ab". В байткод попадает одна инструкция ldc "ab", и в кучу на этапе выполнения ничего лишнего не аллоцируется.
  • s1 + s2: переменные не являются константами времени компиляции. В Java 9+ эта операция компилируется в инструкцию invokedynamic с методом-бутстрапом StringConcatFactory.makeConcatWithConstants. Во время выполнения вычисляется суммарная длина, выделяется новый массив byte[] в куче, и возвращается совершенно новый объект String, который не попадает в String Pool автоматически.

4. Если сборщик мусора дедуплицировал массивы двух строк с помощью -XX:+UseStringDeduplication, будет ли выражение s1 == s2 возвращать true?

Ответ: Нет, выражение s1 == s2 вернёт false! Дедупликация строк сборщиком мусора работает на уровне внутреннего массива byte[] value:

  • Объекты-обёртки String в памяти остаются двумя совершенно разными объектами с разными адресами в куче (s1 != s2).
  • Единственное, что делает GC, — это перенаправляет внутреннее поле s2.value = s1.value, освобождая дубликат массива байтов.
  • Логическое равенство s1.equals(s2) при этом вернёт true. Для того чтобы s1 == s2 стало true, необходимо явно использовать метод s1.intern() == s2.intern().

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

Главное о String Pool и иммутабельности

  • Паттерн: Flyweight (экономное переиспользование одинаковых строковых литералов).
  • Связь с иммутабельностью: критическая. Без неизменяемости изменение одного литерала испортило бы данные во всех потоках приложения.
  • Расположение: с Java 7 находится в основной куче (Heap), собирается GC при отсутствии сильных ссылок.
  • Внутренности: нативная C++ таблица StringTable (с Java 13 — lock-free Concurrent Hash Table).
  • Альтернатива intern(): флаг -XX:+UseStringDeduplication (Java 18+ поддерживает G1, Parallel, Serial, ZGC).

Литерал vs new String() vs intern()

| Операция | Где выделяется память | Попадает в String Pool? | Сравнение по == с литералом | | :— | :— | :— | :— | | String s = "txt"; | В куче (String Pool) | Да (автоматически) | true | | new String("txt"); | В куче (вне пула) | Нет | false | | str.intern(); | Возврат из String Pool | Да | true |

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

  • ❌ «String Pool находится в PermGen» — устарело с Java 7 (пул находится в Heap, PermGen ликвидирован в Java 8).
  • ❌ «Интернированные строки никогда не удаляются сборщиком мусора» — неиспользуемые строки удаляются GC из пула.
  • ❌ «String Deduplication объединяет сами объекты String, поэтому после этого s1 == s2» — дедуплицируются только внутренние массивы byte[], сами ссылки String остаются разными.

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