🔤 Розділ 12 · Питання #11

В якій області пам'яті зберігається String Pool

Пул рядків (String Pool або String Constant Pool) — це спеціальний механізм віртуальної машини Java, призначений для оптимізації використання пам'яті шляхом кешування та повторн...


🟢 Junior Level

Пул рядків (String Pool або String Constant Pool) — це спеціальний механізм віртуальної машини Java, призначений для оптимізації використання пам’яті шляхом кешування та повторного використання унікальних рядкових літералів.

Розташування пулу рядків у пам’яті кардинально змінювалося в ході еволюції Java:

  • У Java 6 та раніших версіях: String Pool розташовувався в PermGen (Permanent Generation) — спеціальній фіксованій області купи, призначеній для метаданих класів. Переповнення цієї області часто призводило до фатальної помилки java.lang.OutOfMemoryError: PermGen space.
  • Починаючи з Java 7 (точніше, з Java 7u6) та у всіх сучасних версіях (Java 8, 11, 17, 21+): String Pool перенесено і він розміщується безпосередньо в основній купі (Java Heap).

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

Головне, про що слід пам’ятати на співбесіді: в сучасних версіях Java пул рядків знаходиться в Java Heap. Він ділить той самий простір пам’яті, що й усі звичайні об’єкти програми, і обмежений загальним обсягом купи (-Xmx). Збирач сміття (GC) автоматично видаляє невикористовувані рядки з пулу, якщо на них більше немає активних сильних посилань у застосунку.

⚠️ Критична помилка: Багато розробників помилково вважають, що в Java 8 пул рядків переїхав у Metaspace. Це не так: у Metaspace переїхали лише метадані класів, а String Pool залишився в Java Heap.


🟡 Middle Level

Архітектурний поділ: об’єкти в Heap проти індексу в Native Memory

При детальному аналізі нутрощів JVM під поняттям «String Pool» приховуються дві різні сутності:

  1. Самі об’єкти рядків (java.lang.String та їхні масиви byte[] value): Вони виділяються в Java Heap (як правило, потрапляючи в Old Generation при тривалому зберіганні або безпосередньо під час інтернування).
  2. Індексна хеш-таблиця (StringTable у вихідному коді HotSpot C++): Це службова структура даних (хеш-таблиця адрес), яка розміщується в Native Memory (C-Heap) процесу JVM.
