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

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

У типових корпоративних застосунках мільйони рядків дублюються (статуси "ACTIVE", "SUCCESS", ролі користувачів, JSON-ключі, заголовки HTTP).


🟢 Junior Level

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

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

У типових корпоративних застосунках мільйони рядків дублюються (статуси "ACTIVE", "SUCCESS", ролі користувачів, JSON-ключі, заголовки HTTP).

  • Без дедуплікації кожен об’єкт String зберігає свій власний дублікат масиву byte[] value.
  • З увімкненою дедуплікацією JVM знаходить дублікати та звільняє зайві масиви байтів, залишаючи один екземпляр масиву для всіх однакових рядків.
  • Це дає економію від 10% до 25% усієї пам’яті купи (Heap) без зміни жодного рядка вихідного Java-коду.
# Увімкнення дедуплікації рядків під час запуску JVM:
java -XX:+UseG1GC -XX:+UseStringDeduplication -jar app.jar

Ключова відмінність від String Pool (intern())

| Параметр | String Pool (intern()) | String Deduplication | | :— | :— | :— | | Що об’єднується | Самі об’єкти java.lang.String | Тільки внутрішні масиви byte[] value | | Результат s1 == s2 | true (посилання на об’єкти стають рівними) | false (об’єкти залишаються різними!) | | Як керується | Вручну викликом str.intern() у Java-коді | Автоматично фоновим потоком GC через прапорець JVM | | Ризик OutOfMemoryError | ⚠️ Високий (у разі інтернування унікальних рядків) | ❌ Відсутній (GC сам керує пам’яттю) |


🟡 Middle Level

Життєвий цикл дедуплікації рядка

Дедуплікація не відбувається миттєво під час створення рядка (інакше це паралізувало б CPU). Процес розділений на етапи:

  1. Народження в Eden: Рядок створюється зі своїм власним масивом byte[].
  2. Фільтрація за віком (Age Threshold): Переважна більшість рядків помирає в поколінні Eden у перші мілісекунди. Дедуплікатор ігнорує молоді рядки, доки вони не переживуть кілька циклів збирання сміття (поріг за замовчуванням — 3 цикли GC, прапорець -XX:StringDeduplicationAgeThreshold=3).
  3. Постановка в чергу: Рядки-кандидати, що вижили в Young GC, реєструються у спеціальній черзі дедуплікації (Deduplication Queue).
  4. Асинхронна обробка фоновим потоком (StringDedupThread):
    • Обчислюється 32-бітний геш масиву byte[] value.
    • Здійснюється пошук у нативній геш-таблиці дедуплікації (Deduplication Table у Native Memory).
    • Якщо геш збігся — виконується побайтове порівняння (для виключення колізій).
    • Якщо масиви ідентичні — посилання value другого об’єкта String через атомарну операцію CAS перенаправляється на масив першого об’єкта.
    • Звільнений дублікат масиву забирається збирачем сміття в наступному циклі GC.
До дедуплікації:
String A [Header] ──value──► byte[] [ 'H', 'e', 'l', 'l', 'o' ] (24 байти)
String B [Header] ──value──► byte[] [ 'H', 'e', 'l', 'l', 'o' ] (24 байти)

Після дедуплікації:
String A [Header] ──value──► byte[] [ 'H', 'e', 'l', 'l', 'o' ] (24 байти)
                                   ▲
String B [Header] ──value──────────┘
(Другий масив byte[] звільнений збирачем сміття!)

Підтримка у збирачах сміття: еволюція до Java 21

  • Java 8 Update 20 – Java 17: Дедуплікація рядків підтримувалася тільки в G1 GC та Shenandoah GC. Збирачі Parallel GC, Serial GC та ZGC її не підтримували.
  • Java 18+ (включно з Java 21 LTS): Механізм дедуплікації рядків був уніфікований і винесений із G1 у загальний шар GC (JDK-8267186). Тепер прапорець -XX:+UseStringDeduplication підтримується в G1, Parallel, Serial та ZGC.

🔴 Senior Level

Архітектура HotSpot: Нативна таблиця та потокобезпека CAS

У вихідному коді OpenJDK HotSpot механізм дедуплікації реалізований мовою C++:

  1. Нативна таблиця дедуплікації (StringDedupTable): Зберігається в пам’яті C-Heap (поза Java Heap) і займає від 15 до 50 МБ нативної пам’яті. Записи в таблиці утримуються слабкими вказівниками (OopHandle), тому таблиця не захищає об’єкти від збирання сміття.
  2. Атомарна підміна через CAS: Поле value в java.lang.String позначено як @Stable final. JVM здійснює підміну посилання на масив через атомарну інструкцію Atomic::cmpxchg (Compare-And-Swap) з бар’єром пам’яті StoreLoad/Release. Потік застосунку, який викликав у цей момент метод charAt() або getBytes(), гарантовано побачить або старий масив, або новий — оскільки їхній вміст байт-у-байт ідентичний, операція є 100% безпечною та непомітною для прикладного коду.
  3. Обмеження за кодуванням (coder check): Дедуплікація об’єднує лише масиви з однаковою довжиною та однаковим coder. Якщо рядок "Hello" має coder = LATIN1 (5 байтів), а інший "Hello" випадково був створений як UTF16 (10 байтів), вони дедупліковані не будуть.

Аналіз накладних витрат: CPU vs Memory Trade-off

