Алгоритмы сборки мусора в Java
Сравнение основных сборщиков мусора Java — Serial, Parallel, G1, ZGC и Shenandoah — с анализом их компромиссов.
JVM избавляет вас от ручного освобождения памяти: сборщик мусора (GC) работает в фоне, находит объекты, к которым ваша программа больше не может обратиться, и освобождает занимаемую ими память. Но «сборщик мусора» — это не единая сущность. В HotSpot JVM реализовано несколько коллекторов, каждый из которых предлагает разный баланс между пропускной способностью (какой процент CPU отдаётся приложению, а не GC), задержкой (как долго приложение простаивает во время работы GC) и объёмом накладных расходов (сколько памяти потребляет сам коллектор).
Правильный выбор имеет значение. Пакетное задание, которое всю ночь обрабатывает числа, хочет максимальную пропускную способность и не обращает внимания на паузы; торговая система или веб-сервер хочет как можно более коротких пауз, даже если общая пропускная способность немного снизится. В этой главе сравниваются производственные коллекторы и показывается то, что у них есть общего: объект живёт ровно столько, сколько он достижим.
Как коллекторы решают, что сохранить
Каждый коллектор HotSpot отвечает на один и тот же вопрос — какие объекты всё ещё используются — одинаковым способом: он отслеживает достижимость от множества корней GC (локальные переменные на стеке, статические поля, активные потоки). Любой объект, достижимый из корня, является живым; всё остальное — мусор. Область видимости и возраст значения не имеют; имеет значение только достижимость. Для более широкого понимания того, как это вписывается в среду выполнения, см. Сборка мусора в Java и Стек и куча.
public class Reachability {
public static void main(String[] args) {
String a = new String("kept"); // reachable via local variable 'a'
String b = new String("dropped"); // reachable via 'b'...
b = null; // ...until now: "dropped" is unreachable
System.out.println(a); // 'a' is still a GC root reference
}
}В момент выполнения b = null объект "dropped" не имеет пути ни от одного корня и становится доступным для сборки. Коллектор может освободить его немедленно, значительно позже или — если программа завершится раньше — никогда. Вы никогда не вызываете free; вы просто прекращаете ссылаться на объект.
Структура поколенческой кучи
Большинство Java-объектов умирают молодыми. Коллекторы используют это с помощью поколенческой кучи: новые объекты попадают в молодое поколение (Eden и два пространства выживших), а объекты, пережившие несколько сборок, продвигаются в старое поколение. Частая сборка небольшого, перегруженного мусором молодого поколения — дешёвая операция; большое старое поколение собирается значительно реже.
| Регион | Что здесь находится | Как часто собирается |
|---|---|---|
| Eden | Только что выделенные объекты | При каждой малой GC |
| Survivor (S0/S1) | Объекты, пережившие малую GC | При каждой малой GC |
| Old (tenured) | Долгоживущие, продвинутые объекты | При основной / полной GC |
| Metaspace | Метаданные классов (вне кучи) | При выгрузке классов |
Малая GC очищает молодое поколение и выполняется быстро; основная или полная GC затрагивает старое поколение и является источником длительных пауз, которых все боятся.
Сравнение коллекторов
HotSpot позволяет выбрать коллектор с помощью одного флага, и каждый настроен на различную цель. Алгоритм в коде вы меняете редко — вы задаёте его в командной строке.
java -XX:+UseSerialGC MyApp # single-threaded, tiny heaps
java -XX:+UseParallelGC MyApp # throughput-oriented, multi-threaded
java -XX:+UseG1GC MyApp # balanced, the default since Java 9
java -XX:+UseZGC MyApp # sub-millisecond pauses, huge heaps
java -XX:+UseShenandoahGC MyApp # low pause, concurrent compactionТаблица ниже — это модель для запоминания:
| Коллектор | Сильная сторона | Поведение при паузах | Типичное применение |
|---|---|---|---|
| Serial | Простейший, минимальные накладные расходы | Stop-the-world, один поток | Малые кучи, контейнеры, CLI |
| Parallel | Наибольшая пропускная способность | Stop-the-world, много потоков | Пакетная / обработка данных |
| G1 | Сбалансированный, предсказуемый | В основном конкурентный, целевая пауза | Универсальный по умолчанию |
| ZGC | Очень низкая задержка | Менее миллисекунды, конкурентный | Кучи от нескольких ГБ до ТБ |
| Shenandoah | Очень низкая задержка | Паузы не зависят от размера кучи | Отзывчивые сервисы |
G1 («Garbage-First») является коллектором по умолчанию начиная с Java 9. Он делит кучу на регионы одинакового размера и сначала собирает регионы с наибольшим количеством мусора, ориентируясь на цель по времени паузы, которую вы задаёте с помощью -XX:MaxGCPauseMillis=200.
Конкурентный vs stop-the-world
Ключевой аспект — когда потокам приложения приходится останавливаться. Stop-the-world (STW) коллекторы (Serial, Parallel) приостанавливают все потоки приложения во время работы — просто и с высокой пропускной способностью, но пауза растёт вместе с кучей. Конкурентные коллекторы (ZGC, Shenandoah и большая часть G1) выполняют основную работу пока ваши потоки продолжают работать, поэтому паузы остаются короткими даже при кучах в гигабайты или терабайты.
# See exactly what the collector is doing and how long it pauses
java -Xlog:gc -XX:+UseG1GC MyApp
# Sample output line:
# [0.412s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 24M->5M(64M) 1.832msЭту строку лога стоит расшифровать: 24M->5M(64M) означает, что используемая куча уменьшилась с 24 МБ до 5 МБ при общем объёме 64 МБ, а приложение простаивало 1.832ms. Чтение вывода -Xlog:gc — это единственный наиболее полезный навык настройки GC: измеряйте перед тем, как менять флаги.
Наблюдение за сборкой из кода
Вы не можете напрямую вызвать конкретный алгоритм из Java, но вы можете наблюдать за происходящей сборкой. WeakReference позволяет хранить указатель, который не удерживает свою цель живой, так что можно спросить: «был ли этот объект уже собран?» Класс Runtime сообщает об использовании кучи, а System.gc() — это подсказка, но никак не команда — запустить сборку прямо сейчас.
import java.lang.ref.WeakReference;
WeakReference<byte[]> ref = new WeakReference<>(new byte[1024]);
System.out.println("Before GC: " + (ref.get() != null)); // true
System.gc();
System.out.println("After GC: " + (ref.get() != null)); // usually falseЗапускаемый пример ниже объединяет эти части вместе: он выделяет волну мусора, удерживает одного выжившего достижимым, наблюдает за недостижимым объектом через слабую ссылку и измеряет кучу до и после сборки.
Что можно извлечь из запуска:
- Шаг 2 выводит
true: наблюдаемый объект всё ещё строго достижим через переменнуюgarbage, поэтомуWeakReferenceможет его прочитать. - Шаг 3 сообщает об используемой куче после 300 000 краткосрочных выделений. Точное число варьируется от запуска к запуску — малая GC уже могла очистить большую часть этой волны в середине цикла, — но именно такая нагрузка на молодое поколение и есть то, для обработки чего каждый поколенческий коллектор спроектирован дёшево.
- Шаг 4 выводит
true, подтверждая, что наблюдаемый объект был освобождён, когдаgarbage = nullсделал его недостижимым, аSystem.gc()запустил сборку — доказательство того, что потеря достижимости, а не выход из области видимости, освобождает память. - Шаг 5 выводит
true:survivor, по-прежнему ссылаемый живой локальной переменной (корнем GC), проходит через сборку нетронутым. - Шаг 6 показывает, что используемая куча возвращается к значению, близкому к базовому, демонстрируя, что коллектор возвращает освобождённое пространство для повторного использования, а не утечку.
Подробнее о типах ссылок, используемых здесь — сильных, мягких, слабых и фантомных — см. Ссылки в Java.