Питання 4 · Розділ 3

Що таке Garbage Collection?

GC базується на двох спостереженнях:

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

🟢 Junior Level

Garbage Collection (GC) — автоматичне «прибирання сміття» в пам’яті Java.

«Сміття» — об’єкти, які більше не використовуються програмою (недосяжні з GC Roots). GC знаходить їх і звільняє пам’ять. Без GC розробнику довелося б вручну звільняти пам’ять (як у C/C++), що загрожує витоками та dangling pointer.

Проста аналогія: Уявіть, що ви працюєте за столом. З часом стіл захаращується папірцями. GC — це прибиральник, який приходить і викидає непотрібні папірці, звільняючи місце.

Навіщо потрібен:

  • У C/C++ програміст сам видаляє пам’ять → часто забуває → витоки
  • У Java GC сам знаходить і видаляє непотрібні об’єкти → немає витоків (майже)

Як GC розуміє, що об’єкт непотрібний:

  • Якщо на об’єкт немає посилань → він сміття
  • Якщо до об’єкта не можна дістатися з коду → він сміття

Приклад:

public void example() {
    String s1 = "hello";   // Об'єкт "hello" використовується
    s1 = "world";          // "hello" більше не потрібен → GC видалить!
}

Основні GC в Java:

  • G1 GC — за замовчуванням (Java 9+)
  • ZGC — мінімальні паузи (< 1 мс), але throughput на 5-10% нижчий, ніж у G1.
  • Parallel GC — для пакетної обробки

🟡 Middle Level

Weak Generational Hypothesis

Терміни:

  • STW (Stop-The-World) — повна зупинка всіх потоків додатку на час збору сміття.
  • SATB (Snapshot-At-The-Beginning) — алгоритм конкурентного маркування: запам’ятовує об’єкти, які були досяжні на момент початку GC, і відстежує тільки їх видалення з графу досяжності.
  • Card Table — бітова карта, де кожен біт показує, чи є посилання з Old Gen в Young Gen. Дозволяє GC не сканувати весь Old Gen.

GC базується на двох спостереженнях:

  1. Для більшості додатків вірно: більшість об’єктів умирають молодими — тимчасові змінні, буфери. Але для workload-ів з великими довгоживучими кешами це спостереження не працює.
  2. Об’єкти, що пережили кілька зборок, живуть довго — синглтони, кеші

Наслідок: Розділити Heap на покоління:

  • Young Generation — часті, швидкі збірки
  • Old Generation — рідкі, повільні збірки

Основні алгоритми

Алгоритм Як працює Де застосовується
Copying Копіює живі в нову область, стару очищує Young Gen
Mark-Sweep Позначає живих, видаляє мертвих Old Gen
Mark-Compact Позначає + зміщує живих (прибирає фрагментацію) Full GC

Фази GC

1. Mark (Розмітка)
   → Обхід графу об'єктів від GC Roots
   → Позначаються всі досяжні об'єкти

2. Sweep (Очищення)
   → Видалення непозначених об'єктів

3. Compact (Ущільнення) — не завжди
   → Зміщення живих об'єктів для усунення фрагментації

Stop-The-World (STW)

Під час GC додаток зупиняється!
  → Усі потоки чекають
  → Додаток "завмирає"
  → Тривалість: від 1 мс (ZGC) до кількох секунд (Full GC)

Типи GC

G1 GC (за замовчуванням):

  • Ділить Heap на регіони
  • Цільова пауза: -XX:MaxGCPauseMillis=200
  • Хороший баланс latency/throughput

ZGC (Java 21+):

  • Паузи < 1 мс (не залежать від розміру Heap)
  • До 16 ТБ пам’яті
  • -5-10% throughput vs G1

Parallel GC:

  • Максимальний throughput
  • Довгі паузи (500 мс+)
  • Для Big Data, batch processing

Типові помилки

  1. System.gc() в коді
    // ❌ Виклик GC вручну — антипатерн
    System.gc();  // Зупиняє весь додаток!
    
  2. Занадто маленький Heap
    -Xmx256m для Spring Boot → постійний GC
    → Додаток гальмує
    → Рішення: -Xmx1g мінімум
    
  3. Витоки пам’яті
    // ❌ Статична колекція росте нескінченно
    static List<Data> cache = new ArrayList<>();
    cache.add(data);  // GC не може видалити!
    

Коли НЕ використовувати кожен GC

  • Serial GC — НЕ для серверів (один потік, довгі паузи)
  • Parallel GC — НЕ коли важливі паузи (SLA < 100 мс)
  • G1 GC — НЕ для SLA < 10 мс (беріть ZGC), НЕ для batch без latency-вимог (беріть Parallel)
  • ZGC — НЕ на Java < 15 (немає стабільної версії), НЕ коли важливий максимальний throughput

🔴 Senior Level

GC Trade-offs (Трикутник GC)

         Latency
          / \
         /   \
  Throughput ── Footprint

Не можна оптимізувати всі три одночасно!

G1: баланс latency + throughput
ZGC: мінімальна latency (ціною throughput: ZGC жертвує 5-15% throughput заради пауз < 1 мс)
Parallel: максимальний throughput (ціною latency)

Safepoints та STW механіка

Потоки не можуть зупинитися миттєво!
→ Повинні дійти до Safepoint (точка зупинки)

Де розставлені Safepoints:
  - Перед виходом з методу
  - В кінцях циклів
  - Перед викликами методів

Механізм: Safepoint Poll
  → JVM позначає сторінку як недоступну
  → Потік читає → SIGSEGV → переходить в очікування

TTSP (Time To Safepoint):
  → Час від команди "стоп" до останнього потоку
  → Counted Loops можуть затримати на секунди!
  → -XX:+UseCountedLoopSafepoints

