🔤 Раздел 12 · Вопрос #22

Что такое String deduplication в 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. Теперь флаг -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).
  • ❌ Утверждать в 2024 году, что String Deduplication работает «только и исключительно в G1» (с Java 18 доступна и в ZGC/Parallel).

Связанные вопросы