🧠 Розділ 3 · Питання #28

Чому не варто викликати System.gc()

Явний виклик System.gc() (або Runtime.getRuntime().gc()) у прикладному бізнес-коді вважається грубим антипатерном у розробці на Java.


🟢 Junior Level

Явний виклик System.gc() (або Runtime.getRuntime().gc()) у прикладному бізнес-коді вважається грубим антипатерном у розробці на Java.

Три головні причини, чому це шкідливо:

  1. Важка зупинка застосунку (Stop-The-World): За замовчуванням HotSpot JVM відповідає на цей виклик запуском повного циклу Full GC. Усі робочі потоки застосунку примусово заморожуються на час від кількох сотень мілісекунд до десятків секунд (залежно від розміру купи).
  2. Збирач сміття розумніший за розробника: Сучасні збирачі сміття (G1, ZGC, Shenandoah) безперервно аналізують темп створення об’єктів та навантаження, самостійно обираючи ідеальний момент для збирання без участі програміста.
  3. Марна витрата ресурсів CPU: Збирання сміття примусово сканує всю пам’ять, навіть якщо купа заповнена лише на 10%, витрачаючи процесорні такти вхолосту.
// ❌ АНТИПАТЕРН: «допомога» збирачу сміття
public void exportReport() {
    byte[] data = generateHugePdf();
    sendByEmail(data);
    data = null;
    System.gc(); // КАТАСТРОФА: заморожує весь сервер заради одного масиву!
}

🟡 Middle Level

1. Руйнування адаптивних евристик JVM

Сучасні збирачі сміття — це складні системи, що самонавчаються. Вони безперервно розраховують статистичні моделі:

  • Allocation Rate: швидкість виділення пам’яті новими об’єктами (МБ/с);
  • Promotion Rate: відсоток об’єктів, що виживають у Young Gen та переміщуються в Old Gen;
  • Time-to-Mark: середній час, необхідний фоновим потокам на розмітку живих об’єктів.

Спираючись на ці метрики та цільовий час паузи (-XX:MaxGCPauseMillis), G1 GC динамічно регулює розмір Eden і завчасно запускає фонову фазу розмітки.

[!WARNING] Ефект зовнішнього шоку: Виклик System.gc() здійснює миттєве скидання накопиченої статистики. Адаптивні моделі JVM «ламаються», збирач вважає, що поведінка застосунку стала непередбачуваною, і наступні автоматичні збирання відбуватимуться із завищеними затримками та неоптимальними розмірами поколінь.

2. Передчасне старіння об’єктів (Premature Tenuring)

Слабка гіпотеза про покоління (Weak Generational Hypothesis) стверджує: понад 95% об’єктів помирають майже одразу після створення. Молоде покоління (Young Gen) спеціально спроєктоване для дешевого знищення короткоживучих об’єктів в Eden.

Коли розробник викликає System.gc():

  1. Раптовий Full GC зачищає всю пам’ять;
  2. Об’єкти, розташовані в Eden та Survivor, які спокійно померли б через 100 мілісекунд під час наступного Minor GC, примусово визнаються «вцілілими» та достроково переміщуються в Tenured (Old Gen);
  3. У результаті Old Gen штучно фрагментується і переповнюється сміттям, яке тепер не можна прибрати швидким Minor GC.
Штатний режим:
[Eden: 99% сміття] ── Minor GC (5 мс) ──► 99% видалено, 1% пішов у Survivor

Виклик System.gc():
[Eden] ── Примусовий Full GC (2 сек) ──► Тимчасові об'єкти виштовхнуті в Old Gen!
                                         (Old Gen засмічений, зростає фрагментація)

🔴 Senior Level

1. Деоптимізація компілятора JIT та втрата Throughput

Механізм, про який рідко замислюються: вплив Full GC на JIT-компіляцію.

Під час виконання повного збирання сміття JVM здійснює вивантаження невикористовуваних класів (Class Unloading):

  1. Збирач сміття сканує метадані класів і видаляє мертві завантажувачі (ClassLoader);
  2. Вивантаження навіть одного динамічного класу може порушити спекулятивні оптимізації JIT-компілятора C2 (наприклад, припущення про мономорфність виклику інтерфейсу або девіртуалізацію методів);
  3. Скомпільований у високоефективний машинний код метод оголошується недійсним (Deoptimization / Uncommon Trap);
  4. Потоки застосунку змушені скинути виконання на рівень повільного байткод-інтерпретатора або першого рівня C1;
  5. Компіляторні потоки JIT різко навантажують процесор на 100%, намагаючись наново перекомпілювати гарячі методи.

У результаті після виклику System.gc() пропускна здатність сервісу просідає на кілька секунд навіть після того, як пауза збирання завершилася.

2. Провокація Concurrent Mode Failure у G1 GC

