Оптимізація JVM під час роботи з памʼяттю
Раніше я вже писав про те, як влаштована робота з памʼяттю в Java, а також ділився оглядом на збирачі сміття. В сьогодні прийшов час приділити трохи уваги саме оптимізації роботи JVM та збирачів сміття.
Якщо ви досі не ознайомлені з попередніми статтями, дуже рекомендую їх переглянути, там маса корисної інформації, яка дозволить краще осягнути сьогоднішню тему. Навіть якщо ви не збираєтеся тюнити збирачі сміття, проглянути цю статтю може бути дуже корисно.
Загальні принципи тюнінгу GC
Баланс цілей: тюнінг збирача сміття завжди вимагає компромісу між пропускною здатністю, затримкою та об’ємом пам’яті. Неможливо максимізувати всі три показники одночасно — доводиться обирати пріоритети.
Збільшення доступної пам’яті (heap) зазвичай підвищує пропускну здатність і зменшує частоту збірок, але окремі паузи стають довшими (гірша затримка). Навпаки менший heap зменшує тривалість пауз, проте змушує GC запускатися частіше, що може знизити пропускну здатність додатку. Тому перед початком тюнінгу визначте, що для вашої системи головне — мінімальні паузи, максимальний throughput чи економія пам’яті.
Розмір heap та початкові налаштування. Правильне встановлення розміру купи пам’яті (-Xms — початковий, -Xmx — максимальний) — перший крок оптимізації. Heap повинен вміщувати всі «живі» дані програми плюс запас для роботи GC. Якщо heap надто малий, GC спрацьовуватиме дуже часто; надто великий — займатиме зайву пам’ять.
Часто варто встановити -Xms рівним -Xmx, щоб уникнути динамічного розширення купи та затрат на додаткове виділення пам’яті під час роботи. Також корисно увімкнути попереднє резервування сторінок пам’яті за допомогою опції -XX:+AlwaysPreTouch, щоб під час запуску JVM проініціалізувати весь heap і знизити затримки на сторінкові помилки у майбутньому.
Аналіз пауз vs throughput. Існує пряма торгівля між затримкою і пропускною здатністю. Наприклад, в G1 GC мета пауз за замовчуванням становить ~200 мс. Якщо задати нижчу ціль пауз (наприклад, 100 мс чи менше), збирач буде працювати більш агресивно — робити менші порції роботи, але частіше. Це знизить тривалість окремих пауз (краща latency), але збільшить сумарний відсоток часу на GC і може зменшити throughput.
Натомість вищий ліміт пауз (наприклад
Використання інструментів. Перед ручним тюнінгом зберіть дані про роботу GC. Увімкніть логування GC (-Xlog:gc*) та проаналізуйте тривалість пауз, частоту збірок, обсяг живих даних тощо. Моніторинг (JDK Flight Recorder, Mission Control, jstat тощо) допоможе зрозуміти, де вузькі місця — можливо, варто збільшити heap, або навпаки, видно, що більшу частину часу GC простоює.
Пам’ятайте: тюнінг GC — це ітеративний процес. Спершу мінімально скоригуйте ключові параметри (heap, ціль пауз), протестуйте під навантаженням, проаналізуйте логи, і лише за необхідності переходьте до тоньших налаштувань.
Обмеження вручну vs адаптивність. Сучасні збирачі мають вбудовані ергономічні алгоритми налаштування. Наприклад, G1 сам підбирає розмір поколінь для досягнення цільової паузи, а Parallel GC — для утримання певного співвідношення часу GC до часу програми (параметр -XX:GCTimeRatio).
Загальна рекомендація — стартувати з дефолтних налаштувань збирача, змінюючи лише найнеобхідніше (heap та, можливо, ціль пауз). Багато «тонких» прапорців можуть взагалі не давати ефекту або погіршувати ситуацію на сучасних GC. Тому почніть з мінімуму і дозвольте JVM самим адаптуватися; ручний тюнінг застосовуйте лише коли автоматичні налаштування не задовольняють вимоги (див. розділ 5).
Налаштування конкретних збирачів (G1, ZGC, Shenandoah, Parallel, Serial)
G1 GC (Garbage-First)
Суть: G1 — сучасний паралельний та частково-конкурентний збирач, за замовчуванням використовується в Java
Дефолтні цілі G1 — пауза до 200 мс і ~90% часу на роботу застосунка (≤10% на GC). G1 добре масштабується на великих купах (гігабайти пам’яті) і багатоядерних машинах, і зазвичай не вимагає багато ручного тюнінгу.
Мінімізація пауз: основний важіль — параметр -XX:MaxGCPauseMillis. Зменшіть його, якщо потрібні коротші паузи (наприклад, 100 мс замість дефолтних 200 мс) — G1 буде обмежувати обсяг роботи в кожному циклі збірки. Втім, занадто агресивне значення (скажімо, <50 мс) може призвести до надто частих мікропауз і «крадіжки» CPU без значної віддачі. Тонше керування паузами дають налаштування молодого покоління: зменшивши мінімальний розмір young-генерації (-XX:G1NewSizePercent, за замовчуванням 5%) і особливо її максимальний розмір (-XX:G1MaxNewSizePercent, дефолт ~60%), можна обмежити кількість об’єктів, що збираються за одну молодіжну GC-паузу.
Це зменшує тривалість молодих пауз, хоча й збільшує їхню частоту. Ще одна опція — раніше починати concurrent-маркування старого покоління: параметр -XX:InitiatingHeapOccupancyPercent визначає, при якому заповненні heap (відсоток) G1 починає фоновий збір старих регіонів (дефолт 45%). Якщо спостерігаються різкі стрибки пауз через те, що G1 запізно почав збір старого, знизьте цей поріг (наприклад, до
Зменшення використання пам’яті: G1 має певний наклад на пам’ять (наприклад, збереження RSet — remembered set для відстеження посилань між регіонами). Для зниження цього оверхеду можна збільшити розмір регіону heap (-XX:G1HeapRegionSize). Великі регіони (можна до 32 МБ, а в Java 21 — до 512 МБ) означають менше міжрегіонних посилань та менші таблиці RSet, що зменшує пам’ятковий слід GC.
Але пам’ятайте: надто великі регіони означають, що в кожному регіоні більше живих об’єктів, які доведеться копіювати під час збірки, це може збільшити тривалість пауз. Знайдіть баланс, враховуючи розмір ваших об’єктів (якщо додаток оперує дуже великими об’єктами, доцільно збільшити регіони, аби менше об’єктів ставали величезними — тобто такими, що займають більше однієї зони).
Також G1 автоматично повертає невикористану пам’ять ОС (починаючи з JDK 12) під час простою, тож окремо налаштовувати це не потрібно. У разі, якщо heap значно зменшився і частина пам’яті не використовується, G1 після фонового циклу звільнить її ОС (це працює автоматично, хоча менш прогнозовано, ніж у Shenandoah/ZGC).
Підвищення throughput: G1 за замовчуванням балансує між latency і throughput, але є параметри для зсуву цього балансу в бік продуктивності. По-перше, дозвольте довші паузи: якщо затримки не критичні, встановіть -XX:MaxGCPauseMillis більшим
По-друге, переконайтеся, що розмір молодого покоління не обмежує пропускну здатність. G1 автоматично підлаштовує Eden, але є верхня межа G1MaxNewSizePercent (60% heap за замовчуванням). Якщо логи показують, що Eden досягає ~60% heap і не росте (а GC все одно часті), варто збільшити -XX:G1MaxNewSizePercent (наприклад до 70—80%).
Крім того, можна зменшити фонове навантаження: G1 виконує частину роботи конкурентно (оновлення RSet, т.зв. refinement). Щоб більше ресурсів залишити додатку, можна перенести цю роботу у паузи, підвищивши -XX:G1RSetUpdatingPauseTimePercent (за замовчуванням 10%) — тобто дозволити витрачати більшу частку паузи на оновлення remembered set. У крайньому разі можливо майже повністю вимкнути конкурентне оновлення RSet (прапорець -XX:-G1UseAdaptiveConcRefinement та встановлення відповідних зон і потоків у 0), щоб максимум GC роботи робилося паралельно у stop-the-world.
Це збільшить тривалість пауз, але може підвищити загальний throughput, якщо додаток здатний пережити такі паузи. І, нарешті, для продуктивності на великих heap варто задіяти великі сторінки (-XX:+UseLargePages), що зменшить наклад на пам’ять і TLB при доступі до купи (потрібна підтримка ОС).
Приклад: за замовчуванням G1 намагається витрачати не більше ~10% часу на збірки. Якщо ваша програма може дозволити, скажімо, до 20% часу на GC заради меншого споживання пам’яті, можна зменшити параметр -XX:GCTimeRatio (формула: час GC = 1/(1+GCTimeRatio) від усього часу). Наприклад, значення 9 (замість 12) дозволить ~10% часу на GC (замість 8%). Для throughput-орієнтованих систем, навпаки, збільшення GCTimeRatio (скажімо, до 19, що дає ~5% часу на GC) змусить JVM розширяти heap і рідше запускати збирач (якщо є доступна пам’ять), тим самим підвищуючи пропускну здатність за рахунок більшого споживання пам’яті.
Ключові параметри G1: MaxGCPauseMillis (ціль пауз), G1NewSizePercent / G1MaxNewSizePercent (мін./макс. розмір молодого покоління), InitiatingHeapOccupancyPercent (поріг початку concurrent-циклу), G1MixedGCCountTarget (скільки
Ці опції можна підлаштовувати за потреби: наприклад, збільшення G1MixedGCCountTarget розподілить прибирання старого покоління на більше кількість невеликих пауз, а підвищення G1HeapWastePercent дозволить G1 раніше завершити цикл, залишивши трохи більше невичищеного сміття до наступного циклу.
Обидва прийоми можуть скоротити тривалість кожної паузи, але ризикують накопичити більше сміття у heap до наступних ітерацій. Практика показує, що значення за замовчуванням G1 збалансовані, і змінювати ці низькорівневі прапорці варто лише за наявності специфічних проблем, видимих у логах GC (наприклад, якщо старі регіони дуже фрагментовані чи pauses Mixed занадто довгі).
ZGC (Z Garbage Collector)
Суть: ZGC — це масштабований збирач сміття з низькою затримкою та повністю конкурентними фазами. Він зупиняє виконання потоків Java не більше, ніж на кілька мілісекунд (як правило
Починаючи з JDK 21, доступна генераційна версія ZGC (Generational ZGC), яка вводить розподіл на молоді/старі покоління для підвищення ефективності. Основна перевага ZGC — мінімальні паузи без детального ручного налаштування: розробники зробили його максимально самоналаштовним.
Мінімізація пауз: У ZGC майже немає налаштувань, що впливають на паузи напряму, оскільки всі дорогі операції (маркування, переміщення об’єктів) виконуються у фонових потоках. Найкраще, що ви можете зробити — забезпечити достатній «запас» пам’яті і CPU, аби ZGC встигав прибирати сміття без зупинок. Тобто розмір heap має бути достатнім, щоб, поки GC працює у фоні, додаток міг продовжувати алокації.
Якщо heap майже повністю зайнятий живими даними і новим об’єктам нема місця, навіть ZGC змушений буде призупинити потоки («allocation stall»). Тому для дуже чутливих до пауз систем варто виділити трохи більше пам’яті, ніж мінімально потрібно, щоб ZGC прибирав сміття з випередженням. У JDK 21 генераційний ZGC значно зменшив ризик таких зупинок, оперативніше очищуючи короткоживучі об’єкти.
За тестами, Generational ZGC майже усуває allocation stalls навіть під екстремальним навантаженням, де раніше однопоколіннєвий ZGC починав не встигати. Якщо latency — абсолютний пріоритет, також рекомендується уникати повернення пам’яті в ОС під час роботи, оскільки це може внести мікрозатримки.
За замовчуванням ZGC повертає невикористану пам’ять (uncommit) у фоні, але можна вимкнути це через -XX:-ZUncommit або просто встановити -Xms = -Xmx. Це гарантує, що ZGC не витрачатиме час на віддачу сторінок ОС і повторне їх виділення (що іноді може призвести до коротких стоп-пауз). Якщо ж повне вимкнення uncommit небажане, можна принаймні налаштувати затримку через -XX:ZUncommitDelay, яка за замовчуванням ~300 с (5 хв) — тобто ZGC чекатиме 5 хв простою перед поверненням пам’яті. Для більш передбачуваної роботи в реальному часі цю затримку можна збільшити (або вимкнути повернення зовсім).
Зменшення використання пам’яті: хоча ZGC спроєктований як досить «ненажерливий» до пам’яті (він тримає кольорові мітки на кожному слові пам’яті, ~5—15% оверхед), у Java 17+ з’явились засоби його обмеження. Ключовий параметр — -XX:SoftMaxHeapSize. Це «м’яка» межа розміру купи: ZGC постарається утримувати heap близько цього значення, хоча і може тимчасово його перевищити до -Xmx при необхідності.
Наприклад, -Xmx5g -XX:SoftMaxHeapSize=4g попросить ZGC працювати в межах ~4 ГБ, але якщо сміття не встигає прибиратися, він зможе розширитися до 5 ГБ, щоб уникнути зупинки застосунку. Це дуже зручно в контейнерних середовищах: SoftMaxHeapSize дозволяє тримати реальне споживання пам’яті нижче ліміту, щоб, наприклад, Kubernetes не перезавантажував под за OOM, і при цьому не жертвувати надійністю (ZGC все одно використає весь Xmx у разі потреби).
Інший аспект — повернення пам’яті ОС, яке в ZGC увімкнене за замовчуванням. Якщо ваш пріоритет — мінімальний footprint, залиште -XX:+ZUncommit увімкненим: ZGC буде поступово віддавати невикористані області купи назад ОС, зменшуючи RSS процесу. Це особливо корисно для застосунків з пульсуючим навантаженням.
Наприклад, після пікового навантаження ZGC через деякий час (після ZUncommitDelay) звільнить пам’ять, і процес займатиме менше RAM. У генераційному режимі ZGC також суттєво скоротив середній розмір heap, необхідний для роботи. Завдяки збору молодих об’єктів багато пам’яті звільняється раніше, і не потрібно роздувати heap «про запас». За даними тесту на Cassandra, Generational ZGC зменшив необхідний об’єм heap приблизно на 75% порівняно з попередньою версією (див. діаграму нижче). Це означає менший footprint і менше тиску на пам’ять системи.
Порівняння пропускної здатності Cassandra (128 ГБ heap) на однопоколіннєвому ZGC vs Generational ZGC. Генераційний режим дає ~4× більший throughput:
Порівняння споживання пам’яті (ефективний розмір heap) для ZGC. Generational ZGC потребує значно менше пам’яті (коротші стовпчики — краще).
Підвищення throughput: за замовчуванням ZGC масштабується на всі доступні CPU-ядра — кількість фонових потоків GC (ConcGCThreads) він вибирає пропорційно до кількості ядер і розміру heap. У більшості випадків втручатися не треба. Якщо ж ви помітили, що GC навантажує CPU більше, ніж хотілося (наприклад, у системі з 4 ядрами ZGC може запускати 4 конкурентні потоки і конкурувати з додатком), можна обмежити -XX:ConcGCThreads до меншого значення.
Це, втім, ризиковано: надто мало GC-потоків == ZGC може не встигнути колектити сміття, і latency погіршиться. У JDK 21+ Generational ZGC покращив throughput ~на 10% порівняно з попередником, адже тепер більшість короткоживучих об’єктів збираються ще швидше і не навантажують загальне маркування.
Загалом ZGC прагне бути оптимальним без ручного втручання — розробники зазначають, що єдиний параметр, який зазвичай варто задати, — це розмір heap (-Xmx). Решту (розмір поколінь, тенюринг, кількість потоків) ZGC налаштовує сам динамічно. Тож для throughput-критичних задач на ZGC насамперед переконайтеся, що додатку вистачає пам’яті і GC не змушений працювати на межі можливостей.
Якщо throughput все ж недостатній, можливо, ваш кейс більше підходить для G1 або Parallel GC (ZGC завжди трохи «платить» за фонову роботу). За вимірами навіть генераційний ZGC все ще може програвати ~2% throughput порівняно з G1 у аналогічних умовах, хоча виграш у latency компенсує це для більшості застосунків.
Shenandoah GC
Суть: Shenandoah — ще один конкурентний збирач з низькою затримкою, розроблений Red Hat. Його ціль — паузи <10 мс (в актуальних версіях досягнуті паузи <1 мс) незалежно від розміру heap. Подібно до ZGC, Shenandoah виконує майже всю роботу паралельно з додатком і зупиняє застосунок тільки на дуже короткі синхронізації.
Ключова відмінність: Shenandoah теж ділить heap на регіони, але не використовує покоління (він non-generational). Замість поколінь Shenandoah динамічно визначає, які регіони прибирати, на основі зайнятості та рівня сміття. Він також виконує конкурентну компактизацію — переміщує об’єкти у фоні, використовуючи додатковий «forwarding pointer» на кожному об’єкті для відстеження нового місця. Це усуває проблему фрагментації пам’яті і уникнення full GC, подібно до алгоритму CMS, але без його довгих пауз.
Мінімізація пауз: У Shenandoah паузи і так малі (в основному на початок і кінець циклу маркування). Щоб гарантувати стабільність, важливо, щоб GC встигав за алокаціями додатку. Shenandoah має вбудований механізм пейсинга (пригальмовування алокацій): якщо додаток надто швидко виділяє пам’ять і є ризик не встигнути зібрати сміття, Shenandoah може трохи пригальмувати потоки алокації, додаючи затримку кілька мілісекунд. Це локальна затримка для окремих потоків, яка не рахується як «stop-the-world», але забезпечує GC фору для завершення роботи. У нормі, якщо GC справляється, пейсинг не спрацьовує.
Ви можете налаштувати агресивність пейсинга через -XX:ShenandoahPacingMaxDelay (макс. затримка на алокацію, мс) та пов’язані параметри, але зазвичай дефолти оптимальні. Якщо ваша мета — мінімізувати навіть мікрозатримки, слід або тримати запас вільної пам’яті (щоб пейсинг не спрацьовував), або, навпаки, допустити трохи більші паузи замість пейсинга (втім Shenandoah не дає прямого аналога MaxPauseMillis, він завжди прагне мін. пауз).
Важливо: Shenandoah має кілька аварійних режимів на випадок, якщо фонова збірка не встигає. По-перше, це degenerative GC — коли heap майже заповнений, Shenandoah переходить у стоп-the-world режим і завершує поточний цикл «в лоб», але паралельно на всіх потоках. Така пауза може бути тривалішою (<100 мс або більше, залежно від обсягу роботи), але все одно значно коротша за повну зупинку роботи на великому heap при традиційному збирачі.
По-друге, якщо навіть деградаційна збірка не звільнить достатньо пам’яті (вкрай рідкий випадок), є фінальна опція — Full GC (що небажано). Для мінімальних пауз ви захочете уникати Degenerated GC — цього досягають тим же шляхом: треба слідкувати, щоб heap не переповнювався.
Можете виділити трохи більше пам’яті або знизити поріг запуску фонової збірки. Shenandoah за замовчуванням використовує адаптивну евристику, яка запускає черговий цикл колекції «трохи заздалегідь», на основі тривалості попереднього циклу і швидкості заповнення heap. Цю поведінку можна побачити/змінити через параметри ShenandoahInitFreeThreshold, ShenandoahMinFreeThreshold тощо, але зазвичай це не потрібно — GC сам підлаштовується, щоб завершувати колекцію до того, як скінчиться вільна пам’ять.
Зменшення використання пам’яті: Shenandoah, як і ZGC, додає метадані на кожен об’єкт (forwarding pointer), тому має схожий оверхед ~10—15%. Проте він дуже агресивно повертає пам’ять системі. Одразу після того, як регіони стають повністю вільні, Shenandoah може їх uncommit (деалокує віртуальну пам’ять). За замовчуванням він робить це з затримкою 5 хвилин після закінчення останньої збірки, щоб не метушити пам’ять надто часто.
Цей інтервал контролюється опцією -XX:ShenandoahUncommitDelay (у мілісекундах, дефолт 300 000 мс). Якщо для вас критичний мінімальний RAM-футпринт, можна зменшити цю затримку — тоді GC швидше віддасть невикористану пам’ять ОС. Наприклад, встановивши -XX:ShenandoahUncommitDelay=10000 (10 с), ви отримаєте майже миттєвий «скид» пам’яті після хвилі навантаження. Але надто мале значення може збільшити CPU-навантаження на запити пам’яті.
Якщо ж, навпаки, у вас достатньо RAM і важлива стабільність, можете збільшити цей delay або навіть встановити -XX:ShenandoahUncommitDelay=0 (що, здається, вимикає повернення пам’яті), щоб heap залишався зарезервованим і програма не витрачала час на повторні системні виклики виділення/звільнення пам’яті.
Інший параметр — -XX:ShenandoahGarbageThreshold — визначає, скільки % сміття має бути в регіоні, щоб його включили до чергового циклу евакуації. За замовчуванням ~20%. Якщо зменшити цей поріг, Shenandoah прибиратиме навіть слабо заповнені сміттям регіони, що може зменшити загальний зайнятий обсяг пам’яті (ціною додаткової роботи). Однак це рідко потрібно: Shenandoah і так прагне звільнити максимум пам’яті, просто робить це поступово, щоб не затримувати додаток.
Підвищення throughput: конкурентні збирачі (Shenandoah, ZGC) традиційно жертвують трохи throughput заради мінімальної latency, адже частина CPU йде на фонову роботу. Якщо ваша мета — максимум продуктивності і ви можете дозволити невеликі паузи, Shenandoah, можливо, не найкращий вибір (Parallel або G1 дадуть вищий throughput).
Проте, припустимо, вам потрібен компроміс: ви хочете використовувати Shenandoah, але мінімізувати його вплив на програму. Є кілька варіантів: зменшити кількість потоків GC — параметр -XX:ConcGCThreads (за замовчуванням вибирається ~від кількості ядер). Менше фонових потоків — менше конкуренції за CPU, але й повільніше прибирання (див. застереження про Degenerated GC).
Можна вибрати й іншу евристику: наприклад, режим Static (-XX:ShenandoahGCHeuristics=static), де збирач просто запускається при заповненні певного відсотка heap (параметр ShenandoahMinFreeThreshold). Static-режим іноді дає трохи кращий throughput, бо не запускає збір надто часто, але і паузи можуть збільшитися при навантаженні. Є також режим Compact (колишній «Continous»), коли GC фактично працює постійно, але з меншим числом потоків, намагаючись мінімізувати стоп-паузи до нуля.
Цей режим може бути корисним, якщо ви хочете дуже плавного фону з мінімальним впливом на програму, але зазвичай дефолтна Adaptive евристика добре збалансована. Якщо throughput все ж страждає, можна розглянути перехід на G1 для даного сервісу — у ряді випадків G1 дає трохи кращу продуктивність на завданнях з високим навантаженням, тоді як Shenandoah виграє в затримках. Загалом Shenandoah орієнтований на latency-sensitive кейси; для нього краще мати запас CPU, щоб фонові потоки не відбирали останній ресурс у додатку.
Parallel GC (Parallel Scavenge)
Суть: Parallel GC — класичний генераційний збирач з паралельними потоками. Його ще називають «throughput collector», адже основна мета — максимальна пропускна здатність при прийнятних паузах. Parallel GC працює за схемою «стоп-всі-потоки», але використовує всі доступні CPU-ядра для прискорення збору і компактування пам’яті.
Молоде покоління збирається алгоритмом Parallel Scavenge, старе — Parallel Mark-Sweep-Compact. У сучасній JVM паралельне компактування увімкнено за замовчуванням, коли обрано Parallel GC. Цей збирач до Java 9 був типовим вибором для серверних додатків на великих heap, нині ж його потрібно вмикати опцією -XX:+UseParallelGC (або якщо явно вимкнути G1).
Мінімізація пауз: Оскільки Parallel GC виконує всю роботу «в паузі», тривалість пауз залежить в основному від розміру heap і швидкості CPU. Щоб зменшити паузи, зменшуйте обсяг роботи на паузу. Основний спосіб — обмежити розмір молодого покоління, адже збірки молоді (Minor GC) відбуваються частіше. JVM з Parallel GC за замовчуванням увімкнена адаптивна політика розмірів поколінь (-XX:+UseAdaptiveSizePolicy), яка намагається досягти цілей пауз та співвідношення часу. Ви можете задати бажану максимальну паузу через ту ж опцію -XX:MaxGCPauseMillis — на відміну від G1, Parallel GC інтерпретує її як орієнтир при налаштуванні розміру Eden/Survivor.
Якщо, наприклад, ви хочете паузи не довше 200 мс, Parallel GC буде тримати молоде покоління відносно невеликим, щоб вписатися в цей бюджет. Але майте на увазі: надто низька ціль може призвести до дуже частих збірок (молоде покоління швидко заповнюватиметься) і в підсумку викличе більше повних збірок старого покоління. Тому цільові паузи
Якщо ж мета — уникнути довгих пауз у старому поколінні, варто забезпечити, щоб молоді збірки відбувалися достатньо часто і більшість сміття прибиралася ще в молодому поколінні. Можна підняти -XX:MaxTenuringThreshold (макс. «вік» об’єкта в Minor GC перед переходом в old, дефолт 15) — це дозволить довше тримати об’єкти у Survivor-областях, даючи їм шанс стати сміттям до потрапляння в старий heap.
Також можна збільшити розмір Survivor-областей (-XX:SurvivorRatio або -XX:TargetSurvivorRatio), щоб зменшити ранні промоції. Все це зменшить навантаження на Full GC (старе покоління), що особливо важливо, бо Full GC при Parallel — теж stop-the-world, хоч і паралельний. Нарешті, уникайте розростання heap до упору: якщо старе покоління заповнене під зав’язку, при Full GC доведеться перебрати весь heap. Краще залишати трохи запасу або збільшити heap, щоб Full GC траплялися рідко.
Зменшення використання пам’яті: Parallel GC, на відміну від Concurrent збирачів, зазвичай не повертає пам’ять ОС автоматично у періоди простою (до JDK 16 він цього майже не робив). Але у Java 17+ впровадили Elastic Metaspace та інші оптимізації, тож non-G1 збирачі теж можуть звільняти частину heap при простої, хоч і менш інтелектуально.
Якщо для вас критично зменшувати RSS, можете явно налаштувати heap shrinking: параметри -XX:MinHeapFreeRatio і -XX:MaxHeapFreeRatio визначають, скільки відсотків вільного heap тримати після GC. За замовчуванням, JVM буде скорочувати heap до Xms, якщо після Full GC відсоток вільної пам’яті перевищує MaxHeapFreeRatio, і навпаки, розширювати heap, якщо вільного менше MinHeapFreeRatio.
Ви можете зменшити MaxHeapFreeRatio (дефолт ~70%) щоб JVM більш агресивно зжимала heap після зборок, економлячи пам’ять. Але це може обернутися частішими алокаціями за рахунок розширення heap, тож теж баланс. З точки зору накладу Parallel GC досить економний — він не тримає структур на кшталт remembered sets (як G1) чи додаткових міток (як ZGC/Shenandoah).
Тому footprint Parallel GC мінімальний, і якщо heap невеликий (сотні МБ), цей збирач може бути дуже ефективним. У таких випадках варто навіть розглянути Serial GC — він ще простіший і економніший (див. нижче). Загалом щоб знизити споживання пам’яті з Parallel GC, не виділяйте надміру великий Xmx, дозвольте JVM зменшувати heap при простої, і ввімкніть -XX:+UseCompressedOops (вмикається автоматично при heap ≤32 ГБ) для економії пам’яті на
Підвищення throughput: тут Parallel GC перевершує всі інші зборщики у більшості сценаріїв, адже він максимально використовує апаратні ресурси під час пауз і не витрачає CPU на фон. Щоб витиснути максимум throughput, по-перше, забезпечте достатню кількість GC-потоків.
За замовчуванням JVM вибирає кількість паралельних потоків GC на основі числа ядер (наприклад, може бути рівним кількості ядер або трохи менше). Прапорець -XX:ParallelGCThreads дозволяє змінити це. На багатоядерних машинах (16+ ядер) не завжди доцільно ставити рівно 16 потоків GC — буває,
Експериментуйте: забагато GC-потоків може навіть знизити throughput через overhead. По-друге, дозвольте збирачу працювати рідше: встановіть відносно великий -XX:GCTimeRatio або довший MaxGCPauseMillis. Наприклад, якщо ви можете терпіти
Часто для throughput-критичних застосунків рекомендують навпаки — підвищувати ціль паузи (до
Перегляньте промоції: можливо, занадто агресивні налаштування Survivor/тен’юрингу призвели до того, що об’єкти рано і масово потрапляють в старий. Можна зменшити MaxTenuringThreshold, щоб швидше викидати об’єкти в старе покоління, але це суперечить мінімізації пауз. Часто краще збільшити heap, щоб Full GC були рідкісні. Загалом Parallel GC дуже добре масштабується по CPU — якщо у вас 32 ядра, він майже пропорційно швидше збирає, ніж на 8 ядрах. Тому для throughput: використовуйте сучасні процесори, надайте достатній heap і дозвольте GC «рідко, але влучно» робити свою справу.
Приклад: Apache Cassandra історично працює на Parallel GC з доволі великими налаштуваннями пауз. Рекомендовано тримати MaxGCPauseMillis=500 мс або навіть до 2000 мс, щоб вузол Cassandra не робив частих пауз, а збирав сміття великими порціями. Це підвищує пропускну здатність системи, хоча пауза 0.5 с вважається доволі великою. У кластері Cassandra це прийнятно, бо паузи рідкі і за них відповідає внутрішній таймаут координації.
Натомість для реального часу (наприклад, онлайн-ігри) такі паузи неприпустимі — там би застосували Shenandoah чи ZGC. Тому Parallel GC найкраще показує себе у високонавантажених бекенд-системах, де невеликі зупинки не критичні, зате важливо максимально швидко обробляти багато даних.
Serial GC
Суть: Serial GC — найпростіший однопотоковий збирач сміття. Він також генераційний (молоде/старе покоління), але використовує лише один потік для збору. Через відсутність синхронізації між потоками Serial GC дуже ефективний на малих heap і однопроцесорних середовищах.
В сучасних JVM він використовується за замовчуванням для «клієнтських» налаштувань (наприклад, при запуску на JVM в режимі Client або на машинах з 1 CPU). Також Serial GC нерідко застосовують в контейнерах з обмеженими ресурсами (малий heap,
Мінімізація пауз: для Serial GC справедливі ті ж прийоми, що й для Parallel, тільки в більшому масштабі. Оскільки збір здійснюється послідовно на одному ядрі, тривалість пауз ~пропорційна кількості живих об’єктів, які треба скопіювати чи просканувати. Якщо heap малий (до ~100 МБ), паузи навіть повного GC обчислюються десятками мілісекунд, що для багатьох застосунків прийнятно.
Якщо heap більший (500 МБ, 1 ГБ), вже варто задуматися про G1/Parallel, бо паузи Serial GC при кількасекундному зборі будуть помітні. Втім, за потреби зменшити паузи в Serial GC — зменшіть heap або розбийте роботу. Наприклад, можна налаштувати частіше Minor GC: зменшити Eden (параметр -Xmn або -XX:NewRatio — співвідношення старого/молодого). Так більше об’єктів встигатиме зібратися у minor, не доходячи до Full GC, і Full GC буде рідшим і швидшим.
Так само якщо є великий об’єм довгоживучих об’єктів, Serial GC доведеться щоразу переглядати їх при compacting — тож мінімізувати паузи можна оптимізацією самого додатку (прибирання непотрібних посилань, менше великих об’єктів у старому поколінні тощо).
Але це вже поза межами JVM-тюнінгу. Загалом якщо у вас Serial GC і спостерігаються довгі паузи, найбільш реалістичний шлях — перейти на інший GC. Serial призначений для простих випадків; він не має складних налаштувань управління паузами, бо його філософія — «просто і з мінімальним overhead».
Зменшення використання пам’яті: Serial GC має мінімальний overhead по пам’яті серед усіх збирачів. Він не тримає додаткових структур для паралельної роботи чи міжрегіонних посилань. До речі, через це саме Serial GC використовується у GraalVM Native Image як дефолт (там важлива компактність). Для зменшення пам’яті, окрім загальної поради не виділяти зайве великий heap, можна хіба що вимкнути деякі опції на кшталт biased locking (до речі, у Java 23 biased locking вже видалено, див. розд.3) чи JIT-компіляції великих методів — але це вже не про GC.
Serial GC автоматично не віддає пам’ять ОС, поки heap не скоротиться до Xms, тому якщо вам треба, щоб процес зменшив RSS, ви можете вручну викликати System.gc() після піку навантаження (але це не рекомендується в продакшні). Краще передбачити -Xms меншим, щоб після Full GC JVM могла скоротити heap. Прапорці MinHeapFreeRatio/MaxHeapFreeRatio також діють і тут — за їх допомогою можна налаштувати, яку частку вільної пам’яті залишати після GC.
Наприклад, якщо встановити MaxHeapFreeRatio=20, JVM спробує зжати heap так, щоб після збору не було більше 20% порожнього простору (віддавши решту ОС). Це фактично дозволить зменшити footprint, хоча й ризикує більш частими алокаціями при новому рості купи.
Підвищення throughput: Serial GC на багатоядерних машинах не використовує паралелізму, тому при великому навантаженні буде програвати Parallel і G1 у пропускній здатності. Проте, якщо ваш додаток запускається на невеликому container або IoT-пристрої з
Тож правило: Serial GC доцільний для малих, однопотокових або короткоживучих застосунків. Він дає дуже передбачувану продуктивність без сплесків CPU на фонові задачі. Для максимального throughput в таких умовах варто вимкнути всі зайві функції JVM, що можуть красти CPU (наприклад, biased locking — хоча, знову ж, в Java 23 це вже не актуально). Налаштуйте -XX:ParallelGCThreads=1 (він і так 1 у Serial), -XX:ConcGCThreads=0 (Serial не використовує concurrent threads).
Сконцентруйтеся на оптимізації коду додатку: найкращий спосіб підвищити throughput при Serial GC — зменшити обсяг сміття, який треба прибирати. Менше алокацій = менше роботи GC = вищий throughput. Це вже сфера профілювання коду, але згадати варто. У контексті JVM-прапорців Serial GC не має спеціальних «режимів» чи евристик — він завжди збирає все сміття, коли heap заповнений.
Ви можете впливати лише на розміри поколінь (NewRatio, SurvivorRatio) та на згадані співвідношення вільної пам’яті. На практиці, якщо ви хочете тюнінгувати GC заради throughput, Serial GC рідко є хорошим вибором — швидше за все, ви оберете Parallel GC. Але є ніша, де Serial чудово підходить: маленькі Java-програми, утиліти, CLI, де запуск триває секунди або хвилини. Тут наклад від складного GC невиправданий, і Serial дасть максимальний performance simplicity.
Актуальність параметрів у Java 23+ (що змінилось, що застаріло)
Серед версій Java останніх років багато налаштувань збирачів було переглянуто. Java 23 належить до покоління, де деякі старі прапорці вже недоступні, а дещо стало працювати автоматично.
Вилучені збирачі (CMS): популярний раніше Concurrent Mark-Sweep GC (CMS) офіційно видалений з HotSpot ще в Java 14 (депрекований у Java 9). Тому будь-які параметри CMS (наприклад, -XX:+UseConcMarkSweepGC, -XX:CMSInitiatingOccupancyFraction, -XX:+CMSClassUnloadingEnabled тощо) в Java 23 не підтримуються — JVM видасть помилку «Unsupported option». Для користувачів це означає перехід на G1 (або Shenandoah/ZGC) для низьких пауз, оскільки CMS більше недоступний.
Biased Locking: механізм упередженого блокування (biased locking), який раніше міг налаштовуватися прапорцем -XX:+UseBiasedLocking, теж зник. Його спочатку вимкнули за замовчуванням у JDK 15, позначили як застарілий, а до JDK 18 усі відповідні прапорці стали недійсними.
Зокрема, UseBiasedLocking та декілька пов’язаних з ним (наприклад, BiasedLockingStartupDelay) у Java 23 не мають ефекту або не розпізнаються зовсім. Це слід врахувати, якщо ви використовували тюнінг -XX:-UseBiasedLocking для покращення latency — тепер JVM завжди працює без biased locking, і вручну нічого вмикати/вимикати не треба.
Параметри G1, змінені в JDK
Якщо у ваших скриптах вони ще прописані — варто їх прибрати, бо вони тепер або ігноруються, або викликають попередження. G1 сам краще управляє цим без ручного втручання.
Розмір регіону G1 (G1HeapRegionSize) — його максимальне значення збільшено у Java 21 з 32 МБ до 512 МБ. Це означає, що для дуже великих heap (>16 ГБ) JVM може автоматично обрати більший регіон, що зменшить кількість регіонів і overhead. Вручну ставити 512 МБ регіони не варто без потреби — але сам факт зміни відображає оптимізацію G1 під сучасні пам’яті. Ще зміна: G1 тепер використовує єдиний бітовий маркер живих об’єктів замість двох, що скорочує наклад на пам’ять приблизно на 1.5% heap (користувачу це не видно, але приємно).
Generational ZGC (нова опція): у Java 21 представлено генераційний режим ZGC, який поки що не увімкнений за замовчуванням. Щоб його використати, треба задати -XX:+ZGenerational разом з UseZGC. У Java 23 Generational ZGC став дефолтним. Тому, плануючи на Java 23+, майте на увазі: ZGenerational — новий важливий прапорець. Він додав також кілька внутрішніх параметрів (наприклад, ZCollectionInterval — інтервал між молодшими GC, тощо), але здебільшого вони приховані і не потребують ручного тюнінгу. Якщо ви переходите з Java 17 на Java 21+ і користувались ZGC, варто протестувати з увімкненим Generational — ви скоріше за все побачите покращення без жодних інших змін.
Shenandoah у Oracle JDK: до Java 15 Shenandoah був лише в збірках OpenJDK від RedHat або інших. Починаючи з Java 17, Shenandoah GC доступний і в Oracle/OpenJDK офіційно (включений у HotSpot).
Відповідно якщо раніше -XX:+UseShenandoahGC могла не розпізнаватися у деяких дистрибутивах, то в Java 23 вона працює «з коробки» в усіх актуальних JVM. Проте деякі Shenandoah-прапорці лишаються експериментальними: наприклад, ShenandoahUncommitDelay, ShenandoahGCHeuristics, ShenandoahAllocSpikeFactor потребують ввімкнення -XX:+UnlockExperimentalVMOptions.
У доках RedHat ці опції описані, але Oracle може позначати їх experimental і міняти синтаксис. Зокрема, в Shenandoah змінились назви режимів евристик: старий режим «dynamic» був перейменований в «static», «continuous» — в «compact» тощо. Якщо ви скопіювали чийсь старий набір тюнінгу Shenandoah (наприклад, -XX:ShenandoahGCHeuristics=dynamic), у Java 23 він може не спрацювати — треба використовувати актуальні назви (static, compact, adaptive).
Завжди звертайтеся до офіційного довідника версії або до -Xlog:gc+start=info — там JVM виводить, які опції увімкнено і чи не є вони deprecated.
Інше: прапорці, що керують виводом та діагностикою GC, змінилися з Java 9 (Unified Logging). Наприклад, замість старих -XX:+PrintGCDetails тепер використовують -Xlog:gc*. Якщо ви оновилися на Java 23, рекомендовано почистити startup-опції від старих параметрів друку GC-логів або deprecated-опцій (JVM сама попереджає в консолі про obsolete VM option). Також зауважте, що Epsilon GC (no-op збирач) доступний як експериментальний у Java 23 (-XX:+UseEpsilonGC), але він не для продакшну — суто для тестів (його налаштовувати особливо нічим, він просто не прибирає сміття).
Висновок щодо актуальності: у Java 23+ застаріли більшість «ручних» параметрів старих збирачів (CMS повністю прибрано; тонке налаштування G1 через внутрішні пороги більше не потрібне; biased locking зник). Натомість з’явилися нові можливості: Generational ZGC, покращений Shenandoah, можливість великих регіонів в G1 тощо — вони за замовчуванням покращують роботу GC без ручного втручання.
Якщо ви мігруєте з Java 8 або 11 на Java 17/21/23, перегляньте свій набір JVM-прапорців: багато з того, що колись тюнінгували (наприклад, параметри CMS, або -XX:SurvivorRatio, -XX:MaxPermSize — останній взагалі не застосовується з Java 8, бо PermGen замінено на Metaspace) — зараз або неактуальне, або за замовчуванням оптимальне.
Завжди читайте release-notes і вивчайте warnings при старті JVM: Java 23 підкаже, якщо якийсь прапорець «обсолет» чи «депрекейтед». Позбувшись зайвого, ви спростите підтримку і дасте JVM можливість оптимізуватися автоматично.
Реальні кейси оптимізації GC
Розглянемо кілька прикладів з практики, що демонструють тюнінг GC у продакшні та його результати:
Перехід з G1 на ZGC для критичної latency (кейс Halodoc). Компанія Halodoc описала досвід оптимізації мікросервісів Java для зниження затримок під час пікових навантажень. Спочатку використовувався G1 GC, що давав передбачувані паузи, але при різких сплесках навантаження спостерігався ріст затримок і навіть ризик OOM-killer у контейнерах (heap заповнювався до максимума). Вирішенням стало впровадження ZGC.
Зокрема, увімкнули ZGC з генераціями (-XX:+UseZGC -XX:+ZGenerational) і встановили SoftMaxHeapSize трохи меншим за ліміт контейнера, щоб ZGC починав прибирати пам’ять завчасно і не доводив heap до повного вичерпання. Результат: середня відповідь сервісів скоротилася з ~205 мс до 148 мс, throughput зріс з 391 до 504 запитів/хв, час на GC зменшився з 3.5% до ~2.4%.
Також помітно знизилося споживання пам’яті: раніше додатки тримали ~25% зарезервованого heap у вигляді невикористаної пам’яті, після — ~20%. Тобто ZGC забезпечив стабільніші паузи та ефективніше використання пам’яті, що критично для їхніх real-time сервісів. Цей кейс показує, як правильний вибір GC (з урахуванням нових функцій, доступних у Java 21) дозволяє досягти кращої продуктивності навіть без глибокого ручного налаштування — достатньо ввімкнути потрібний збирач і задати пару ключових параметрів (heap та SoftMaxLimit).
Тюнінг G1 для великого старого додатку (кейc умовний): уявімо застосунок на Java 11 з heap 8 ГБ, який перейшов на G1 з CMS. Спершу сисадміни залишили всі колишні налаштування CMS (включно з CMSInitiatingOccupancyFraction=70, UseCMSCompactAtFullCollection тощо). Результат — G1 працював неефективно: часто траплялися Full GC, бо старі CMS-параметри конфліктували з алгоритмом G1. Порада від Oracle — прибрати всі сторонні прапорці і залишити лише -Xmx8g -XX:MaxGCPauseMillis=200. Після цього G1 сам налаштував young/old і проблема зникла.
Далі в логах помітили, що іноді mixed GC (коли G1 збирає старі регіони) трохи затягувалися — по
Проаналізувавши лог gc+ergo+cset=trace, виявили, що деякі регіони старого покоління зайняті на 90+% і їх все одно збирають, витрачаючи багато часу.
Інженери вирішили підкрутити: збільшили G1MixedGCLiveThresholdPercent з 85% до 90%, щоб дуже щільно заповнені регіони не чіпати під час молодих пауз. Натомість, щоб не накопичувати сміття, трохи підвищили G1HeapWastePercent з 5% до 7% — дозволили G1 лишати трохи більше сміття після циклу. Це скоротило тривалість кожної mixed-паузи, і середні паузи вписалися в ~150—200 мс. Throughput майже не змінився. Такий приклад демонструє «тонке» налаштування G1 під особливості додатку: якщо довго живуть великі об’єкти, інколи краще їх збирати рідше. Проте варто зазначити: такий тюнінг — індивідуальний, і його ефект потрібно підтверджувати метриками. G1 — досить розумний за замовчуванням, і більшість систем обходяться без подібних змін.
Shenandoah у продакшині (кейc повернення пам’яті): розробник під ніком Thomas описував проблему: Java-додаток з періодичними сплесками навантаження, після яких довго простоює. На G1 GC (Java 11) він бачив, що після піку heap залишався роздутим і не повертав пам’ять ОС дуже довго. Це було критично в середовищі Kubernetes — под займав пам’ять, хоч робота вже завершилась.
Перехід на Shenandoah GC вирішив проблему: додаток став повертати майже весь невикористаний heap назад в ОС через 5 хв після піку (дефолтний ShenandoahUncommitDelay=300000). Для експерименту він навіть зменшив затримку до 1 секунди та встановив періодичний примусовий GC раз на 10 с (ShenandoahGuaranteedGCInterval=10000) — і побачив майже миттєве вивільнення пам’яті після завершення роботи. У продакшні він залишив дефолт 5 хв, щоб уникнути зайвого навантаження, але все одно Shenandoah повністю вирішив «memory leak-like» поведінку G1.
Це реальний кейс, який показує: для додатків зі змінним профілем пам’яті Shenandoah/ZGC можуть значно покращити віддачу ресурсів. З виходом JDK 12 G1 теж навчився повертати пам’ять (JEP 346), але Shenandoah робить це агресивніше і прогнозованіше. Тому деякі сервіси на Java 17+ одразу обирають Shenandoah, коли важливо не тримати зайву пам’ять після пікових навантажень.
Serial GC для малої програми: наприклад, невелика утиліта, що запускається раз на годину для обробки файлу, працює 30 секунд і завершується. Вона запускається з параметрами -Xmx64m на контейнері з 1 vCPU. В такій ситуації Serial GC найдоречніший: його паузи тривають кілька мілісекунд (heap всього 64 МБ), а overhead на багатопоточність відсутній. Розробники спробували G1 — помітили, що JVM за замовчуванням створює ~4 потоки GC, які іноді одночасно спрацьовують, викликаючи стрибки використання CPU на короткий час, що для контейнера небажано.
Переключившись на -XX:+UseSerialGC, вони побачили більш плавний профіль CPU, трохи менший час виконання і відсутність будь-яких проблем з паузами (бо ті і так були <10 мс). Висновок: Serial GC перевершив G1 для цього сценарію, бо G1 просто не встигає розкрити свої переваги на такому малому heap, зате додає складність. Це приклад, що найновіший GC — не завжди найкращий: все залежить від конкретних вимог і навантаження.
Parallel GC в сервері оголошень: уявімо legacy-систему, що генерує звіти, де головне — якомога швидше обробити великий обсяг даних, а паузи некритичні. Вона працює на
Результат: throughput (пропускна здатність) JVM зріс приблизно на
Такий приклад часто зустрічається в Hadoop-подібних системах, офлайн-обробці: Parallel GC дає максимальний обсяг зробленої роботи за одиницю часу, якщо додатку не критичні зупинки. Знову ж, правильний вибір GC під задачу іноді важливіший за дрібний тюнінг.
Кожен із наведених кейсів підкреслює: тюнінг GC треба робити з огляду на специфіку додатку і вимірювати ефект. Параметри, що спрацювали в одній ситуації, можуть не дати виграшу в іншій. Наприклад, SoftMaxHeapSize корисний у контейнерах (вберегти від OOM Kill), але може бути зайвим в монолітному додатку, де краще нехай GC сам керує пам’яттю. Завжди збирайте метрики (час пауз, кількість збірок, використання heap, частка часу GC) до і після змін — це дозволить об’єктивно оцінити, чи покращення відбулося.
Автоматичний vs ручний тюнінг: коли покладатися на JVM, а коли — ні
Сучасна JVM значно «розумніша» у плані налаштування GC, ніж десятиліття тому. В більшості випадків автоматичні параметри достатньо хороші, і втручання потрібне мінімальне.
Ось рекомендації, коли довіритися автоматичним налаштуванням (ERGономіці):
Типові робочі навантаження без жорстких SLA. Якщо у вас звичайний вебсервіс або мікросервіс, який працює стабільно і не має ультранизьких вимог до latency, то дефолтний GC (в Java 17+ це G1) швидше за все впорається як слід. За замовчуванням G1 прагне балансувати latency і throughput, не потребуючи ручних параметрів.
Oracle прямо рекомендує: "Use G1 with its default settings, optionally just set pause-time goal і Xmx«. Тобто часто достатньо вказати розмір heap і, якщо потрібно, прийнятну паузу, і більше нічого. JVM сама налаштує розмір поколінь, кількість потоків та інше.
Наприклад, JDK сам визначає «server-class machine» і вибере G1 замість Serial при >1 CPU або великому heap. Такі механізми роблять більшість стандартних налаштувань зайвими.
Відсутність проблем: якщо ви не спостерігаєте ні довгих пауз, ні високого % часу на GC, ні OOM — не варто зайвий раз «оптимізувати».
Часто запитують: «Може, додати GC-прапорців для швидшої роботи?» — але якщо немає симптомів, лікувати нічого. Збирачі типу G1 і ZGC створені працювати оптимально «з коробки» для широкого спектру випадків. Лишній тюнінг може навіть нашкодити: наприклад, ручне обмеження розміру Eden (через -Xmn) в G1 відключає його динамічний контроль пауз і може погіршити результати. Тому варто покладатися на JVM, поки вона справляється.
Нові GC з self-tuning дизайном: ZGC і Shenandoah спеціально проєктувалися як мінімально налаштовувані. Розробники прямо кажуть: "ZGC is designed to be self-tuning. In most cases, only Xmx is needed«. Shenandoah теж має адаптивні евристики, що за замовчуванням працюють добре. Ручний тюнінг для них — скоріше виняток, коли відомо, що конкретна евристика помиляється для вашого кейсу.
Якщо ви перейшли на такі GC, розраховуйте, що JVM сама оптимізує покоління (для ZGC) чи частоту циклів (Shenandoah). Наприклад, Generational ZGC сам підбирає поріг тен’юрінгу молодих об’єктів під час виконання. Немає потреби лізти в ці деталі — в багатьох випадках немає навіть exposed-параметрів для цього. Тож автоматичний режим тут — єдиний і найкращий.
Динамічні/непостійні навантаження: якщо ваш профіль змінюється (сьогодні одна кількість користувачів, завтра вдвічі більша), автоматика GC краще адаптується на льоту, ніж жорстко виставлені руками параметри. Ручний тюнінг може бути оптимальним для однієї ситуації, але не оптимальним для іншої. JVM-же евристики (особливо в G1) постійно коригують розміри поколінь, паузи тощо, підлаштовуючись під поточну активність.
Приклад: microservice із «піками» трафіку — G1 сам збільшить молоде покоління у час пік, а зменшить у час простою, щоб економити ресурси (це робиться AdaptiveSizePolicy та іншими алгоритмами). Якщо б ви руками виставили велике молоде покоління, воно б займало пам’ять і під час простою надаремно. Тому в змінних навантаженнях — хай GC вирішує за ситуацією.
Принцип «спершу код»: часто вузьке місце — не GC. Перед складним тюнінгом переконайтеся, що саме GC впливає на продуктивність, а не, наприклад, погані алгоритми в коді, протікання пам’яті чи I/O.
Налаштовувати GC має сенс, коли підтверджено, що GC-паузи чи оверхед є проблемою. Якщо ж ні — ліпше зосередитись на оптимізації програми. Це узгоджується з порадою: "GC tuning won’t magically enhance performance. If goals are not reached — consider hardware upgrade or code refactoring«.
Коли потрібне ручне налаштування GC?
Жорсткі вимоги по latency. Якщо додаток вимагає пауз не довше, скажімо,
Також, наприклад, в Shenandoah можна вручну зменшити ShenandoahMaxPauseTimeThreshold (експериментальний параметр, м’яка ціль пауз) або налаштувати pacing більш агресивно — такі речі автоматика сама не зробить, бо це специфічно під ваші SLA.
Через те, що latency-вимоги у всіх різні, high-frequency трейдинг системи часто дуже тонко тюнінгують GC або навіть переходять на спеціалізовані рішення (pauseless collectors на кшталт Azul C4). В Java 23 до такого рідко доходить, бо ZGC сам дає
Великі heap і throughput-завдання: якщо у вас heap сотні гігабайт і ціль — максимальний throughput (напр. обробка big data), вибір і тюнінг GC критичний. Можливо, ви вирішите вимкнути малі оптимізації на користь стабільності: наприклад, G1 має параметр G1PeriodicGCInvokesConcurrent, що може час від часу запускати фоновий GC в режимі idle. На величезному heap це, можливо, не потрібно, і ви його вимкнете.
Або якщо throughput — єдиний критерій, ви могли б увімкнути Serial GC (!) — були випадки, коли на
Приклад: БД Elasticsearch рекомендує для master-вузлів збільшити MaxGCPauseMillis G1 до 400 мс (замість 200) саме з метою підвищити throughput і уникнути частих збірок. Це ручне налаштування, продиктоване профілем навантаження Elasticsearch.
Обмеження середовища (контейнери, функції): у контейнері може бути ситуація, що JVM неправильно визначає ресурси. Хоч з Java 10+ більшість цього вирішено (JVM бачить cgroup-ліміти), інколи варто явно встановити -XX:ParallelGCThreads або ConcGCThreads, якщо точно знаєте, що додатку стільки ядер недоступно.
Наприклад, у docker ви виділили 2 ядра, а JVM все одно може взяти 4 GC-потоки, якщо не ввімкнуто контейнер-обізнаність. У таких випадках ручний тюнінг threads = 2 вирішить проблему з oversubscription. Інший приклад — ліміти по пам’яті: SoftMaxHeapSize для ZGC, ShenandoahUncommitDelay і так далі — ці параметри дуже корисні в Kubernetes, але JVM сама не знає, «скільки бажано не перевищувати».
Ви, як інженер, виставляєте SoftMax = 80% від ліміту контейнера, наприклад. Це явний ручний тюнінг, без якого GC, може, і сам нормально працював би, але є ризик убиття процеса по OOM. Тому при роботі в обмеженому середовищі ручне налаштування допомагає вписати JVM в рамки платформи.
Legacy-застосунки та міграція: якщо ви переносите стару систему на нову Java, інколи доводиться щось тюнінгувати, щоб зберегти поведінку. Наприклад, стара програма розраховувала, що System.gc() буде регулярно запускати Full GC і чистити PermGen (раніше так прибирали непотрібні класи). У Java 23 PermGen немає, Full GC рідкісний, System.gc() за замовч. ігнорується G1. Ви можете примусово увімкнути -XX:+ExplicitGCInvokesConcurrent для G1, щоб System.gc() запускав concurrent-mark цикли замість ігнорування.
Це дуже специфічно, але от приклад: коли треба лізти в докуметацію і шукати параметр під свою потребу. Ще: якщо застосунок погано працює з G1 (теоретично можливо в рідкісних випадках — наприклад, G1 мав баг у старій версії), можна вручну переключитися на Parallel або Shenandoah. JVM сама не знає про баги вашої програми, тут рішення за вами.
Підсумок: дозвольте JVM автоматично керувати GC у більшості випадків, особливо якщо у вас сучасна Java і збирач за замовчуванням. Ручний тюнінг залишайте на ситуації, коли:
- Є чітка проблема (довгі паузи, просадки throughput, переповнення пам’яті) і ви визначили, що її можна вирішити налаштуваннями GC.
- Ви розумієте, які параметри на це впливають, і як їх змінити (підтвердивши ефект тестами).
- Вигоди від тюнінгу перевищують ризики. Ризик — зробити систему більш крихкою до змін навантаження або версії JVM. Після глибокого тюнінгу може виявитись, що при зміні апаратури чи оновленні Java доведеться все перенастроювати заново.
Найкраща практика — почати з мінімуму налаштувань (heap + вибір відповідного GC + базові цілі) і поступово додавати лише те, без чого не досягти мети. Документація Oracle наголошує: "On a case-by-case basis, application-level optimizations can be more effective than trying to tune the VM«. Тобто іноді краще оптимізувати код (наприклад, зменшити виділення об’єктів), ніж надмірно гратися з параметрами JVM.
Висновки (best practices)
Для Java 23+ налаштування GC зводиться до кількох кроків: виберіть відповідний тип GC під вашу задачу (G1 — універсальний вибір, ZGC/Shenandoah — для мінімальних пауз, Parallel — для максимального throughput на великих серверах, Serial — для дрібних процесів).
Виділіть достатньо пам’яті (-Xmx) і визначте прийнятні межі пауз (-XX:MaxGCPauseMillis) або частки часу (-XX:GCTimeRatio) — ці параметри задають стратегічну ціль для збирача. Решту деталей довірте JVM. Моніторте застосунок у продакшні: якщо бачите, що GC близький до меж (наприклад, використання heap стабільно >90% чи паузи наближаються до цілі), можна ще трохи підкоригувати (збільшити heap або послабити ціль).
Але уникайте «мікроменеджменту» — сучасні збирачі оптимізовані під широкі сценарії й часто самі знаходять оптимум. Пам’ятайте, що ціль тюнінгу — покращити роботу застосунка, а не просто змінити налаштування заради зміни. Тому керуйтеся метриками і вимогами, і хай GC робить свою роботу автоматично там, де це можливо.
11 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів