Garbage Collection: Як працює сміттевоз в Java

Однією з найбільших переваг Java (і водночас причиною палких суперечок) є автоматичне керування пам’яттю. Розробникам на Java не потрібно вручну виділяти пам’ять і самостійно звільняти її, як це роблять у C або C++ за допомогою malloc() та free(). За нас цю «брудну роботу» виконує таємничий та потужний механізм — Garbage Collector (GC), або збирач сміття.

Але як саме він розуміє, що об’єкт більше не потрібен? Чому іноді додаток починає «заїдати» (так звані pauses)? І головне — як налаштувати GC під потреби вашого проекту? Давайте розберемося «на пальцях» і без складної академічної термінології.


Що таке «сміття» у Java і як його знайти?

У світі Java «сміттям» вважається будь-який об’єкт у купі (Heap), який більше не може бути використаний вашим кодом. Як GC дізнається, що об’єкт став непотребом?

Раніше популярним був метод підрахунку посилань (Reference Counting): кожен об’єкт мав лічильник, який збільшувався, коли на нього посилалися, і зменшувався, коли посилання зникало. Якщо лічильник дорівнював нулю — об’єкт видалявся. Проте цей підхід мав фатальну ваду: він не міг впоратися з циклоподібними посиланнями (коли Об’єкт А посилається на Об’єкт Б, а той — на Об’єкт А, хоча в програмі вони вже не використовуються).

Тому сучасна JVM використовує метод трасування (Tracing), або аналіз досяжності (Reachability Analysis).

Алгоритм виглядає так:

  1. Визначається набір базових точок, які називаються GC Roots (корені збирача сміття). Це локальні змінні в потоках, активні системні класи, статичні змінні тощо.
  2. GC починає «йти» по ланцюжках посилань від цих коренів до всіх об’єктів у Heap.
  3. Якщо до об’єкта можна дійти від будь-якого GC Root — він вважається досяжним (live object).
  4. Якщо об’єкт ізольований і до нього немає шляху від коренів (навіть якщо кілька непотрібних об’єктів посилаються один на одного) — це «сміття», яке підлягає утилізації.

Покоління пам’яті (Generational Hypothesis)

Більшість об’єктів у програмах «живуть швидко і вмирають молодими». Створена всередині методу тимчасова змінна чи об’єкт запиту (Request) стають непотрібними вже через мілісекунди. Водночас деякі об’єкти (наприклад, синглтони, кеші, конфігурації) живуть протягом усього часу роботи програми.

Спираючись на це спостереження, пам’ять Heap розділили на кілька зон (поколінь):

  1. Young Generation (Молоде покоління):
    • Eden (Едем): сюди потрапляють абсолютно всі новостворені об’єкти.
    • Survivor Spaces (S0 та S1): дві невеликі області для «тих, хто вижив» під час перших очисток.
  2. Old Generation (Старе покоління / Tenured):
    • Сюди переселяються об’єкти, які пережили певну кількість циклів збирання сміття у Young Generation. Вони вважаються «довгожителями».

Як відбувається кругообіг об’єктів?

Коли Eden заповнюється, запускається Minor GC (мале прибирання):

  • Живі об’єкти з Eden переміщуються в область S0 (або S1).
  • Під час наступного Minor GC живі об’єкти з Eden та S0 копіюються в S1, а S0 очищається. Цей процес чергування S0 та S1 триває постійно, підвищуючи «вік» (age) об’єктів.
  • Коли об’єкт досягає певного віку (за замовчуванням 15 циклів), він отримує «зелену карту» і переїжджає до Old Generation.

Коли заповнюється старе покоління, запускається велике прибирання — Major GC або Full GC, яке аналізує весь Heap. Це значно важча і довша операція.


Які бувають Garbage Collectors?