Увімкнення -XX:+UseStringDeduplication не є «безкоштовним»:

  • Накладні витрати CPU: Фоновий потік дедуплікації безперервно обчислює геші та виконує побайтові порівняння, споживаючи від 1.5% до 4% процесорного часу.
  • Коли дедуплікація шкідлива: Якщо в застосунку переважають унікальні рядки (UUID, геші паролів, часові мітки), дедуплікатор витратить такти CPU на даремне гешування, не знайде збігів і заощадить 0% пам’яті.
  • Коли дедуплікація ідеальна: REST-мікросервіси, агрегатори логів, ETL-пайплайни, де тисячі JSON-об’єктів містять повторювані назви полів, статуси та переліки.

4 Tricky Questions

1. Чи стане вираз s1 == s2 істинним (true) після того, як G1 GC успішно дедуплікує два однакові рядки?

Відповідь: Ні, вираз s1 == s2 залишиться false. String Deduplication працює на рівні внутрішніх масивів byte[] value. Збирач сміття робить спільним лише посилання на масив символів. Самі об’єкти String (їхні заголовки Mark Word, Klass Word, геш-коди та адреси в купі) залишаються ізольованими об’єктами. Порівняння за посиланням s1 == s2 завжди порівнює адреси самих екземплярів String, тому повертає false. Порівняння за значенням s1.equals(s2) повертає true.

2. Чи підтримується String Deduplication у ZGC в сучасних версіях Java (Java 18–21)?

Відповідь: Так, підтримується. У старих версіях Java (Java 8–17) дедуплікація рядків була жорстко вбудована виключно в кодову базу G1 GC (та Shenandoah). Проте починаючи з Java 18, в OpenJDK було реалізовано проєкт уніфікації дедуплікації рядків (JDK-8267186). Механізм було перенесено до загального компонента віртуальної машини, і в сучасних версіях Java (включно з Java 21 LTS) прапорець -XX:+UseStringDeduplication повноцінно працює не лише в G1, але також у ZGC, Parallel GC та Serial GC.

3. Навіщо потрібен параметр -XX:StringDeduplicationAgeThreshold=3 і чому не можна дедуплікувати рядки відразу під час їх створення в Eden?

Відповідь: Згідно зі слабкою гіпотезою про покоління (Weak Generational Hypothesis), понад 95% рядків у Java-застосунках є ультракороткоживучими: вони створюються для формування логу, парсингу JSON або виконання методу і помирають за кілька мілісекунд у першому ж Minor GC. Якби JVM намагалася дедуплікувати рядки прямо в Eden:

  • Процесор витрачав би до 30–40% усієї потужності на обчислення гешів і пошук у таблиці для об’єктів, які за частку секунди зникнуть.
  • Поріг у 3 цикли збирання (Age Threshold) гарантує, що дедуплікуються лише довгоживучі рядки (Tenured / Old Gen), які реально осядуть у пам’яті й окуплять витрати процесорного часу на їх оптимізацію.

4. Як перевірити ефективність роботи дедуплікації рядків у продакшені без зупинки сервісу?

Відповідь:

  1. Діагностична команда jcmd:
    jcmd <pid> GC.string_deduplication -dump
    

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

  2. Логування GC під час старту JVM: Увімкнення тегу логування: -Xlog:gc+stringdedup*=info:stdout:time,uptime,level,tags. У логах відображатиметься детальний звіт кожного запуску потоку дедуплікації: [GC concurrent-string-deduplication, 12.4ms, 150000 strings, 4.2MB (25.3%) saved].

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

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

«String Deduplication — це прозора оптимізація збирача сміття (прапорець -XX:+UseStringDeduplication), яка у фоновому режимі знаходить однакові рядки, що пережили кілька циклів GC, і перенаправляє їхнє поле value на один спільний масив byte[]. На відміну від intern(), дедуплікація не об’єднує самі об’єкти String (== залишається false), не вимагає зміни коду і не несе ризику OutOfMemoryError. У типових корпоративних застосунках економить 10–25% пам’яті купи при оверхеді по CPU у 2–4%. Починаючи з Java 18, працює не лише в G1, але й у ZGC, Parallel та Serial GC».

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

  1. Рівень оптимізації: Об’єднує масиви byte[] value, але зберігає різні екземпляри String (== дає false).
  2. Age Threshold: За замовчуванням 3 цикли GC; відсікає короткоживучі рядки з Eden для захисту CPU.
  3. CAS Update: Атомарна заміна вказівника на масив є безпечною для паралельно читаючих потоків.
  4. Уніфікація в Java 18+: Підтримується в G1, Shenandoah, ZGC, Parallel GC.
  5. CPU Overhead: Витрачає 2–4% CPU; не рекомендується для застосунків із переважно унікальними рядками (UUID, геші).
  6. Діагностика: jcmd <pid> GC.string_deduplication та -Xlog:gc+stringdedup*=debug.

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

  • ❌ Стверджувати: «Дедуплікація рядків робить оператор s1 == s2 істинним» (головна помилка: об’єкти залишаються різними).
  • ❌ Вважати дедуплікацію синонімом String.intern() (різні механізми, пам’ять та цілі).
  • ❌ Вважати, що дедуплікуються всі рядки поспіль відразу в Eden (без урахування Age Threshold).
  • ❌ Стверджувати, що String Deduplication працює «тільки і виключно в G1» (з Java 18 доступна і в ZGC/Parallel).

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