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

Що таке дедуплікація рядків в G1 GC?

4. Об'єднання: Якщо знайдено ідентичний масив, поле value перенаправляється на існуючий масив через атомарну операцію

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

🟢 Junior Level

String Deduplication (дедуплікація рядків) — це функція збирача сміття G1, яка автоматично знаходить однакові рядки в пам’яті і об’єднує їх внутрішні масиви, щоб заощадити пам’ять.

Як це працює:

  1. JVM помічає, що два різних об’єкти String містять однаковий текст
  2. Замість того щоб зберігати два однакові масиви байт, вона змушує обидва рядки посилатися на один масив
  3. Це відбувається автоматично — вам не потрібно змінювати код

Як увімкнути:

java -XX:+UseG1GC -XX:+UseStringDeduplication -jar app.jar

Проста аналогія: Уявіть, що у вас 100 копій однієї і тієї ж книги в бібліотеці. Дедуплікація — це коли бібліотекар залишає одну книгу на полиці, а решті читачів дає посилання на неї. Текст той самий, але місце економиться.

Відмінність від String Pool: | | String Pool (intern()) | String Deduplication | | —————– | ————————– | ————————— | | Що об’єднує | Об’єкти String | Внутрішні byte[] масиви | | Потрібно змінювати код? | Так (str.intern()) | Ні (тільки JVM-флаг) | | Коли працює | При виклику intern() | Під час GC (фоном) |


🟡 Middle Level

Як це працює всередині

  1. Сканування: Під час GC (evacuation phase) G1 помічає об’єкти String у регіонах, що збираються
  2. Черга: Посилання на рядки-кандидати поміщаються в чергу дедуплікації
  3. Фоновий потік: Окремий потік обчислює хеш byte[] і шукає збіги в deduplication table
  4. Об’єднання: Якщо знайдено ідентичний масив, поле value перенаправляється на існуючий масив через атомарну операцію

Відмінність від String Pool — детальне порівняння

Характеристика String Pool (intern()) String Deduplication
Що об’єднує Об’єкти String Внутрішні byte[] масиви
Коли При виклику intern() (синхронно) Під час GC (асинхронно)
Управління Ручне (потрібно викликати intern()) Автоматичне (JVM-флаг)
Працює з будь-якими GC Так Тільки G1 GC і Shenandoah
Вплив на == Робить == true == залишається false (об’єкти різні)
CPU overhead При кожному intern() Фоновий потік, ~2–5% CPU
Пам’ять table StringTable у Heap (~32 bytes/entry) Native memory (~10–50MB)

Коли вмикати

  • Профілювальник показує багато дублюючих рядків у Heap
  • Ви не можете використовувати intern() (legacy-код, складна логіка, немає доступу до коду)
  • Додаток працює на G1 GC (за замовчуванням в Java 9+)
  • Heap > 4GB і рядки займають значну частину

Таблиця типових помилок

Помилка Наслідки Рішення
Очікування миттєвого результату «Увімкнув, а пам’ять не звільнилася» Дедуплікація відбувається під час GC, не миттєво; потрібно кілька GC-циклів
Ввімкнення без моніторингу Невідомо, чи працює взагалі Перевірте через -XX:+PrintStringDeduplicationStatistics
Очікування, що == стане true Логіка порівняння зламана Дедуплікація не змінює посилання на об’єкти, тільки внутрішні масиви
Ввімкнення на ZGC Не працює ZGC поки не підтримує String Deduplication

Коли НЕ використовувати

  • Мало дублікатів: якщо рядки здебільшого унікальні — overhead без профіту
  • Short-lived рядки: вмирають до потрапляння в чергу дедуплікації
  • ZGC: не підтримується (використовуйте -XX:+UseStringDeduplication з Shenandoah)
  • Ультра-low-latency: CPU overhead 2–5% може бути критичним

🔴 Senior Level

Internal Implementation — G1 GC Deduplication Pipeline

┌──────────────────────────────────────────────────────────────┐
│  GC (Evacuation Phase)                                        │
│  ├── Identify String objects in collection set                │
│  ├── Filter: age >= DeduplicationAgeThreshold (default 3)     │
│  ├── Enqueue candidates to dedup queue                        │
│  └── Continue evacuation                                      │
├──────────────────────────────────────────────────────────────┤
│  Deduplication Thread (concurrent, low-priority)              │
│  ├── Dequeue String references                                │
│  ├── Compute hash of byte[] value (age hash, not String.hashCode) │
│  ├── Lookup in deduplication table (native memory hashtable)  │
│  ├── If found: побайтове порівняння для підтвердження         │
│  ├── If match: CAS redirect value reference → shared array    │
│  └── If not found: add to table                               │
└──────────────────────────────────────────────────────────────┘