JVM еволюціонувала роками, і сьогодні ми маємо вибір із кількох збирачів сміття. Кожен оптимізований під різні завдання. Нижче наведена лише частина популяпних GC:

  • Serial GC (-XX:+UseSerialGC): Найпростіший варіант. Використовує лише один потік для збирання. Під час його роботи додаток повністю зупиняється (Stop-The-World, STW). Підходить лише для маленьких консольних утиліт або систем з обмеженими ресурсами (1 CPU).
  • Parallel GC (-XX:+UseParallelGC): Працює у кілька потоків, що робить його набагато швидшим за Serial. Орієнтований на максимальну пропускну здатність (throughput) системи. Проте під час очищення все одно виникають паузи STW. Досі популярний на серверах для фонових розрахунків, де секундна затримка не є критичною.
  • G1 GC (-XX:+UseG1GC): Стандартний збирач, починаючи з Java 9. Він ділить Heap на сотні дрібних регіонів. G1 намагається передбачити час пауз і прибирає спочатку ті регіони, де найбільше «сміття» (звідси назва Garbage First). Дуже збалансований збирач для більшості веб-додатків.
  • ZGC (-XX:+UseZGC) та Shenandoah (-XX:+UseShenandoahGC): Сучасні ультранизькозатримкові (ultra-low latency) збирачі. Вони виконують майже всю роботу паралельно з роботою вашого додатку. Паузи STW тут вимірюються мікросекундами, навіть на гігантських терабайтних Heap. Доступні у сучасних версіях Java.

Базове налаштування GC: Практичні поради

JVM чудово вміє налаштовувати себе самостійно (ергономіка), але для високонавантажених систем ручне тюнінгування необхідне.

Ось ключові параметри, з яких варто почати:

1. Керування розміром пам’яті

Перше правило клубу оптимізації пам’яті — дати додатку стільки простору, скільки потрібно, але не забагато:

-Xms<size> — початковий розмір Heap (наприклад, -Xms2g).

-Xmx<size> — максимальний розмір Heap (наприклад, -Xmx4g).
Java

Рекомендація: Для серверних додатків часто встановлюють однаковий початковий і максимальний розмір (-Xms = -Xmx), щоб уникнути витрат ресурсів JVM на динамічне розширення Heap під час роботи.

2. Вибір правильного збирача

  • Якщо у вас Java 11/17/21+ та звичайний веб-сервіс (Spring Boot, Micronaut), залиште G1 GC за замовчуванням.
  • Якщо критично важливо мінімізувати паузи (наприклад, у фінтех-системах або real-time чатах), увімкніть ZGC:
-XX:+UseZGC
Java

Якщо ви обробляєте великі масиви даних у фоні (Big Data, звіти) і вам важлива загальна швидкість виконання, а не паузи:

-XX:+UseParallelGC
Java

3. Тюнінг G1 GC

G1 надзвичайно гнучкий. Замість складних математичних формул, ви можете просто вказати йому бажаний час максимальної паузи:

-XX:MaxGCPauseMillis=200
Java

Цей прапорець каже JVM: «Будь ласка, намагайся не зупиняти мій додаток довше, ніж на 200 мілісекунд». G1 самостійно підлаштує розміри поколінь, щоб вкластися в цей ліміт.

Також корисно налаштувати ініціалізацію очищення старого покоління:

-XX:InitiatingHeapOccupancyPercent=45
Java

Цей параметр визначає відсоток заповнення всього Heap, при якому G1 почне фонову підготовку до очищення (за замовчуванням це 45%). Якщо у вас часто виникають Full GC, спробуйте зменшити це значення до 35-40%, щоб прибирання починалося раніше.


Для глибокого розуміння процесів оптимізації JVM необхідно розглянути, як саме розподіляється пам’ять на фізичному рівні, якими структурами оперує віртуальна машина і як виглядають алгоритми збирання сміття під мікроскопом.

Структурна організація Heap та робота з покажчиками

Область пам’яті Heap (купа) виділяється операційною системою при старті JVM. Вона поділяється на кілька логічних просторів, кожен з яких використовує власні механізми алокації та утилізації ресурсів.