Якщо виклик System.gc() відбувається в момент, коли G1 GC вже виконує фонову конкурентну розмітку (Concurrent Marking):

  • Конкурентний цикл примусово переривається;
  • Відбувається аварійний перехід в однопотоковий або паралельний Full GC Stop-The-World (Concurrent Mode Failure);
  • Логи збирача сміття фіксують найтривалішу з можливих пауз у G1: [GC (Allocation Failure) -- [Pause Full (System.gc()) 1540M->420M(4096M), 4.1245620 secs].

3. Маскування реальних витоків пам’яті (GC Thrashing)

// ❌ Спроба «виправити» нестачу пам'яті
public void processBatch(List<Item> batch) {
    try {
        handle(batch);
    } catch (OutOfMemoryError e) {
        System.gc(); // «Почистимо пам'ять і спробуємо знову»
        handle(batch);
    }
}

Цей патерн занурює JVM у стан GC Thrashing (пробуксовування пам’яті):

  • Застосунок витрачає 95–98% усього процесорного часу на нескінченні спроби Full GC;
  • Сервіс фактично зависає, мережеві запити відпадають за таймаутами;
  • Процес утримується в зомбі-стані годинами, приховуючи факт реального витоку від систем алертингу.

4. Захист Production: правильна конфігурація прапорців

# ❌ ПОГАНО (небезпечно для DirectMemory у NIO):
-XX:+DisableExplicitGC

# ✅ РЕКОМЕНДУЄТЬСЯ ДЛЯ PRODUCTION:
-XX:+ExplicitGCInvokedConcurrent

Прапорець -XX:+ExplicitGCInvokedConcurrent трансформує примусовий синхронний Full GC у штатний неблокуючий фоновий цикл розмітки збирача G1/ZGC, нівелюючи негативні ефекти бібліотечних викликів System.gc().


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

30-секундна відповідь для співбесіди

«Викликати System.gc() не варто, оскільки за замовчуванням він запускає руйнівний синхронний Full GC Stop-the-World, що зупиняє всі потоки на секунди. Крім того, цей виклик скидає статистичні евристики адаптивних збирачів (G1, ZGC), передчасно виштовхує короткоживучі об’єкти з Young Gen у Old Gen, ініціює деоптимізацію JIT-компілятора через вивантаження класів і може спровокувати аварійний збій Concurrent Mode Failure. Єдиним виправданим місцем виклику System.gc() є бенчмарки (JMH) для ізоляції тестових прогонів».


Питання підвищеної складності з відповідями

  1. Як виклик System.gc() може призвести до деградації продуктивності JIT-компілятора?
    • Відповідь: Під час Full GC віртуальна машина виконує вивантаження невикористовуваних класів (Class Unloading). Якщо вивантажуються класи, для яких JIT-компілятор вибудував спекулятивні оптимізації (наприклад, інлайнінг методів за припущення про одного нащадка інтерфейсу — monomorphic dispatch), відбувається деоптимізація машинного коду. Виклики методів скидаються на рівень інтерпретатора, а фонові потоки компілятора C2 починають повторну компіляцію, що призводить до різкого стрибка навантаження на CPU і падіння throughput сервісу.
  2. Чому System.gc() викликає ефект Premature Tenuring (передчасного переміщення в Old Gen)?
    • Відповідь: У нормальному циклі роботи об’єкти алокуються в Eden і переважна більшість із них помирає до найближчого Minor GC. При виклику Full GC очищається вся купа відразу: ті об’єкти, які щойно були створені й мали померти в Eden природним шляхом через мілісекунди, збирач змушений зберегти і перемістити в Tenured (Old Gen). Це призводить до засмічення старшого покоління сміттям, зростання фрагментації пам’яті та якнайшвидшого настання реального вичерпання пам’яті.
  3. Що станеться, якщо System.gc() викличеться під час роботи фонової фази розмітки G1 GC?
    • Відповідь: Виклик перериває активний цикл фонової конкурентної розмітки (Concurrent Marking). G1 GC не може продовжувати конкурентну роботу при явному запиті на синхронне збирання і змушений перемкнутися в режим аварійного Stop-The-World Full GC з ущільненням пам’яті (Full Garbage Collection). Це анулює всі переваги низьколатентного збирача і заморожує сервіс на кілька секунд.
  4. Де виклик System.gc() є абсолютно виправданим і технічно коректним?
    • Відповідь: У мікробенчмарках (наприклад, Java Microbenchmark Harness — JMH) між ітераціями тестів для очищення сміття прогріву та приведення купи до суворо детермінованого базового стану перед початком вимірювань. Також виклик припустимий на тестових стендах при цілеспрямованому відтворенні сценаріїв збирання сміття або профілюванні витоків (Delta Analysis).

Типові помилки та Red Flags

  • 🚩 Вважати, що виклик System.gc() корисний перед початком важкої операції для «очищення місця». (Red Flag: нерозуміння роботи поколінь пам’яті; ручний виклик переносить живе сміття в Old Gen і морозить потоки).
  • 🚩 Намагатися лікувати витоки пам’яті регулярним викликом System.gc(). (Спроба сховати проблему замість її розслідування через Heap Dump).
  • 🚩 Використовувати System.gc() всередині блоків перехоплення помилок (catch). (Гарантоване введення сервісу в стан GC Thrashing).

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