Deduplication Table:

  • Нативна хеш-таблиця (поза Java Heap, в C-heap)
  • Зберігає хеші byte[] масивів + weak references
  • При збігу хешів — побайтове порівняння для підтвердження (захист від колізій)
  • Оновлення посилання через CAS (Compare-And-Swap) — thread-safe без блокувань

Marking Algorithm:

  • G1 використовує concurrent marking
  • Рядки помічаються під час marking phase
  • Age threshold (за замовчуванням 3 GC-цикли) — дедуплікуються тільки достатньо «переживші» рядки
  • Це відсіює short-lived рядки, які помруть до обробки черги

Trade-offs

Плюси:

  • Прозорість: не потребує зміни коду — тільки JVM-флаг
  • Економія: 10–20% Heap для додатків з великою кількістю тексту
  • Безпека: немає ризику corruption String Pool (дані не змінюються)
  • Працює з будь-якими рядками, не тільки інтернованими

Мінуси:

  • CPU overhead: хешування + lookup + побайтове порівняння (~2–5% CPU)
  • Пам’ять: deduplication table (~10–50MB native memory)
  • Тільки G1 GC (і Shenandoah в OpenJDK)
  • Затримка: дедуплікація відбувається через кілька GC циклів (age threshold)
  • Не дедуплікує: рядки з різними coder (Latin-1 vs UTF-16 — масиви різної довжини)

Edge Cases (мінімум 3)

1. Не дедуплікує рядки з різними coder:

String s1 = "Hello"; // Latin-1, byte[5]
String s2 = new String("Hello".getBytes(StandardCharsets.UTF_16), StandardCharsets.UTF_16); // UTF-16, byte[12]
// Різні coder → різні byte[] масиви → дедуплікація НЕ спрацює
// Навіть якщо вміст однаковий, масиви мають різний розмір і байти

2. Дуже короткоживучі рядки:

void process() {
    String temp = "duplicate"; // Eden
    String temp2 = "duplicate"; // Eden
    // Обидва рядки вмирають у Young GC — не доживають до age threshold (3 GC)
    // Дедуплікація не встигне спрацювати
}

3. Race condition при redirect:

// Dedup thread виконує CAS:
// if (CAS(oldValue, sharedValue)) → success
// Якщо два потоки одночасно намагаються redirect — тільки один succeeds
// Інший потік бачить, що value вже redirect-нуто, і пропускає
// Потік, що читає s.value, завжди бачить консистентне значення (@Stable + CAS)
// @Stable — JVM-аннотація, що повідомляє JIT, що поле записується один раз при конструюванні.

4. String Pool vs Deduplication — взаємодія:

String s1 = new String("Hello");
String s2 = new String("Hello");
// s1.value і s2.value — різні byte[] масиви (обидва Latin-1, byte[5])
// Після дедуплікації: s1.value і s2.value → ОДИН і той самий byte[]
// Але s1 != s2 (об'єкти String різні!)
// Економія: один byte[5] замість двох → 5 bytes

5. Субнормальні рядки (дуже довгі):

String huge = "A".repeat(10_000_000); // 10MB
String huge2 = "A".repeat(10_000_000); // 10MB
// Побайтове порівняння 10MB — expensive (~10ms)
// Deduplication thread може сповільнити GC
// На практиці: довгі рядки можуть бути ідентичними в log aggregators або data pipelines
// (однакові JSON payloads), але це рідкість для typical web applications.

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

Метрика Без дедуплікації З дедуплікацією Delta
Heap usage 4.0 GB 3.2 GB -20%
GC pause (avg) 50ms 55ms +10%
CPU overhead Baseline +2–5% Small
Young GC 10ms 10ms No change
Mixed GC 50ms 55ms +5ms
Native memory (table) 0MB 10–50MB Extra

Економія пам’яті (реальні сценарії):

  • JSON API сервіс: 15–25% рядків — дублікати (keys, status values)
  • Log aggregator: 30–40% дублікатів (levels, service names, host names)
  • ETL pipeline: 5–10% дублікатів (category names, country codes)

Thread Safety

Дедуплікація thread-safe:

  • Оновлення посилання value через CAS (атомарна операція)
  • value поле — @Stable, але JVM дозволяє redirect в рамках GC
  • Потоки, що читають, завжди бачать консистентне значення (memory barriers при GC)
  • Deduplication thread — один (single-threaded), немає contention між dedup-потоками

Production War Story

Сценарій 1: JSON API сервіс (G1 GC, 8GB Heap, Spring Boot, 50K RPS):

  • Без дедуплікації: Heap usage 6.5GB, Full GC кожні 30 хвилин, p99 latency = 25ms
  • З дедуплікацією: Heap usage 5.2GB, Full GC кожні 50 хвилин, p99 latency = 20ms
  • CPU overhead: +3% (прийнятно)
  • Stats: deduplicated 2.3GB рядків, 850K unique byte[] масивів об’єднано
  • Результат: скорочення інстансів з 10 до 8 (економія $15K/міс)