+-------------------------------------------------------------------+
|                           JVM HEAP                                |
+-----------------------------------+-------------------------------+
|         Young Generation          |        Old Generation         |
|  +---------------+------+------+  |  +-------------------------+  |
|  |     Eden      |  S0  |  S1  |  |  |                         |  |
|  +---------------+------+------+  |  |                         |  |
|        [ TLABs ]                  |  |                         |  |
+-----------------------------------+-------------------------------+
Java

Алокація у Young Generation: TLAB та Bump-the-pointer

Створення нових об’єктів у Java відбувається надзвичайно швидко завдяки двом технологіям:

  1. Thread-Local Allocation Buffers (TLAB): Щоб уникнути синхронізації між потоками під час виділення пам’яті в загальному просторі Eden, кожному потоку виділяється його власний невеликий буфер всередині Eden — TLAB. Потік створює об’єкти у своєму TLAB без використання блокувань (lock-free), що суттєво підвищує пропускну здатність.
  2. Bump-the-pointer: Всередині TLAB (або в Eden, якщо об’єкт занадто великий і не вміщується в TLAB) JVM підтримує вказівник на останній виділений адрес. Коли створюється новий об’єкт, JVM перевіряє, чи достатньо місця після вказівника, створює об’єкт і просто зсуває вказівник вперед на розмір об’єкта. Це працює майже так само швидко, як алокація на стеку у мовах типу C++.

Низькорівневий життєвий цикл та копіювання (Copying Collector)

Коли простір Eden заповнюється, JVM ініціює Minor GC. На низькому рівні цей процес використовує алгоритм Copying (копіювання):

  1. Фаза розмітки (Mark): JVM зупиняє потоки додатку (використовуючи глобальні точки зупинки — Safepoints) і сканує стек викликів, регістри та статичні посилання для формування початкового набору GC Roots. Далі відбувається обхід графа об’єктів у Young Generation.
  2. Евакуація (Evacuation): Всі живі об’єкти з Eden та активного простору Survivor (наприклад, S0) копіюються в альтернативний простір Survivor (S1).
    • Ущільнення (Compaction): Оскільки об’єкти просто копіюються один за одним в новий пустий регіон S1, автоматично вирішується проблема фрагментації пам’яті — у S1 об’єкти лежать суцільним блоком, без «дірок».
    • Очищення: Весь простір Eden та S0 очищається одним махом шляхом простого скидання покажчика алокації Bump-the-pointer в нульове положення.
  3. Просування (Promotion): У заголовку кожного об’єкта (Mark Word) зберігається його вік у циклах очищення (відомий як Tenuring Threshold). Якщо після копіювання вік об’єкта перевищує ліміт (за замовчуванням 15, оскільки на вік у заголовку виділено лише 4 біти), об’єкт копіюється безпосередньо у старе покоління (Old Generation).

Механізми старого покоління: Mark-Sweep-Compact

У Old Generation об’єкти зазвичай мають великий розмір або довгий життєвий цикл. Копіювати гігабайти даних між областями пам’яті неефективно. Тому для старого покоління класичні збирачі (наприклад, Parallel GC) використовують алгоритм Mark-Sweep-Compact (Розмітка-Очищення-Ущільнення):

  1. Mark Phase (Розмітка): Проводиться повне трасування досяжності об’єктів від GC Roots. Живі об’єкти маркуються спеціальним бітом у їхніх заголовках або у зовнішній бітовій карті (Card Table).
  2. Sweep Phase (Очищення): JVM сканує пам’ять старого покоління. Об’єкти, які не мають маркування, додаються до списку вільних областей (Free List). Ця фаза призводить до сильної фрагментації пам’яті.
  3. Compact Phase (Ущільнення): Щоб уникнути фрагментації (через яку JVM не зможе виділити пам’ять під новий великий об’єкт, навіть якщо сумарно вільного місця достатньо), виконується зсув усіх живих об’єктів до початку Old Generation. Всі посилання на ці об’єкти в пам’яті оновлюються на нові адреси. Це найважча операція, яка вимагає тривалого Stop-The-World (STW).

Сучасна альтернатива: Регіонарна структура G1 GC та Card Table

