🔒 Розділ 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() переносить рядок, створений динамічно під час виконання (наприклад, прочитаний з бази даних, файлу чи HTTP-запиту), у 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)$ за часом, паралізуючи процесор.

Уніфікована дедуплікація рядків (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 залишаються різними.

Пов’язані питання