Як працює String pool і як це пов'язано з незмінністю
Коли в коді оголошується строковий літерал (наприклад, String s = "Java";):
🟢 Junior Level
30-секундна відповідь
String Constant Pool (Пул рядків) — це спеціалізована область в оперативній пам’яті (Heap) віртуальної машини JVM, яка реалізує патерн проєктування Flyweight (Пристосуванець) для усунення дублювання однакових рядків у пам’яті.
Коли в коді оголошується строковий літерал (наприклад, String s = "Java";):
- JVM перевіряє наявність рядка з таким вмістом у пулі.
- Якщо рядок уже існує, JVM повертає посилання на наявний об’єкт у купі, не виділяючи нову пам’ять.
- Якщо рядка в пулі немає, створюється новий об’єкт, реєструється в пулі, і посилання повертається коду, що викликає.
Чому 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залишаються різними.