Збирач G1 (Garbage First) відмовився від класичного фізичного розділення Heap на безперервні зони. Замість цього вся купа ділиться на тисячі дрібних Регіонів (Regions) розміром від 1 до 32 МБ. Кожен регіон динамічно може призначатися як Eden (E), Survivor (S) або Old (O).

Для великих об’єктів, що перевищують 50% розміру регіону, виділяються спеціальні безперервні ланцюжки регіонів — Humongous Regions (H).

Як G1 уникає сканування всього Heap? Remembered Sets (RSet) та Card Table

Якщо об’єкт у старому поколінні посилається на об’єкт у молодому поколінні, то під час Minor GC нам довелося б сканувати всю гігантську Old Generation, щоб перевірити досяжність молодого об’єкта. Для запобігання цьому використовуються спеціальні структури даних:

  • Card Table (Таблиця карт): Весь Heap ділиться на віртуальні “карти” розміром по 512 байт.
  • Remembered Set (RSet): Кожен регіон має свій RSet, який реєструє всі зовнішні посилання, що входять у цей регіон із сусідніх.
  • Write Barrier (Бар’єр запису): Коли у коді програми виконується запис посилання (objA.field = objB), JVM виконує низькорівневу інструкцію — бар’єр запису. Він аналізує, чи посилається старе покоління на молоде, і якщо так, маркує відповідну карту в Card Table як «брудну» (dirty).

Під час Minor GC збирач G1 сканує лише «брудні» карти з Card Table та аналізує RSet відповідного регіону, що дозволяє локалізувати очищення та значно скоротити час пауз STW.


Розуміння роботи Garbage Collection перетворює розробника з простого кодера на інженера, який здатен створювати стабільні та швидкі системи. Не бійтеся експериментувати з налаштуваннями JVM, але робіть це свідомо: спочатку зберіть метрики (наприклад, за допомогою Prometheus та Grafana або аналізуючи GC-логи за допомогою утиліт типу GCViewer), знайдіть слабке місце, змініть один параметр і протестуйте знову під навантаженням

javaadmin

Супер крутий Dev

Related Posts

Оптимізуємо це: GraalVM Native Image, Leyden та CRaC

Традиційно Java славилася своєю концепцією «Write Once, Run Anywhere» завдяки віртуальній машині JVM та JIT-компіляції (Just-In-Time). Проте у світі мікросервісів, безсерверних обчислень (Serverless) та Kubernetes критично важливими стали швидкість старту застосунку та мінімальне споживання оперативної пам’яті (RSS). У відповідь на ці виклики екосистема Java висунула новаторські потужні технології: GraalVM Native Image, Project Leyden та CRaC (Coordinated Restore at Checkpoint). Давайте для початку трохи детально розберемося ключові компоненти, архітектуру…

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *

Цікаве

Garbage Collection: Як працює сміттевоз в Java

  • Автор javaadmin
  • 13 Серпня, 2026
  • 39 views
Garbage Collection: Як працює сміттевоз в Java

Робота з базами даних у Spring: порівнюємо JDBC, Hibernate та Spring Data JPA

  • Автор javaadmin
  • 6 Серпня, 2026
  • 64 views
Робота з базами даних у Spring: порівнюємо JDBC, Hibernate та Spring Data JPA

Dependency Injection у Spring: пояснюємо на пальцях

  • Автор javaadmin
  • 31 Липня, 2026
  • 79 views
Dependency Injection у Spring: пояснюємо на пальцях

Java Collections Framework: List, Set чи Map?

  • Автор javaadmin
  • 31 Липня, 2026
  • 83 views
Java Collections Framework: List, Set чи Map?

Оптимізуємо це: GraalVM Native Image, Leyden та CRaC

  • Автор javaadmin
  • 17 Липня, 2026
  • 103 views
Оптимізуємо це: GraalVM Native Image, Leyden та CRaC

JBang: Як вивчати Java легко

  • Автор javaadmin
  • 12 Травня, 2026
  • 186 views
JBang: Як вивчати Java легко