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

Как работает String Pool

Поскольку строки в Java иммутабельны (неизменяемы), хранить в памяти тысячи одинаковых строк вида "USD", "ACTIVE" или "SUCCESS" бессмысленно и расточительно.


🟢 Junior Level

String Pool (Пул строк) — это специальная область хранения в памяти JVM, предназначенная для оптимизации использования оперативной памяти за счет повторного использования (дедупликации) неизменяемых строковых объектов.

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

Поскольку строки в Java иммутабельны (неизменяемы), хранить в памяти тысячи одинаковых строк вида "USD", "ACTIVE" или "SUCCESS" бессмысленно и расточительно.

  1. При объявлении строки через строковый литерал (String s = "Hello";) JVM сначала проверяет, есть ли уже строка с таким содержанием в String Pool.
  2. Если строка уже есть в пуле — JVM не создает новый объект, а возвращает ссылку на уже существующий экземпляр.
  3. Если строки в пуле нет — она создается в куче (Heap), помещается в пул, и возвращается ссылка на нее.
// Пример работы пула строк:
String s1 = "Java";
String s2 = "Java";

// s1 и s2 ссылаются на ОДИН И ТОТ ЖЕ физический объект в памяти!
System.out.println(s1 == s2);      // true (сравнение ссылок!)
System.out.println(s1.equals(s2)); // true (сравнение содержимого)

// Создание через оператор new ОБХОДИТ автоматическое использование пула:
String s3 = new String("Java");
System.out.println(s1 == s3);      // false (разные объекты в Heap!)
System.out.println(s1.equals(s3)); // true

Зачем нужен String Pool

В среднестатистическом корпоративном Java-приложении строки занимают от 25% до 40% всей оперативной памяти. String Pool позволяет многократно сократить footprint памяти, предотвращая создание сотен тысяч дублирующихся объектов.


🟡 Middle Level

Как устроен String Pool внутри HotSpot JVM

На уровне виртуальной машины (HotSpot JVM) String Pool реализован на языке C++ в виде нативной хеш-таблицы — StringTable.

  • Ключами и значениями в таблице являются слабые указатели (oop, ordinary object pointer) на Java-объекты java.lang.String, расположенные в куче (Java Heap).
  • StringTable использует алгоритм хеширования, основанный на предварительно вычисленном hash строки.
                  StringTable (C++ HashMap в HotSpot)
                  ┌─────────────────────────────────┐
  bucket[0] ────> │ [hash: 2301548 | ptr: 0x7FA01] │ ───┐
                  ├─────────────────────────────────┤    │
  bucket[1] ────> │ NULL                            │    │
                  ├─────────────────────────────────┤    │
  bucket[2] ────> │ [hash: 7120391 | ptr: 0x7FB20] │    │
                  └─────────────────────────────────┘    │
                                                         │
                     Java Heap (Куча JVM)                │
                  ┌─────────────────────────────────┐    │
      0x7FA01 ──> │ String Object: "Java"           │ <──┘
                  │ value: byte[] {'J','a','v','a'} │
                  └─────────────────────────────────┘

Два способа попадания строки в пул

  1. Автоматически во время компиляции и загрузки класса (Literals): Все строковые литералы, константы и константные выражения ("a" + "b") компилятор javac записывает в таблицу Constant_Utf8_info в секцию Constant Pool внутри .class-файла. При загрузке и компоновке класса JVM автоматически разрешает эти литералы и заносит ссылки на них в StringTable.
  2. Вручную во время выполнения через метод String.intern(): Метод public native String intern() выполняет поиск текущей строки в StringTable:
    • Если строка с таким содержимым уже есть в пуле — метод возвращает ссылку из пула.
    • Если строки в пуле нет — строка, на которой был вызван intern(), регистрируется в StringTable, и возвращается ссылка на этот же объект.
String dynamicString = new StringBuilder("Ja").append("va").toString(); // Объект в куче вне пула
String internedString = dynamicString.intern(); // Помещаем в пул или берем существующий

String literalString = "Java"; // Взято из пула
System.out.println(internedString == literalString); // true!

🔴 Senior Level

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

