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

Чи може String Pool стати причиною OutOfMemoryError

Тип і механізм помилки залежать від версії Java: java.lang.OutOfMemoryError: Java heap space або java.lang.OutOfMemoryError: GC overhead limit exceeded.


🟢 Junior Level

Так, може. Пул рядків (String Pool) може стати прямою причиною виникнення фатальної помилки java.lang.OutOfMemoryError (OOM), якщо застосунок безконтрольно наповнює його унікальними рядками через метод String.intern().

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

Тип і механізм помилки залежать від версії Java:

  • У Java 6 та старших версіях: Пул рядків знаходився у фіксованій області PermGen. При інтенсивному інтернуванні рядків швидко виникала помилка java.lang.OutOfMemoryError: PermGen space.
  • У Java 7+ (включаючи Java 17 та 21): Пул рядків перенесено в основну пам’ять купи (Java Heap). Якщо ви інтернуєте мільйони унікальних рядків (наприклад, UUID, токени, хеші, динамічні email-адреси), вони заповнять усю доступну пам’ять купи, що призведе до: java.lang.OutOfMemoryError: Java heap space або java.lang.OutOfMemoryError: GC overhead limit exceeded.
// Класичний приклад коду, що гарантовано призводить до OOM:
public class StringPoolOOMDemo {
    public static void main(String[] args) {
        // Нескінченне інтернування унікальних рядків
        long counter = 0;
        while (true) {
            // Кожен рядок унікальний — інтернування не перевикористовує пам'ять,
            // а створює нескінченний потік нових записів у String Pool
            String unique = ("transaction-id-" + counter++).intern();
        }
    }
}

Золоте правило: Метод String.intern() категорично заборонено викликати для унікальних динамічних даних. Він призначений виключно для значень із високим ступенем повторюваності (наприклад, коди валют "USD", "EUR", статуси замовлень, країни).


🟡 Middle Level

Механіка вичерпання пам’яті пулом рядків

Кожен рядок, розміщений у пулі, споживає пам’ять на двох рівнях:

  1. У Java Heap:
    • Об’єкт java.lang.String (~24 байти при Compressed OOPs).
    • Масив byte[] value (довжина рядка $\times$ розмір символу + заголовок масиву ~16 байтів).
  2. У Native Memory (C-Heap):
    • Вузол нативної таблиці StringTable (покажчики на наступний елемент, хеш, обгортка OopHandle $\approx$ 32 байти).

При інтернуванні 10 мільйонів унікальних рядків довжиною 30 символів кожен: \(\text{Пам'ять Heap} \approx 10^7 \times (24 + 16 + 30) \text{ байтів} \approx 700 \text{ МБ}\) \(\text{Пам'ять Native} \approx 10^7 \times 32 \text{ байти} \approx 320 \text{ МБ}\) Сумарно це понад 1 гігабайт пам’яті, яка не може бути перевикористана, оскільки всі рядки є унікальними.

Сценарії виникнення OutOfMemoryError

  1. Сценарій 1: Витік через збереження посилань у застосунку:
    // Застосунок інтернує рядки і зберігає їх у довгоживучій колекції
    List<String> cache = new ArrayList<>();
    while (true) {
        cache.add(UUID.randomUUID().toString().intern());
    }
    

    Тут GC безсилий: посилання утримуються в cache, пам’ять купи стрімко закінчується $\to$ OOM: Java heap space.

  2. Сценарій 2: Швидкість інтернування перевищує пропускну здатність GC:
    // Навіть без збереження посилань у колекцію:
    while (true) {
        UUID.randomUUID().toString().intern();
    }
    

    Хоча збирач сміття вміє видаляти невикористовувані інтерновані рядки, швидкість створення нових об’єктів в одному потоці CPU сягає сотень тисяч на секунду. Збирач сміття просто не встигає запускати фази очищення $\to$ купа переповнюється, і застосунок падає з OOM.

  3. Сценарій 3: Перевищення ліміту роботи GC (GC overhead limit exceeded): JVM викидає OutOfMemoryError: GC overhead limit exceeded, якщо застосунок витрачає понад 98% процесорного часу на роботу збирача сміття, а звільнити вдається менше ніж 2% пам’яті купи. Це типовий симптом переповнення купи мільйонами інтернованих рядків.

