Як працює String Pool
Оскільки рядки в Java є імутабельними (незмінними), зберігати в пам'яті тисячі однакових рядків виду "USD", "ACTIVE" або "SUCCESS" безглуздо й марнотратно.
🟢 Junior Level
String Pool (Пул рядків) — це спеціальна область зберігання в пам’яті JVM, призначена для оптимізації використання оперативної пам’яті шляхом повторного використання (дедуплікації) незмінних рядкових об’єктів.
Суть у 30 секундах
Оскільки рядки в Java є імутабельними (незмінними), зберігати в пам’яті тисячі однакових рядків виду "USD", "ACTIVE" або "SUCCESS" безглуздо й марнотратно.
- При оголошенні рядка через рядковий літерал (
String s = "Hello";) JVM спочатку перевіряє, чи є вже рядок із таким вмістом у String Pool. - Якщо рядок уже присутній у пулі — JVM не створює новий об’єкт, а повертає посилання на вже наявний екземпляр.
- Якщо рядка в пулі немає — він створюється в купі (Java 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'} │
└─────────────────────────────────┘
Два способи потрапляння рядка до пулу
- Автоматично під час компіляції та завантаження класу (Literals):
Усі рядкові літерали, константи та константні вирази (
"a" + "b") компіляторjavacзаписує в таблицюConstant_Utf8_infoв секціюConstant Poolвсередині.class-файлу. Під час завантаження та лінкування класу JVM автоматично резолвить ці літерали й заносить посилання на них доStringTable. - Вручну під час виконання через метод
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+):
- Якщо рядок було поміщено до пулу через
intern(), і в застосунку більше не залишилося жодного сильного посилання (Strong Reference) з GC Roots на цей об’єкт, Garbage Collector має повне право видалити цей об’єкт із купи та очистити запис уStringTable. - Виняток: літерали, прив’язані до класів, завантажених системним завантажувачем (
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.
Розбір механіки:
- Під час завантаження класу літерал
"hello"поміщається в String Pool. - Інструкція
new String("hello")створює новий об’єкт у купі, що містить копію символів із пулу. - Виклик методу
.intern()на цьому новому об’єкті шукає рядок"hello"уStringTable, знаходить там раніше зареєстрований літерал і повертає посилання на нього. Зміннаs1отримує посилання на рядок із пулу. - Вираз
String s2 = "hello"бере посилання на той самий рядок із пулу. - Оскільки обидві змінні вказують на один і той самий канонічний об’єкт у пулі, порівняння посилань
==дає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).
- НЕ об’єднує самі об’єкти
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з динамічним масштабуванням».
Чек-лист ключових понять
- Де зберігається: У Java Heap (починаючи з Java 7 і в усіх сучасних LTS 11/17/21). Не в Metaspace!
- Як працює
intern(): Повертає посилання з пулу, якщо рядок уже є; додає рядок до пулу, якщо його там не було. - Очищення GC: Рядки з пулу збираються збирачем сміття, якщо на них немає сильних посилань.
- Реалізація в HotSpot:
StringTable(C++ConcurrentHashTableз lock-free читанням у Java 11+). - G1 Deduplication:
-XX:+UseStringDeduplicationдедуплікує масивиbyte[] valueу фоні без оверхеду наintern().
Червоні прапорці (чого в жодному разі не можна говорити)
- ❌ «String Pool зберігається в Metaspace» (Груба помилка: Metaspace містить метадані класів, а String Pool лежить у Java Heap).
- ❌ «Рядки зі String Pool ніколи не видаляються збирачем сміття» (Динамічно інтерновані рядки без сильних посилань збираються GC).
- ❌ «String.intern() виконується миттєво і не навантажує CPU» (Це нативний пошук у хеш-таблиці з обчисленням хешу, що створює помітний CPU overhead при мільйонах викликів).