Сценарій 2: Log aggregator (1M log lines/min, G1 GC, 12GB Heap):

  • Поля level, service, host — багато дублікатів ("INFO", "UserService", "host-1")
  • Без дедуплікації: 12GB Heap
  • З дедуплікацією: 9GB Heap
  • Економія: 3GB → менше інстансів у кластері
  • Проблема: CPU overhead виріс до 7% через величезну кількість рядків. Fix: -XX:StringDeduplicationAgeThreshold=5 (збільшили age threshold, менше кандидатів → менше CPU).

Сценарій 3 (anti-pattern): Команда ввімкнула дедуплікацію «на всякий випадок» для додатка з унікальними рядками (UUID, hash, timestamps). CPU overhead +4%, економія пам’яті 0.5%. Вимкнули.

Monitoring

# Увімкнути статистику дедуплікації
-XX:+PrintStringDeduplicationStatistics
-XX:+PrintGC

# Вивід в GC логах:
# [GC concurrent string deduplication]
# String Deduplication: 1.2GB deduplicated (500K strings)
# [DEDUP: 500K strings, 1.2GB, 2.3ms]

# JCmd — статистика в runtime
jcmd <pid> GC.string_deduplication_statistics

# Вивід:
# String Deduplication Statistics:
#   Executed: 1234 times
#   Deduplicated: 567890 strings (1.2GB)
#   Skipped: 123456 strings (already deduplicated)

# JFR (Java Flight Recorder)
java -XX:StartFlightRecording=filename=recording.jfr ...
# Events: StringDeduplicationStatistics
# В JDK Mission Controller: Memory → String Deduplication

# Налаштування age threshold
-XX:StringDeduplicationAgeThreshold=3  # За замовчуванням 3 GC-цикли
# Збільште до 5–10, якщо CPU overhead занадто високий

Best Practices для Highload

  • Вмикайте коли профілювальник показує > 10% рядків-дублікатів у Heap
  • Не використовуйте як заміну intern() для високодублюючих long-lived рядків (словники, конфіги) — intern() ефективніше
  • Моніторьте CPU overhead — якщо > 5%, збільште -XX:StringDeduplicationAgeThreshold=5
  • Комбінуйте: intern() для dictionary data (enum values, status codes) + deduplication для всього іншого
  • Для ZGC: не підтримується (ZGC (as of JDK 21): не підтримує String Deduplication) — розгляньте Shenandoah (-XX:+UseShenandoahGC -XX:+UseStringDeduplication)
  • Для max економії: налаштуйте -XX:G1HeapRegionSize — менші регіони → частіша evacuation → більше кандидатів на дедуплікацію
  • Не вмикайте для short-lived додатків (CLI, batch jobs < 1min) — не встигне спрацювати
  • Для ultra-low-latency: бенчмарк з і без дедуплікації; іноді 5ms GC pause increase критичний

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

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

  • String Deduplication — функція G1 GC, автоматично об’єднує однакові byte[] масиви рядків
  • Вмикається флагом: -XX:+UseG1GC -XX:+UseStringDeduplication (не потребує зміни коду)
  • Відмінність від String Pool: дедуплікація об’єднує byte[], а не об’єкти String; == залишається false
  • Працює асинхронно під час GC, age threshold (за замовчуванням 3 GC-цикли) — відсіює short-lived рядки
  • CPU overhead: ~2-5%, native memory для dedup table: ~10-50MB
  • Не дедуплікує рядки з різними coder (Latin-1 vs UTF-16)

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

  • Чим дедуплікація відрізняється від intern()?intern() об’єднує об’єкти String (== стає true), потребує коду. Дедуплікація — тільки byte[] масиви, == залишається false, без зміни коду.
  • Яка економія пам’яті? — 10-20% Heap для додатків з дублюючими рядками. JSON API: 15-25%, log aggregator: 30-40%.
  • Чому не працює з ZGC? — ZGC (as of JDK 21) не підтримує String Deduplication. Альтернатива: Shenandoah GC.
  • Як зменшити CPU overhead? — Збільшити -XX:StringDeduplicationAgeThreshold=5 — менше кандидатів, менше CPU.

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

  • ❌ “Дедуплікація робить == true” — об’єкти String залишаються різними, об’єднуються тільки byte[]
  • ❌ “Це заміна intern()” — ні, intern() ефективніше для dictionary data
  • ❌ “Працює миттєво” — відбувається під час GC, потрібно кілька циклів
  • ❌ “Працює з будь-яким GC” — тільки G1 GC і Shenandoah

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

  • [[1. Як працює String Pool]]
  • [[3. Коли потрібно використовувати intern()]]
  • [[19. Що таке компактні рядки в Java 9+]]
  • [[20. Як дізнатися, скільки пам’яті займає String]]