🔴 Senior Level

У HotSpot JVM покажчики на рядкові об’єкти в StringTable реєструються через механізм слабких коренів (Weak Roots / OopStorage):

  1. Фаза маркування GC (Marking): Збирач сміття обходить граф об’єктів від живих коренів застосунку. Якщо об’єкт рядка в купі не досяжний за сильними посиланнями, він не позначається як живий.
  2. Фаза очищення слабких коренів (Weak Root Processing): GC сканує кошики StringTable. Якщо рядок не позначено, викликається метод StringTable::unlink(). Запис видаляється з таблиці, а сам об’єкт String звільняється в купі.

У чому полягає прихована загроза продуктивності (GC Pauses)? Коли у StringTable накопичуються мільйони записів, фаза очищення слабких коренів починає займати колосальний час:

  • У G1 GC та ZGC час фази сканування таблиці рядків зростає пропорційно загальній кількості записів у StringTable.
  • Це призводить до роздування Stop-The-World пауз або затримок фонового циклу GC до сотень мілісекунд, деградуючи latency всього сервісу.

Ризик вичерпання нативної пам’яті (Native Memory Exhaustion)

Хоча в Java 7+ самі об’єкти String переїхали в Java Heap, службові структури StringTable (включаючи кошики та вузли зв’язаних списків C++) розміщуються в Native Memory (C-Heap) через виклики malloc/os::malloc. Якщо в JVM з великим обсягом купи (-Xmx64g) застосунок встигає інтернувати сотні мільйонів унікальних рядків, нативна пам’ять процесу може перевищити ліміт контейнера (Docker/Kubernetes Memory Limit), внаслідок чого операційна система аварійно знищить процес JVM через Linux OOM Killer (exit code 137) взагалі без генерації Java-винятку OutOfMemoryError.

Безпечні архітектурні альтернативи String.intern()

У високонавантажених системах виклик String.intern() визнано антипатерном. Для дедуплікації рядків використовуються два перевірених рішення:

  1. Вбудована дедуплікація рядків G1 GC (-XX:+UseStringDeduplication):
    • Працює повністю асинхронно у фоні.
    • Знаходить однакові масиви byte[] value в об’єктів String старше певного віку (поріг за замовчуванням — 3 цикли збирання).
    • Об’єднує масиви байтів, не зачіпаючи глобальну StringTable.
    • Повністю виключає ризик виникнення OOM від пулу рядків.
  2. Локальний кеш з обмеженням розміру (Bounded Cache):
    // Обмежений LRU-кеш рядків на базі Caffeine
    Cache<String, String> stringPool = Caffeine.newBuilder()
        .maximumSize(50_000)
        .expireAfterAccess(Duration.ofMinutes(15))
        .build();
    
    String canonical = stringPool.get(rawString, k -> k);
    

4 Tricky Questions

1. Якщо збирач сміття вміє видаляти невикористовувані рядки зі String Pool, чому код while(true) { UUID.randomUUID().toString().intern(); } все одно падає з OutOfMemoryError?

Відповідь: Тут працюють одразу три фактори:

  1. Швидкість виділення пам’яті (Allocation Rate): Потік виконання в циклі створює й інтернує десятки тисяч рядків на секунду. Процес GC потребує часу на виявлення, маркування та видалення об’єктів. Швидкість наповнення купи багаторазово перевищує швидкість роботи GC.
  2. Затримка старту GC: Збирач сміття запускається не миттєво на кожній ітерації, а лише при заповненні регіонів Eden. До моменту старту циклу збирання обсяг живих алокацій уже переповнює купу.
  3. Захист GC Overhead Limit: Навіть якщо GC встигає частково очистити купу, безперервні спроби очищення мільйонів рядків забирають майже 100% часу CPU. JVM розпізнає цей стан неефективної роботи і примусово викидає OutOfMemoryError: GC overhead limit exceeded.

2. У чому різниця між OutOfMemoryError: Java heap space та OutOfMemoryError: GC overhead limit exceeded при переповненні пулу рядків?