Версия Java Область памяти Проблемы и особенности
Java 6 и ранее PermGen (Permanent Generation) Фиксированный размер (-XX:MaxPermSize). GC в PermGen работал крайне редко и неэффективно. Массовый вызов intern() быстро приводил к падению JVM с ошибкой java.lang.OutOfMemoryError: PermGen space.
Java 7 Java Heap (Основная куча) String Pool был перенесен в общую кучу (Young/Old Generation). Объекты из пула стали управляться стандартными GC и подчиняться квоте -Xmx.
Java 8+ … Java 21+ Java Heap (НЕ Metaspace!) Распространенный миф гласит, что с удалением PermGen пул ушел в Metaspace. Это грубейшая ошибка! Метаданные классов ушли в нативную память Native Memory (Metaspace), а строковый пул остался в обычной куче Java Heap.

Эволюция внутренней структуры StringTable: от Java 7 до Java 11+

До Java 11 нативная таблица StringTable представляла собой хеш-таблицу с фиксированным количеством корзин (fixed-bucket array) и цепочками коллизий в виде связных списков:

  • Размер пула задавался флагом -XX:StringTableSize=N (по умолчанию 1009 в Java 6, 60013 в Java 7u40+ и Java 8).
  • Главный дефект до Java 11: Таблица не умела автоматически расширяться (отсутствовал rehashing/resize). Если приложение интернировало 5 000 000 уникальных строк при размере таблицы в 60 013 корзин, в каждом бакете образовывалась цепочка длиной ~80 элементов. Сложность поиска деградировала из $O(1)$ в $O(N)$, вызывая катастрофический скачок загрузки CPU!

Революция в Java 11 (JEP 341): В HotSpot JVM нативная таблица StringTable была полностью переписана на основе ConcurrentHashTable:

  • Поддерживает динамический параллельный ресайз (dynamic resizing) при росте load factor.
  • Поддерживает масштабируемый конкурентный доступ без глобальных спинлоков (lock-free lookup).
  • Интегрирована с механизмом безопасных точек (Safepoints) и фонового удаления неиспользуемых строк при сборке мусора.

Сборка мусора и жизненный цикл строк в пуле

Строки в String Pool НЕ являются бессмертными (начиная с Java 7+):

  1. Если строка была помещена в пул через intern(), и в приложении больше не осталось ни одной сильной ссылки (Strong Reference) из GC Roots на этот объект, Garbage Collector имеет полное право удалить этот объект из кучи и очистить запись в StringTable.
  2. Исключение: литералы, привязанные к классам, загруженным системным загрузчиком (Bootstrap/Application ClassLoader). Они живут до тех пор, пока жив сам класс (то есть фактически все время работы JVM).

Альтернатива intern(): String Deduplication в G1 GC

В высоконагруженных системах массовый вызов s.intern() часто приводит к деградации CPU из-за накладных расходов на расчет хешей и синхронизацию. Начиная с Java 8u20, в Garbage Collector G1 (а также в Shenandoah и ZGC) появился прозрачный механизм:

-XX:+UseStringDeduplication -XX:StringDeduplicationAgeThreshold=3
  • Он работает в фоне во время фаз сборки мусора (Concurrent Marking).
  • Если GC видит две строки с одинаковым содержимым, он не объединяет сами объекты String, но заменяет ссылку на внутренний массив byte[] value у второй строки ссылкой на массив первой строки.
  • Дублирующий массив байт освобождается. Код приложения не меняется, StringTable не засоряется, а экономия памяти составляет 10–25% в реальном проде!

4 Tricky Questions

1. Может ли вызов String.intern() привести к OutOfMemoryError, и в какой именно области памяти это произойдет на Java 17/21?

Ответ: Да, может. Начиная с Java 7 и во всех современных версиях (Java 17, 21+) возникнет java.lang.OutOfMemoryError: Java heap space. Причина: Пул строк физически расположен в обычной куче (Java Heap). Если в бесконечном цикле генерировать уникальные строки (UUID.randomUUID().toString().intern()) и сохранять ссылки на них в долгоживущей коллекции, куча JVM переполнится, сборщик мусора не сможет их удалить, и выбросится ошибка нехватки памяти кучи. (В старых версиях Java 6 и ранее выбросилась бы ошибка OutOfMemoryError: PermGen space).

