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

Что такое String deduplication в 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: benchmark с и без дедупликации; иногда 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. Что такое compact strings в Java 9+]]
  • [[20. Как узнать, сколько памяти занимает String]]