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

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

Кожен унікальний рядок у пулі — це об'єкт (~48 байт) + запис у хеш-таблиці (~32 байти). 100 мільйонів унікальних рядків = ~8GB тільки на пул.

Мовні версії: English Russian Ukrainian

🟢 Junior Level

Так, може. Але тип помилки залежить від версії Java.

Кожен унікальний рядок у пулі — це об’єкт (~48 байт) + запис у хеш-таблиці (~32 байти). 100 мільйонів унікальних рядків = ~8GB тільки на пул.

Java 6 і раніше:

java.lang.OutOfMemoryError: PermGen space

Пул рядків був в області PermGen з фіксованим розміром. Багато intern() → пам’ять закінчилася → падіння.

Java 7+:

java.lang.OutOfMemoryError: Java heap space

Пул переїхав в основну купу. Якщо інтернювати мільйони унікальних рядків — вони заповнять всю купу.

Приклад:

// Небезпечний код — може викликати OOM
List<String> list = new ArrayList<>();
for (int i = 0; i < 100_000_000; i++) {
    list.add(String.valueOf(i).intern()); // Кожен рядок унікальний!
}

Як уникнути: Не використовуйте intern() для унікальних рядків (UUID, хеші, ID). Використовуйте тільки для рядків з дублікатами.


🟡 Middle Level

Коли виникає OOM

Сценарій 1: Масове intern() унікальних рядків

// Кожен рядок унікальний — пул росте безконтрольно
for (User user : users) {
    String email = user.getEmail().intern(); // UUID/email — всі різні
}

Сценарій 2: Витік через strong references

// Рядки в пулі + посилання в колекції = ніколи не будуть зібрані GC
Set<String> cache = new HashSet<>();
while (true) {
    String data = readFromNetwork().intern();
    cache.add(data); // Росте нескінченно
}

Як запобігти

  1. Моніторинг: jcmd <pid> VM.stringtable -verbose
  2. Тюнінг: -XX:StringTableSize=1000003
  3. Альтернатива: -XX:+UseStringDeduplication (G1 GC)
  4. Власний кеш: ConcurrentHashMap<String, String> з eviction

Типові помилки

  1. Помилка: Думати, що GC автоматично почистить пул Рішення: StringTable — це нативна хеш-таблиця JVM, яка зберігає strong references на об’єкти String. Доки запис у таблиці — об’єкт не eligible для GC.

  2. Помилка: intern() для кожного рядка з БД Рішення: Тільки для полів з високим ступенем дублювання


🔴 Senior Level

Internal Implementation

StringTable — нативна хеш-таблиця:

oop StringTable::intern(Symbol* string, TRAPS) {
  unsigned int hashValue = hash_string(string);
  int index = the_table()->hash_to_index(hashValue);
  oop found_string = the_table()->lookup(index, string, hashValue);

  // Found
  if (found_string != NULL) return found_string;

  // Not found — створити новий запис у StringTable
  Handle string_object = java_lang_String::create_from_symbol(string, CHECK_NULL);
  the_table()->basic_add(index, string_object, string, hashValue, CHECK_NULL);
  return string_object();
}

Кожен запис у StringTable — strong reference. GC не видалить String, доки запис у таблиці.

Два типи OOM

Type 1: StringTable overflow (hash collisions)

  • При StringTableSize < кількість рядків → довгі ланцюжки колізій
  • intern() деградує до O(n)
  • Додаток “зависає” — CPU 100% на пошук у таблиці
  • Може проявитися як GC Overhead Limit Exceeded

Type 2: Heap exhaustion

  • Мільйони унікальних інтернованих рядків заповнюють Heap
  • OOM: Java heap space
  • Відбувається коли пул конкурує за пам’ять з бізнес-об’єктами

Архітектурні Trade-offs

String Pool і GC:

  • Young Gen: нові intern() → Eden → швидко вмирають (якщо немає посилань)
  • Old Gen: довгоживучі intern() → Old Gen → Full GC сканує їх усі
  • G1 GC: StringTable scan додає до evacuation pause

Contention:

  • StringTable — глобальна структура з блокуванням
  • При паралельному intern() із сотень потоків → contention
  • Може стати bottleneck у highload системах