2. Чему будет равен результат выражения s1 == s2 в следующем коде, и почему:

String s1 = new String("hello").intern();
String s2 = "hello";

Ответ: Выражение вернет true. Разбор механики:

  1. При загрузке класса литерал "hello" помещается в String Pool.
  2. Инструкция new String("hello") создает новый объект в куче, содержащий копию символов из пула.
  3. Вызов метода .intern() на этом новом объекте ищет строку "hello" в StringTable, находит там ранее зарегистрированный литерал и возвращает ссылку на него. Переменная s1 получает ссылку на строку из пула.
  4. Выражение String s2 = "hello" берет ссылку на ту же самую строку из пула.
  5. Поскольку обе переменные указывают на один и тот же канонический объект в пуле, сравнение ссылок == дает true.

3. Удаляются ли строки из String Pool сборщиком мусора, если на них больше нет ссылок?

Ответ: Да, удаляются! Начиная с Java 7, строковый пул находится в Java Heap. Записи в нативной StringTable хранятся в виде слабых ссылок (Weak References / OopStorage). Если строка была создана динамически во время выполнения и помещена в пул через str.intern(), но в приложении не осталось «живых» ссылок на этот объект из стеков потоков или статических переменных, сборщик мусора (GC) во время очередной фазы очистит этот объект из кучи и удалит соответствующую запись из StringTable.

4. В чем ключевая разница между оптимизацией памяти через String.intern() и JVM-флагом -XX:+UseStringDeduplication?

Ответ:

  • String.intern():
    • Работает синхронно в коде приложения.
    • Объединяет сами экземпляры String (делает ссылки идентичными, s1 == s2).
    • Тратит процессорное время на поиск в нативной хеш-таблице StringTable при каждом вызове.
    • Может создавать contention в многопоточном коде.
  • -XX:+UseStringDeduplication (в G1/ZGC/Shenandoah):
    • Работает полностью прозрачно и асинхронно во время фоновой сборки мусора (GC thread).
    • НЕ объединяет сами объекты String (ссылки остаются разными: s1 != s2, но s1.equals(s2) == true).
    • Объединяет внутренние массивы байт byte[] value у строк, переживших порог выживания (age threshold), освобождая дублирующие массивы из кучи без нагрузки на CPU приложения.

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

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

«String Pool — это область в Java Heap, реализованная как нативная хеш-таблица StringTable в коде HotSpot JVM. Она предназначена для экономии памяти путем устранения дубликатов строк. Строковые литералы попадают в пул автоматически при загрузке классов, а динамические строки можно поместить вручную через String.intern(). Начиная с Java 7, пул перенесен из PermGen в кучу и очищается GC при отсутствии сильных ссылок. В современных JVM (Java 11+) StringTable реализован на базе ConcurrentHashTable с динамическим масштабированием».

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

  1. Где хранится: В Java Heap (начиная с Java 7 и во всех современных LTS 11/17/21). Не в Metaspace!
  2. Как работает intern(): Возвращает ссылку из пула, если строка уже есть; добавляет строку в пул, если её там не было.
  3. Очистка GC: Строки из пула собираются сборщиком мусора, если на них нет сильных ссылок.
  4. Реализация в HotSpot: StringTable (C++ ConcurrentHashTable с lock-free чтением в Java 11+).
  5. G1 Deduplication: -XX:+UseStringDeduplication дедуплицирует массивы byte[] value в фоне без оверхеда на intern().

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

  • ❌ «String Pool хранится в Metaspace» (Грубейшая ошибка: Metaspace содержит метаданные классов, а String Pool лежит в Java Heap).
  • ❌ «Строки из String Pool никогда не удаляются сборщиком мусора» (Динамически интернированные строки без сильных ссылок собираются GC).
  • ❌ «String.intern() выполняется мгновенно и не тратит CPU» (Это нативный поиск в хеш-таблице с вычислением хеша, создающий CPU overhead при миллионах строк).

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