Сборка мусора в Java
Как работает сборка мусора в Java: достижимость и GC-корни, поколенческая куча, mark-sweep-compact, типы ссылок и сборщики.
В Java вы никогда не вызываете free(). JVM отслеживает каждый объект, выделенный в куче, и когда объект становится недоступен из вашей работающей программы, сборщик мусора (GC) автоматически освобождает его память. Вы пишете код, создающий объекты; GC молча убирает за вами. Понимание того, как именно он определяет мусор — и в какой части кучи ищет — отличает код, способный масштабироваться, от кода, который зависает под нагрузкой.
На этой странице рассматривается: как GC решает, что оставить (достижимость), как устроена куча для быстрой сборки, алгоритм mark-sweep-compact, четыре силы ссылок, как выбрать сборщик и запускаемый пример, делающий сборку наблюдаемой.
Достижимость и GC-корни
GC не ищет объекты, с которыми вы «закончили работу». Он ищет объекты, которые ещё достижимы. Начиная с набора GC-корней, он проходит по каждой ссылке. Всё, до чего он может добраться, является живым; всё остальное — мусор, независимо от того, считаете ли вы, что объект вам ещё нужен.
| GC-корень | Пример |
|---|---|
| Локальные переменные | Ссылка в стеке выполняющегося потока |
| Статические поля | static final Logger LOG = ... |
| Активные потоки | Живой объект Thread |
| JNI-ссылки | Объекты, удерживаемые нативным кодом |
Присвоение ссылке значения null (или выход её из области видимости) не удаляет ничего — это лишь убирает один путь к объекту. Объект становится пригодным для сборки только тогда, когда не остаётся ни одного пути от какого-либо корня.
Object a = new Object(); // reachable via local variable 'a'
Object b = a; // now two references point to the same object
a = null; // still reachable through 'b' — not garbage
b = null; // now unreachable — eligible for collectionПоколенческая куча
Большинство объектов недолговечны — область запроса, временная переменная в цикле, промежуточная строка. JVM использует эту гипотезу слабых поколений, разбивая кучу на области и собирая молодую область значительно чаще, чем старую.
| Область | Содержит | Сборка |
|---|---|---|
| Young (Eden + 2 пространства Survivor) | Только что выделенные объекты | Часто, быстрой малой GC |
| Old (Tenured) | Объекты, пережившие несколько малых GC | Редко, более медленной большой/полной GC |
| Metaspace | Метаданные классов (не ваши объекты) | При выгрузке загрузчиков классов |
Новые объекты попадают в Eden. Малая GC копирует немногочисленных выживших в пространство Survivor; объекты, которые продолжают выживать, в конечном счёте продвигаются в старое поколение. Поскольку малые GC сканируют только небольшую молодую область, они дёшевы — именно поэтому краткосрочное выделение памяти в Java происходит быстро. (О том, чем стек отличается от кучи, читайте в разделе Stack vs Heap; о роли JVM, которая хранит кучу, читайте в разделе Архитектура JVM.)
Mark, sweep, compact
Сборка выполняется в несколько фаз. Сначала она помечает каждый достижимый объект, обходя граф от корней. Затем очищает, освобождая непомеченные объекты. Многие сборщики добавляют фазу уплотнения, которая сдвигает выжившие объекты вплотную друг к другу, чтобы свободное пространство стало единым непрерывным блоком — это делает выделение памяти простым смещением указателя и предотвращает фрагментацию.
// Pseudocode of what the collector does for you:
// 1. mark: visit(roots); for each reachable object, set live = true
// 2. sweep: for each object on the heap, if !live -> reclaim its memory
// 3. compact: move survivors next to each other, update referencesВы можете предложить сборку с помощью System.gc(), но это лишь подсказка — JVM может её проигнорировать. Никогда не полагайтесь на неё для корректности; считайте её диагностическим инструментом, а не стратегией управления памятью.
Сила ссылок
Не каждая ссылка удерживает объект одинаково. Пакет java.lang.ref позволяет сообщить GC, насколько сильно вы хотите сохранить объект, что лежит в основе кешей, чувствительных к памяти.
| Ссылка | Поведение GC |
|---|---|
Сильная (обычное =) | Никогда не собирается, пока достижима |
SoftReference | Собирается только при нехватке памяти — хороша для кешей |
WeakReference | Собирается при следующей GC, как только не остаётся сильных ссылок |
PhantomReference | Используется для планирования очистки после сборки |
import java.lang.ref.WeakReference;
byte[] data = new byte[1024];
WeakReference<byte[]> ref = new WeakReference<>(data);
data = null; // drop the only strong reference
// After the next GC, ref.get() may return null.Утечки памяти всё равно случаются
Сборщик мусора защищает вас от висячих указателей и двойного освобождения, но не от утечек. Утечка памяти в Java — это объект, который вы больше не используете, но который всё ещё достижим из корня, поэтому GC обязан его хранить. Куча заполняется, GC запускается всё чаще, и в конечном счёте вы получаете OutOfMemoryError.
Классические причины — все варианты «я забыл отпустить»:
- Коллекция
static(кеш, список слушателей, карта), в которую вы постоянно добавляете, но никогда не удаляете. Статические поля являются GC-корнями, поэтому всё, до чего они дотягиваются, живёт вечно. - Слушатели или обратные вызовы, зарегистрированные с долгоживущим объектом и никогда не снятые.
- Ключи, оставшиеся в
HashMapдолго после того, как они перестали быть нужны, потому что карта всё ещё ссылается на них.
Исправление — не флаг: нужно снимать ссылки по завершении работы (удалять из коллекции, отменять регистрацию слушателя) или использовать структуру на основе WeakReference, например WeakHashMap, чтобы GC мог освобождать записи, как только ничто другое не указывает на ключ.
finalize() устарел и ненадёжен — JVM может вызвать его поздно или вовсе не вызвать. Для детерминированного освобождения файлов, сокетов и других ресурсов, не связанных с памятью, используйте try-with-resources и AutoCloseable, а не сборщик мусора.Выбор сборщика
HotSpot JVM поставляется с несколькими сборщиками с разными компромиссами между пропускной способностью (общим объёмом выполненной работы) и задержкой (длиной пауз). Вы выбираете один с помощью флага JVM; по умолчанию с Java 9 используется G1.
| Сборщик | Флаг | Подходит для |
|---|---|---|
| G1 (по умолчанию) | -XX:+UseG1GC | Сбалансированная задержка/пропускная способность, большие кучи |
| Parallel | -XX:+UseParallelGC | Пакетные задания, которым важна сырая пропускная способность |
| ZGC | -XX:+UseZGC | Очень большие кучи, паузы менее миллисекунды |
| Serial | -XX:+UseSerialGC | Маленькие кучи, одноядерные системы или контейнеры |
# Pick a collector and set the heap size at launch:
java -XX:+UseG1GC -Xms256m -Xmx2g MyApp
# Print what the GC is doing, with timestamps:
java -Xlog:gc* MyAppПрактический пример
Приведённая ниже программа делает поведение GC наблюдаемым. Она удерживает один объект сильной ссылкой, держит другой только через WeakReference, порождает волну краткоживущего мусора, затем запрашивает сборку и сообщает, что выжило и как изменилось использование кучи.
Что можно вынести из запуска:
- Слабый референт выводит
trueдо сборки иfalseпосле, доказывая, чтоWeakReferenceне удерживает свой объект живым, как только не остаётся сильных ссылок. - Массив
kept, удерживаемый сильной ссылкой, выводитsurvived: trueдаже послеSystem.gc(), потому что он достижим из GC-корня и сборщик обязан его сохранить. - Примерно 300 МБ мусора выделяется (
Bytes allocated as garbage: 307200000), однако используемая куча вырастает лишь примерно до 5 МБ — малые GC возвращают краткоживущие массивы цикла так же быстро, как они создаются. Runtime.maxMemory()сообщает потолок кучи (здесь около 256 МБ), установленный через-Xmx, тогда какtotalMemory() - freeMemory()— живая используемая часть, которая держится около 3–5 МБ на протяжении всего выполнения.System.gc()— лишь подсказка, но на этой JVM она срабатывает: используемая куча уменьшается, а недостижимый слабый референт очищается, а не продолжает висеть.