Write Barriers та Card Tables

Проблема: Minor GC не хоче сканувати весь Old Gen

Рішення: Card Table
  → Old Gen розбитий на картки по 512 байт
  → При запису oldObj.field = youngObj
    → Write Barrier позначає картку як Dirty
  → Minor GC сканує тільки Dirty картки

Write Barrier (вставляється JIT):
  card_table[address >> 9] = DIRTY
  → ~1-2 ns overhead на кожен запис посилання

Сучасні GC (Java 17-21+)

G1 GC:

  • Регіони 1-32 МБ (динамічні)
  • Remembered Sets (RSet) для міжрегіональних посилань
  • SATB (Snapshot-At-The-Beginning) для конкурентного маркування
  • Mixed GC: Young + найбільш “брудні” регіони Old

ZGC:

  • Colored Pointers (метадані у вказівниках)
  • Load Barriers (перевірка при читанні)
  • Multi-mapping віртуальної пам’яті
  • Generational ZGC (Java 21+) — поділ на покоління

Shenandoah:

  • Brooks Pointers → Load Reference Barriers
  • Конкурентне ущільнення (Evacuation)
  • Альтернатива ZGC для OpenJDK

Epsilon GC:

  • “No-op GC” — тільки алокація
  • Для тестів продуктивності
  • Для мікросервісів з коротким життєвим циклом

Allocation Stall

Найнебезпечніший сценарій для конкурентних GC:

Додаток створює сміття швидше, ніж GC збирає
→ Пам'ять закінчується
→ Потік додатку ЗАМИРАЄ і чекає GC
→ Пауза: сотні мілісекунд або секунди

Рішення:
  → Збільшити Heap
  → Збільшити ConcGCThreads
  → Зменшити Allocation Rate

Production Experience

Реальний сценарій #1: Full GC кожну хвилину

  • Spring Boot, -Xmx512m → занадто мало
  • Full GC кожні 60 секунд, пауза 2 секунди
  • SLA порушено
  • Рішення: -Xmx2g → Minor GC кожні 5 хвилин, пауза 50 мс

Реальний сценарій #2: ZGC врятував HFT систему

  • High-frequency trading: SLA < 10 мс
  • G1 GC: паузи 50-200 мс → пропуски угод
  • ZGC: паузи < 1 мс → SLA виконано
  • Ціна: -8% throughput (прийнято)

Best Practices

  1. Не викликайте System.gc() вручну
  2. G1 GC — хороший вибір за замовчуванням
  3. ZGC (Java 21+) — якщо SLA < 10 мс
  4. Parallel GC — для batch processing
  5. Моніторте GC логи через -Xlog:gc*
  6. Safepoint логи для діагностики “зависань”
  7. Уникайте Allocation Stall → достатній Heap
  8. Найкраще сміття — те, що не створене → оптимізуйте алокації

Резюме для Senior

  • GC = система управління ресурсами, не просто “очищення”
  • Трикутник: Latency ↔ Throughput ↔ Footprint
  • Safepoints = механізм STW, TTSP може бути проблемою
  • Write Barriers = Card Tables для оптимізації Minor GC
  • ZGC = Colored Pointers + Load Barriers → < 1 мс паузи
  • Allocation Stall = катастрофа для latency
  • Вибір GC = архітектурне рішення на основі SLA
  • Найкраще сміття = не створене → оптимізуйте код до налаштування GC

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

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

  • GC автоматично видаляє недосяжні з GC Roots об’єкти — розробнику не потрібно управляти пам’яттю вручну
  • Weak Generational Hypothesis: більшість об’єктів умирають молодими → Heap ділиться на Young/Old Generation
  • Основні алгоритми: Copying (Young Gen), Mark-Sweep/Mark-Compact (Old Gen)
  • G1 GC — за замовчуванням з Java 9, баланс latency/throughput; ZGC — паузи < 1 мс, але throughput на 5-15% нижчий
  • Stop-The-World: усі потоки зупиняються на час GC; ZGC < 1 мс, G1 20-200 мс, Full GC — секунди
  • Safepoints — точки в коді, де потоки можуть зупинитися; TTSP може бути довшим за сам GC
  • Card Table + Write Barriers оптимізують Minor GC, не скануючи весь Old Gen
  • Найкраще сміття — те, що не створене; оптимізуйте алокації до налаштування GC

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

  • Чому не можна оптимізувати latency, throughput і footprint одночасно? — Трикутник GC: покращення одного погіршує інше (наприклад, ZGC жертвує throughput заради пауз < 1 мс)
  • Що таке Allocation Stall? — Додаток створює сміття швидше, ніж concurrent GC встигає → потік завмирає і чекає
  • Чому ZGC повільніший за G1 по throughput? — Load Barriers при кожному читанні посилання додають 5-15% CPU overhead
  • Коли викликати System.gc()? — Ніколи у production. Тільки в тестах/бенчмарках.

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

  • «Я викликаю System.gc() після великих операцій для оптимізації» — це антипатерн, GC розумніший
  • «GC використовує Reference Counting» — Java використовує Reachability Analysis, а не підрахунок посилань
  • «ZGC завжди краще G1» — ZGC жертвує throughput; для batch processing G1/Parallel краще
  • «GC можна повністю вимкнути» — Epsilon GC не збирає сміття взагалі, але це не production-рішення для довготривалих додатків

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

  • [[5. Коли об’єкт стає кандидатом на видалення GC]]
  • [[8. Що таке покоління в GC (young, old, metaspace)]]
  • [[12. Які алгоритми GC існують]]
  • [[13. Що таке G1 GC]]
  • [[16. Що таке stop-the-world]]