Відповідь:

  • Java heap space: Виникає, коли в купі фізично не залишилося неперервного блоку пам’яті для виділення чергового об’єкта рядка або масиву byte[], а повний цикл збирання сміття (Full GC) не зміг звільнити достатній обсяг байтів.
  • GC overhead limit exceeded: Є захисним механізмом HotSpot JVM (увімкнений прапорцем -XX:+UseGCOverheadLimit). Він спрацьовує до фізичного вичерпання пам’яті, якщо за останні 5 циклів GC збирач сміття витрачає понад 98% часу процесу та відновлює менше ніж 2% пам’яті купи. Це запобігає нескінченному зависанню застосунку в конвульсіях постійного Stop-the-World GC.

3. Чи може String Pool викликати аварійне завершення JVM через Linux OOM Killer (SIGKILL / Exit Code 137) без генерації Java OutOfMemoryError?

Відповідь: Так, може. У контейнеризованих середовищах (Kubernetes / Docker) ліміт пам’яті встановлюється на весь процес операційної системи (RSS — Resident Set Size), а не лише на Java Heap. Оскільки службові кошики та C++ структури таблиці StringTable виділяються в Native Memory (C-Heap), неконтрольоване додавання сотень мільйонів рядків призведе до роздування нативної пам’яті процесу JVM. Щойно сумарне споживання пам’яті (Heap + Metaspace + Native Memory StringTable + Threads) перевищить ліміт cgroup контейнера, ядро Linux знищить процес сигналом SIGKILL (exit code 137) без створення heap dump і без виклику java.lang.OutOfMemoryError.

4. Як за допомогою утиліти jcmd діагностувати проблеми з переповненням String Pool на працюючому продакшен-сервері?

Відповідь: Для цього використовується нативна команда jcmd <pid> VM.stringtable -verbose:

jcmd 12345 VM.stringtable -verbose

У виводі команди аналізуються ключові метрики:

  1. Number of entries: загальна кількість рядків у пулі. Якщо вона вимірюється мільйонами — у застосунку наявний витік інтернування.
  2. Number of buckets: кількість кошиків у хеш-таблиці.
  3. Maximum bucket size: максимальна довжина зв’язаного списку в одному кошику. Якщо це число перевищує 10–15 (у старих версіях) або сотні, це вказує на колосальну кількість колізій, деградацію швидкості intern() до $O(N)$ та величезні затримки GC під час обходу таблиці.

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

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

«Так, String Pool може викликати OutOfMemoryError. У Java 6 він викликав OOM: PermGen space. У Java 7+ пул розташований у Java Heap, тому безконтрольний виклик String.intern() для унікальних рядків (UUID, ID замовлень, токени) призводить до OOM: Java heap space або OOM: GC overhead limit exceeded. Крім того, службова C++ таблиця пула в нативній пам’яті може роздути RSS процесу і спровокувати аварійне падіння від Linux OOM Killer. Використовувати intern() допустимо тільки для рядків із високим ступенем дублювання, або замінювати його на G1 String Deduplication чи локальний кеш Caffeine».

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

  1. Еволюція помилки: Java 6 (PermGen space) $\to$ Java 7+ (Java heap space / GC overhead limit exceeded).
  2. Два рівні пам’яті: Об’єкти String та byte[] — у Java Heap; хеш-таблиця StringTable — у Native Memory (C-Heap).
  3. GC Weak Roots: Записи пулу утримуються слабкими коренями (OopStorage). Очищення мільйонів записів через StringTable::unlink викликає сплески Stop-the-World пауз GC.
  4. Linux OOM Killer: Роздування Native Memory таблиці рядків може призвести до вбивства контейнера за перевищення ліміту пам’яті (Exit code 137).
  5. Production Best Practice: Заборона intern() на динамічних унікальних рядках; використання -XX:+UseStringDeduplication у G1 GC або LRU-кешу з обмеженням за розміром.

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

  • ❌ Стверджувати: «String Pool не може викликати OOM, тому що GC завжди автоматично видаляє всі зайві рядки».
  • ❌ Рекомендувати викликати intern() для всіх рядків поспіль, включаючи первинні ключі, UUID та email-адреси.
  • ❌ Говорити, що в Java 17 переповнення String Pool викликає OutOfMemoryError: PermGen space (PermGen видалено в Java 8).

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