┌─────────────────────────────────────────────────────────────┐
│                      JVM Memory                             │
│                                                             │
│   ┌─────────────────────────────────────────────────────┐   │
│   │                     Java Heap                       │   │
│   │                                                     │   │
│   │   [Звичайні об'єкти: User, Order, byte[]]           │   │
│   │                                                     │   │
│   │   [Рядкові об'єкти String Pool: "hello", "admin"]   │   │
│   │         ▲                     ▲                     │   │
│   └─────────┼─────────────────────┼─────────────────────┘   │
│             │                     │                         │
│             │ покажчики (oop)     │                         │
│   ┌─────────┴─────────────────────┴─────────────────────┐   │
│   │         Native Memory (C-Heap / Process Memory)     │   │
│   │                                                     │   │
│   │   HotSpot C++ StringTable (ConcurrentHashTable)     │   │
│   │   [Bucket 0] ──► [Bucket 1] ──► [Bucket 2]          │   │
│   └─────────────────────────────────────────────────────┘   │
│                                                             │
│   ┌─────────────────────────────────────────────────────┐   │
│   │                     Metaspace                       │   │
│   │   [Klass metadata, Method bytecode, Constant Pools] │   │
│   └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

Навіщо String Pool перенесли з PermGen до Java Heap?

У Java 6 перебування пулу рядків у PermGen створювало колосальні проблеми в продакшені:

  1. Жорсткий ліміт пам’яті: PermGen мав фіксовану верхню межу (за замовчуванням від 64 до 256 МБ, прапорець -XX:MaxPermSize). Будь-яке активне використання методу String.intern() швидко призводило до OutOfMemoryError: PermGen space та аварійного завершення JVM.
  2. Неефективне збирання сміття: Збирач сміття в PermGen викликався вкрай рідко (лише під час Full GC), а алгоритм відстеження посилань для інтернованих рядків був неоптимальним.
  3. Безпека в Heap: Перенесення в загальну купу зняло жорстку стелю пам’яті — тепер пул обмежений лише параметром -Xmx (гігабайти пам’яті), а збирач сміття очищає невикористовувані рядки пулу регулярно разом зі звичайними об’єктами.

Порівняльна таблиця за версіями Java

| Характеристика | Java 6 і нижче | Java 7 (починаючи з 7u6) | Java 8 – 21+ | | :— | :— | :— | :— | | Область пам’яті об’єктів пулу | PermGen | Java Heap | Java Heap | | Обмеження пам’яті | -XX:MaxPermSize (64–256 MB) | -Xmx (загальна купа) | -Xmx (загальна купа) | | Тип помилки при переповненні | OOM: PermGen space | OOM: Java heap space | OOM: Java heap space | | Збирання сміття для пулу | Лише під час Full GC (важке) | Звичайні цикли GC (Minor/Major) | Паралельне/конкурентне очищення GC | | Де метадані класів | PermGen | PermGen | Metaspace (Native Memory) | | Реалізація StringTable у C++ | Hashtable з глобальним локом | Hashtable з глобальним локом | ConcurrentHashTable (Java 11+, lock-free) |


🔴 Senior Level

Внутрішня будова StringTable в HotSpot (C++)

У вихідному коді OpenJDK HotSpot пул рядків реалізовано як нативну хеш-таблицю.

  • До Java 11: Використовувався клас StringTable : public Hashtable. Доступ до таблиці синхронізувався глобальним м’ютексом StringTable_lock. При інтенсивному інтернуванні в багатопотокових застосунках потоки стикалися з жорстким contention на цьому блокуванні.
  • Java 11+ (JEP 341: Concurrent StringTable): Таблицю було повністю переписано на ConcurrentHashTable (lock-free структура на базі алгоритмів без очікування):
    • Потоки можуть паралельно шукати та вставляти рядки без глобальних блокувань.
    • Таблиця вміє динамічно збільшувати кількість кошиків (resizing) при перевищенні коефіцієнта заповненості (Load Factor).

Як Garbage Collector очищає String Pool (Слабкі корені та OopStorage)

Рядки, розміщені у String Pool, не залишаються там назавжди (якщо вони не зашиті в пулі констант завантаженого класу).

  1. Покажчики зі StringTable на Java-об’єкти обробляються збирачем сміття як слабкі корені (Weak Roots).
  2. У сучасних версіях HotSpot для цього використовується механізм OopStorage (Weak Processor).
  3. Під час фази маркування (Marking Phase) збирач сміття обходить граф живих об’єктів від Strong Roots (стеки потоків, JNI handles, статичні поля).
  4. Якщо на об’єкт String у пулі немає жодного сильного посилання із застосунку, збирач сміття позначає його як кандидата на видалення.
  5. Під час фази зачистки (Cleaning / Unlinking) JVM викликає нативний метод StringTable::unlink(), видаляючи запис із хеш-таблиці, після чого пам’ять об’єкта String та його масиву byte[] звільняється збирачем сміття як звичайне сміття.

Постійний пул класу (Runtime Constant Pool у Metaspace) проти String Pool

Важливо розрізняти Run-Time Constant Pool конкретного класу та глобальний String Pool:

  • Constant Pool класу: Знаходиться в пам’яті Metaspace усередині C++ структури InstanceKlass. Для кожного строкового літерала в байт-коді записано тег CONSTANT_String_info з індексом у таблицю символів CONSTANT_Utf8_info.
  • Роздільна здатність літерала (Resolution): При першому виконанні інструкції байт-коду ldc (Load Constant) HotSpot бере символи UTF-8 з Metaspace, звертається до глобальної StringTable в Native Memory та знаходить або створює екземпляр java.lang.String у Java Heap. Посилання на цей об’єкт кешується в Constant Pool Cache класу в Metaspace.
Metaspace: Class Constant Pool Cache [Index #5] ──покажчик oop──► Java Heap: String ("test")
                                                                         ▲
Native Memory: StringTable [Bucket Hash] ──weak handle───────────────────┘

4 Tricky Questions

1. Де фізично зберігаються рядкові об’єкти пулу, а де — сама хеш-таблиця StringTable в Java 17/21?

Відповідь: Тут відбувається розділення областей пам’яті:

  • Самі об’єкти java.lang.String та їхні внутрішні масиви byte[] value фізично розміщуються в Java Heap (зазвичай переходячи в Old Generation при тривалому житті).
  • Службова індексна структура — хеш-таблиця StringTable (масив покажчиків на кошики, нативні вузли зв’язаних списків і службові структури ConcurrentHashTable) — розміщується в Native Memory (C-Heap) процесу операційної системи поза Java Heap.

2. Чи може збирач сміття (GC) видалити рядок зі String Pool, якщо ми викликали "dynamicString".intern()?

Відповідь: Так, може. Запис у StringTable утримує об’єкт за слабким посиланням (weak reference / weak root). Якщо після виклику String s = new String("data").intern() посилання s вийшло з області видимості (виконання методу завершилося, змінну занулено), і більше жоден живий об’єкт у Heap не посилається на цей екземпляр рядка, збирач сміття під час найближчого циклу GC (або фази Concurrent Weak Root Processing) видалить запис зі StringTable та звільнить пам’ять у купі. Рядок не буде зібраний лише тоді, коли він жорстко зашитий у пулі констант класу (поки живе ClassLoader цього класу) або якщо на нього існує сильне посилання (Strong Reference) у живих об’єктах.

3. Чому багато розробників переконані, що в Java 8 String Pool перенесли в Metaspace, і чому це груба помилка?

Відповідь: У Java 8 було повністю видалено область пам’яті PermGen, а замість неї створено Metaspace у Native Memory для зберігання метаданих класів (структури Klass, Method, Field). Через це виникло хибне уявлення: «якщо PermGen видалили і замінили на Metaspace, отже, увесь вміст PermGen переїхав у Metaspace». Це помилка: пул рядків було винесено з PermGen у Java Heap ще в Java 7 (update 6) за кілька років до виходу Java 8. У Java 8 до Metaspace переїхали виключно метадані класів, а String Pool залишився в Java Heap, де й розташовується сьогодні.

4. Що відбудеться, якщо запустити JVM із параметром -XX:StringTableSize=1009 та інтернувати 1 000 000 унікальних рядків у Java 8 та в Java 17?

Відповідь:

  • У Java 8: Розмір StringTable фіксований (1009 кошиків). При додаванні 1 000 000 рядків середня довжина ланцюжка колізій у кожному кошику становитиме близько 1000 елементів. Оскільки в Java 8 доступ до StringTable захищений глобальним м’ютексом StringTable_lock, кожен виклик .intern() перетвориться на лінійний пошук $O(N)$ зв’язаним списком із 1000 елементів під глобальним блокуванням. Продуктивність застосунку впаде в десятки разів (катастрофічна деградація CPU).
  • У Java 17+: Завдяки JEP 341 таблиця реалізована як ConcurrentHashTable. JVM динамічно відстежує коефіцієнт завантаження та автоматично виконує масштабування (ресайзинг) кількості кошиків у фоновому режимі без блокування читаючих потоків. Деградація продуктивності буде неспівмірно меншою.

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь

«String Pool у сучасних версіях Java (починаючи з Java 7 і включаючи Java 8, 11, 17, 21+) розміщується в Java Heap. Самі об’єкти рядків та їхні масиви байтів живуть у купі та збираються збирачем сміття, якщо на них немає сильних посилань. Службова хеш-таблиця StringTable знаходиться в Native Memory. Поширений міф про те, що пул розташований у Metaspace, є помилковим: у Metaspace зберігаються лише метадані класів».

Ключові тези для Senior-інтерв’ю

  1. Еволюція: Java 6 (PermGen) $\to$ Java 7u6+ (Java Heap) $\to$ Java 8 (з’явився Metaspace, але пул залишився в Heap).
  2. Розподіл пам’яті: Об’єкти String — у Java Heap (ліміт -Xmx). Таблиця StringTable — у Native Memory (C-Heap).
  3. GC та Weak Roots: Посилання зі StringTable є слабкими коренями (OopStorage). Невикористовувані інтерновані рядки видаляються через StringTable::unlink().
  4. JEP 341 (Java 11): Заміна старої синхронізованої Hashtable на lock-free ConcurrentHashTable з динамічним розширенням.
  5. Діагностика: Прапорці -XX:StringTableSize, утиліта jcmd <pid> VM.stringtable -verbose.

Червоні прапорці (Чого говорити категорично не можна)

  • ❌ Стверджувати: «String Pool розташований у Metaspace» (груба помилка розуміння архітектури пам’яті JVM).
  • ❌ Говорити: «Рядки зі String Pool ніколи не видаляються збирачем сміття» (видаляються, оскільки утримуються слабкими посиланнями).
  • ❌ Заявляти: «String Pool обмежений окремим прапорцем пам’яті» (пул ділить загальну пам’ять купи, обмежений -Xmx).

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