Що таке дедуплікація рядків в G1 GC?
4. Об'єднання: Якщо знайдено ідентичний масив, поле value перенаправляється на існуючий масив через атомарну операцію
🟢 Junior Level
String Deduplication (дедуплікація рядків) — це функція збирача сміття G1, яка автоматично знаходить однакові рядки в пам’яті і об’єднує їх внутрішні масиви, щоб заощадити пам’ять.
Як це працює:
- JVM помічає, що два різних об’єкти
Stringмістять однаковий текст - Замість того щоб зберігати два однакові масиви байт, вона змушує обидва рядки посилатися на один масив
- Це відбувається автоматично — вам не потрібно змінювати код
Як увімкнути:
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
Як це працює всередині
- Сканування: Під час GC (evacuation phase) G1 помічає об’єкти
Stringу регіонах, що збираються - Черга: Посилання на рядки-кандидати поміщаються в чергу дедуплікації
- Фоновий потік: Окремий потік обчислює хеш
byte[]і шукає збіги в deduplication table - Об’єднання: Якщо знайдено ідентичний масив, поле
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]]