Edge Cases

  1. StringTableSize за замовчуванням (60013): Якщо ви інтернюєте 1M+ унікальних рядків, середня довжина ланцюжка = 1M / 60013 ≈ 16. Найгірший випадок — НАБАГАТО більше.

  2. GC і String Pool cleaning: Починаючи з Java 7u40, JVM видаляє unreachable записи з StringTable під час Full GC. Але це працює тільки якщо на рядки немає strong references.

  3. ZGC/Shenandoah: Ці GC використовують concurrent marking. StringTable scan відбувається concurrently, але overhead все одно є.

Продуктивність

| Метрика | Значення | | ———————————- | ———————————————— | | StringTableSize default | 60013 | | Max safe entries (default size) | ~100K | | intern() без колізій | ~50-100ns | | intern() з колізіями (1M entries) | 10-50μs | | Memory per entry | ~48 bytes (String) + ~32 bytes (Hashtable entry) |

Production Experience

Сценарій: ETL pipeline — завантаження 50M записів з CSV:

  • Поле category — 500 унікальних значень → intern() заощадив 99.9% пам’яті
  • Поле id — 50M унікальних значень → intern() викликав OOM через 20 хвилин
  • Fix: intern() тільки для category, для id — звичайний String
  • Результат: стабільна робота, heap usage знизився з 8GB до 3GB

Правило: якщо кількість унікальних значень поля < 1% від загальної кількості записів — intern() має сенс. Якщо > 50% — шкідливий.

Сценарій 2: API gateway — 100K RPS:

  • Кожен запит: intern() для header names (Content-Type, Authorization)
  • StringTable виріс до 500K entries
  • Без збільшення StringTableSize: p99 latency виріс з 5ms до 50ms
  • Fix: -XX:StringTableSize=1000003 → p99 повернувся до 5ms

Monitoring

# Статистика StringTable
jcmd <pid> VM.stringtable -verbose
# Вивід:
# Number of buckets       : 60013
# Number of entries       : 500234
# Maximum bucket size     : 87   ← якщо > 10, проблема!

# GC логи
java -Xlog:gc*:file=gc.log:time,level,tags ...

# Heap histogram
jmap -histo:live <pid> | head -20

Best Practices для Highload

  • Ніколи не інтернюйте унікальні рядки (UUID, ID, хеші, timestamps)
  • Збільште -XX:StringTableSize при очікуваному > 100K унікальних рядків
  • Моніторьте Maximum bucket size через jcmd
  • Розгляньте ConcurrentHashMap<String, String> з size limit + LRU eviction
  • Для автоматичної економії: -XX:+UseStringDeduplication (G1 GC)

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

Обов’язково знати:

  • String Pool може викликати OOM: в Java 6 — PermGen space, в Java 7+ — Java heap space
  • Масовий intern() унікальних рядків (UUID, email, ID) — головна причина OOM
  • StringTable зберігає strong references — GC не видалить рядки, доки записи в таблиці
  • Два типи проблем: переповнення хеш-таблиці (колізії) та вичерпання купи
  • Maximum bucket size > 10 — ознака проблеми, потрібно збільшити StringTableSize
  • Правило: intern() має сенс якщо унікальних значень < 1% від загальної кількості записів

Часті уточнюючі запитання:

  • Які рядки НЕ варто інтернювати? — Унікальні: UUID, ID, email, хеші, timestamps.
  • Як запобігти OOM від String Pool? — Збільшити StringTableSize, не інтернювати унікальні рядки, моніторити через jcmd VM.stringtable.
  • Що таке contention на StringTable?StringTable — глобальна структура з блокуванням. При паралельному intern() із сотень потоків — bottleneck.
  • Чи може GC почистити String Pool? — В Java 7+ так, якщо на рядки немає strong references. Але якщо ви утримуєте посилання — не зберуться.

Червоні прапорці (НЕ говорити):

  • ❌ “intern() для кожного рядка з БД — гарна ідея” — тільки для полів з дублікатами
  • ❌ “String Pool не може викликати OOM” — може, і це часта проблема
  • ❌ “GC автоматично почистить пул” — тільки якщо немає strong references
  • ❌ “StringTableSize не потрібно налаштовувати” — при > 100K унікальних рядків обов’язково

Пов’язані теми:

  • [[1. Як працює String Pool]]
  • [[3. Коли потрібно використовувати intern()]]
  • [[11. В якій області пам’яті зберігається String Pool]]
  • [[22. Що таке дедуплікація рядків в G1 GC]]