В языках вроде C память приходится освобождать вручную: забыл вызвать free — утечка, освободил дважды — крах. В Java этим занимается сборщик мусора (garbage collector, GC): он сам находит объекты, которые больше никому не нужны, и возвращает их память. Разберёмся, как это устроено и какими настройками можно на это влиять.
Жив тот объект, до которого есть путь по ссылкам от корней. Молодое поколение чистится часто и быстро — почти всё в нём уже мусор, а редкий выживший переезжает в старое.
Зачем нужна автоматическая сборка мусора
Когда вы пишете new Order(), объект создаётся в области памяти под названием куча (heap). Пока на объект есть хоть одна живая ссылка — он нужен. Как только ссылок не осталось (например, переменная вышла из области видимости), объект становится мусором — до него уже не добраться из работающего кода.
Сборщик мусора периодически проходит по живым объектам, начиная от «корней» (локальные переменные на стеке, статические поля), и всё, до чего не дотянулся, считает мусором и освобождает.
Отсюда и выгода: целый класс ошибок — утечки, двойное освобождение, обращение к уже освобождённой памяти — просто не возникает, и думать можно о логике, а не о том, кто и когда удалит объект.
Проверить это можно прямо в коде. WeakReference — «слабая» ссылка: она даёт посмотреть на объект, но от сборки его не защищает. Если обычных ссылок больше не осталось, после сборки она отдаст null.
живой пример
import java.lang.ref.WeakReference;
public class GcDemo {
record Order(String id) {}
public static void main(String[] args) throws InterruptedException {
Order kept = new Order("ord-1");
Order dropped = new Order("ord-2");
WeakReference<Order> lookKept = new WeakReference<>(kept);
WeakReference<Order> lookDropped = new WeakReference<>(dropped);
dropped = null; // ссылки от корней больше нет
System.gc(); // просьба собрать прямо сейчас
Thread.sleep(100);
System.out.println("на kept ссылка есть: " + lookKept.get());
System.out.println("на dropped ссылок нет: " + lookDropped.get());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
На kept ссылка со стека есть — объект жив. У dropped ссылку обнулили, и сборщик его забрал: слабая ссылка отдаёт null. Строго говоря, этот вывод ничем не гарантирован: System.gc() — просьба, а не команда, и JVM вправе её проигнорировать. На практике HotSpot просьбу выполняет, поэтому пример и показывает то, что показывает, — но писать код, который на это рассчитывает, нельзя. В обычной программе System.gc() не пишут вовсе, момент сборки выбирает сама JVM. Это и есть цена автоматики: иногда сборка приостанавливает приложение, и когда именно — решаете не вы.
Куча и поколения
Большинство объектов живут очень недолго: создались внутри метода, отработали и стали мусором. На этом наблюдении построена поколенческая (generational) модель кучи. Куча делится на две основные части:
- Young Generation (молодое поколение) — сюда попадают все новые объекты. Заполняется быстро. Внутри оно само поделено на три части: большой Eden, куда объект кладут при создании, и две маленькие области Survivor, между которыми переживших сборку перекидывают туда-сюда.
- Old Generation (старое поколение) — сюда «переезжают» объекты, пережившие несколько сборок в молодом поколении, то есть те, что живут долго.
«Несколько сборок» — величина считанная, а не на глаз. У каждого объекта есть возраст: счётчик пережитых молодых сборок. Дошёл счётчик до порога — объект переезжает в старое поколение. Порог задаётся флагом -XX:MaxTenuringThreshold, по умолчанию 15, но сборщик может понизить его на ходу: если выжившие не помещаются в Survivor, часть отправляют в старое поколение раньше срока.
Соответственно и сборки бывают двух видов:
- minor GC — сборка только в молодом поколении. Случается часто, проходит быстро, потому что большинство молодых объектов уже мусор и проверять надо мало живых.
- major GC — сборка, которая добирается до старого поколения. Случается реже, но идёт дольше.
То, что сборки идут сами и часто, видно и без логов: счётчики сборщика доступны из самой программы.
живой пример
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.List;
public class MinorGcDemo {
public static void main(String[] args) {
List<GarbageCollectorMXBean> collectors = ManagementFactory.getGarbageCollectorMXBeans();
for (GarbageCollectorMXBean gc : collectors) {
System.out.println(gc.getName() + ": сборок до цикла " + gc.getCollectionCount());
}
byte[] last = null;
for (int i = 0; i < 200_000; i++) {
last = new byte[1024]; // мусор: живёт один виток цикла
}
System.out.println("создали 200 000 массивов по " + last.length + " байт");
for (GarbageCollectorMXBean gc : collectors) {
System.out.println(gc.getName() + ": сборок после цикла " + gc.getCollectionCount());
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Двести тысяч массивов по килобайту — это 200 МБ мусора, но столько куча не занимала ни секунды: каждый массив умирал на следующем витке цикла, и молодое поколение чистилось несколько раз подряд. Счётчик вырос только у молодого сборщика — на G1 это строка «G1 Young Generation», старый не тронулся вовсе. Обратите внимание, что мы перебираем весь список: порядок бинов в нём ничем не закреплён, и «нулевой» на другой машине окажется другим сборщиком. Имена в выводе — те сборщики, которые работают у вас по умолчанию.
Отдельно стоит full GC — полная сборка всей кучи. У современного сборщика G1 это не очередная фаза по расписанию, а признак, что штатный режим не справился: памяти не хватило, и он остановил приложение, чтобы разобрать кучу целиком. Full GC в логах — всегда повод разобраться, а не норма жизни.
Рядом с кучей есть область, которую сборщик обслуживает по своим правилам: Metaspace. В ней лежат не объекты, а описания классов: байт-код методов, таблицы полей, константы. Живёт она в нативной памяти процесса, в -Xmx не входит и по умолчанию не ограничена сверху (потолок задаёт -XX:MaxMetaspaceSize). Освобождается место в ней только вместе с загрузчиком классов, о чём статья про загрузку классов: пока загрузчик жив, все его классы остаются, и приложение, которое генерирует классы на лету или перезагружается внутри одной JVM, растит Metaspace до OutOfMemoryError: Metaspace. Для обычного сервиса это несколько десятков мегабайт, которые надо учесть в лимите памяти контейнера, о чём ниже.
Паузы stop-the-world
Чтобы безопасно пересчитать ссылки и переместить объекты, сборщику иногда нужно на мгновение остановить весь прикладной код. Это и есть пауза stop-the-world: приложение замирает, ничего не отвечает, пока GC делает свою работу, потом продолжает.
Простыми словами: библиотекарь не пересчитает полки, пока читатели ходят и переставляют книги, — вход ненадолго закрывают, наводят порядок и открывают снова. Чем больше книг (объектов) и чем дольше уборка, тем заметнее задержка. Вся разница между сборщиками — в том, насколько короткими и редкими они умеют делать эти паузы.
Как сборщик вообще узнаёт, что объект мусор
Ни один сборщик не считает ссылки на объект. Он идёт с другой стороны: берёт заведомо живые места — корни (GC roots) — и обходит от них все ссылки. Корни это локальные переменные работающих потоков, статические поля загруженных классов, ссылки, удерживаемые из нативного кода. Всё, до чего обход не дошёл, — мусор, и его не нужно даже перечислять.
Слева корни обхода, справа два исхода: смотрите на подписи стрелок - живым объект делает найденный путь, а не число ссылок на него.
Отсюда важное следствие, из-за которого молодая сборка так дёшева: работа пропорциональна живому, а не мусору. Двести тысяч мёртвых массивов из примера выше не стоили сборщику ничего — он их просто не встретил.
Дальше живое надо как-то убрать с дороги, и способов ровно три:
- пометить и подмести (mark-sweep) — пройти от корней, пометить живое, свободные куски вернуть в список. Быстро, но память дырявится: свободных байт много, а подряд идущего места под большой массив нет.
- пометить и уплотнить (mark-compact) — то же самое плюс сдвиг живых объектов в начало области. Дырок не остаётся, но объекты переезжают, и все ссылки на них надо поправить.
- скопировать живое (copying) — перенести живое в пустую половину, а прежнюю объявить свободной целиком. Именно так работает молодое поколение: Eden и Survivor — это те самые половины.
Что в вашем коде порождает мусор
Флаги сборщика вы будете трогать редко, а объём мусора создаёте каждый день, и это единственное, на что прикладной код влияет напрямую. Пять источников, которые дают большую часть молодых сборок в типичном сервисе.
Склейка строк в цикле. result += line создаёт на каждой итерации новую строку и новый буфер; на тысяче строк это тысяча промежуточных объектов, из которых нужен один. Внутри одного выражения компилятор склеивает через буфер сам, но цикл он не видит: там нужен StringBuilder.
Упаковка чисел. List<Integer> и Map<Long, …> хранят не числа, а объекты-обёртки, и каждое put(id, …) с примитивным long создаёт Long. Счётчик в HashMap<String, Integer>, который увеличивают на миллион событий, создаёт миллион Integer; коллекции примитивов из сторонних библиотек или массив решают это там, где это правда горячо.
Промежуточные коллекции. Цепочка «отфильтровать в список, потом из списка собрать другой список, потом из него третий» создаёт три коллекции ради одной. Стрим делает то же без промежуточных списков, а IntStream обходится и без обёрток.
Объекты на один вызов. Форматтер дат, регулярное выражение, ObjectMapper, созданные внутри метода, который вызывают тысячу раз в секунду. Их создают один раз и переиспользуют; DateTimeFormatter и Pattern для этого и сделаны неизменяемыми.
Ленивая логика в логах. log.debug("заказ " + order) склеивает строку и зовёт toString у заказа, даже когда уровень debug выключен и строка никому не нужна. Параметры вместо склейки, log.debug("заказ {}", order), откладывают работу до момента, когда она точно нужна.
Что здесь важно понять: короткоживущий мусор дёшев, молодая сборка его не считает, и переписывать код ради каждого лишнего объекта не надо. Дорого становится, когда мусора столько, что молодые сборки идут десятки раз в секунду, или когда объекты доживают до старого поколения (кэш без вытеснения, растущий статический список), потому что старое поколение убирается долго и с паузами. Первое видно по частоте строк в логе сборщика, второе по объёму после сборки, о чём разделы ниже.
Основные сборщики и когда какой
В Java 21 в одной JVM (HotSpot) встроено несколько сборщиков. Выбор — это всегда компромисс между двумя величинами:
- пропускная способность (throughput) — какую долю времени машина тратит на полезную работу, а не на сборку;
- длина пауз (latency) — насколько коротки остановки stop-the-world.
| Сборщик | Сильная сторона | Когда уместен |
|---|---|---|
| Serial | минимум накладных расходов | маленькие приложения, мало памяти, одно ядро |
| Parallel | максимальная пропускная способность | пакетная обработка, где паузы не критичны |
| G1 | баланс пауз и пропускной способности | по умолчанию, подходит большинству сервисов |
| ZGC / Shenandoah | очень короткие паузы | большие кучи, требования к стабильно низким задержкам |
Serial GC — самый простой: вся работа в одном потоке и всегда с паузой stop-the-world. Хорош там, где данных немного.
Parallel GC (его ещё называют throughput collector) собирает мусор в несколько потоков. Паузы есть, но суммарно на сборку уходит меньше всего времени: максимум процессора достаётся приложению. Подходит, когда важна общая скорость обработки, а короткие подвисания терпимы.
G1 GC (Garbage-First) — сборщик по умолчанию начиная с Java 9. Делит кучу на множество небольших регионов и собирает в первую очередь те, где больше всего мусора (отсюда название). Старается уложить паузы в заданный бюджет. Это разумный баланс, который подходит большинству серверных приложений без всякой настройки.
ZGC и Shenandoah — сборщики с упором на минимальные паузы. Большую часть работы они делают параллельно с приложением, почти не останавливая его, поэтому паузы остаются очень короткими даже на кучах в десятки и сотни гигабайт. Цена — чуть больше расхода процессора и памяти. Нужны там, где недопустимы заметные подвисания. Shenandoah есть не в каждой сборке JDK — проверьте, что флаг принимается именно вашей.
У ZGC есть тонкость, важная именно для Java 21. По умолчанию в ней работает непоколенческий ZGC: он обходит кучу целиком, не разделяя молодые и старые объекты. Поколенческий вариант в 21-й уже есть, но включается флагом -XX:+ZGenerational — и на обычной нагрузке, где почти весь мусор умирает молодым, он ощутимо дешевле и по процессору, и по памяти. В версиях после 21-й поколенческий стал вариантом по умолчанию, поэтому «включили ZGC» на разных версиях означает разное поведение.
Короткая формула: по умолчанию — G1; нужна максимальная пропускная способность — Parallel; нужны стабильно крошечные паузы на большой куче — ZGC.
Какой сборщик что делает внутри
Названия из таблицы выше — это в первую очередь разные ответы на вопрос «что делать с живыми объектами и когда останавливать приложение».
Serial и Parallel. Молодое поколение — копирование, старое — mark-sweep-compact, и всё это под паузой. Разница между ними одна: Serial работает одним потоком, Parallel — несколькими. Алгоритм тот же, просто у второго больше рук.
G1. Куча нарезана на регионы по несколько мегабайт, и регион в любой момент может быть молодым, старым или «огромным» (humongous — под объект больше половины региона). Пометка живого идёт параллельно с приложением, а собственно уборка — эвакуация: сборщик выбирает несколько регионов, где живого меньше всего, копирует это живое в свободные регионы и объявляет исходные пустыми. Отсюда и название Garbage-First: первыми берут самые «мусорные» регионы, потому что там за ту же паузу освобождается больше.
ZGC и Shenandoah. Здесь параллельно с приложением идёт не только пометка, но и перемещение объектов. Приложение работает в тот момент, когда объект уже переехал, а ссылка в поле ещё указывает на старое место. Как это не ломается — в разделе про барьеры и карты ближе к концу статьи; именно ради этого их паузы не растут вместе с кучей.
Ключевые параметры запуска
Поведение сборщика и размер кучи задаются флагами при запуске java.
Размер кучи:
# начальный размер кучи 512 МБ, максимальный 2 ГБ
java -Xms512m -Xmx2g -jar app.jar
-Xms— начальный размер кучи;-Xmx— максимальный размер кучи.
Частый приём для серверов — задать -Xms равным -Xmx. Тогда JVM сразу резервирует всю кучу и не тратит время на её постепенное расширение под нагрузкой.
В контейнере правило другое, и совет «задайте -Xmx под доступную память» там нужно уточнить. JVM с Java 10 (и с 8u191) видит лимит памяти контейнера и по умолчанию берёт под кучу четверть от него: при лимите пода 2 ГБ куча будет 512 МБ, и три четверти памяти простаивают. Задают долю флагом -XX:MaxRAMPercentage=75, а не абсолютным -Xmx: при смене лимита в манифесте не придётся менять и флаг. Оставить надо не 25%, а столько, сколько нужно всему, что живёт вне кучи: Metaspace, стекам потоков (по мегабайту на поток по умолчанию), буферам ввода-вывода, JIT-компилятору. Если это не учесть, процесс превысит лимит контейнера и будет убит операционной системой с кодом 137 без единой строки в логе JVM: OutOfMemoryError не будет, потому что куче места хватало. Как это выглядит в Kubernetes и как согласовать лимиты пода с флагами, разбирает фаза про контейнеры.
Выбор сборщика:
java -XX:+UseG1GC -jar app.jar # G1 (и так по умолчанию)
java -XX:+UseZGC -jar app.jar # ZGC, короткие паузы
java -XX:+UseParallelGC -jar app.jar # Parallel, максимум пропускной способности
Подсказка цели по паузам (для G1):
# просим G1 стараться удерживать паузы в районе 100 мс
java -XX:MaxGCPauseMillis=100 -jar app.jar
-XX:MaxGCPauseMillis — это цель, а не гарантия. JVM будет стараться её соблюдать, балансируя размеры регионов и частоту сборок, но обещать точное значение не может.
Логи сборки мусора:
# писать события GC в консоль
java -Xlog:gc -jar app.jar
# подробнее и с записью в файл
java -Xlog:gc*:file=gc.log:time,uptime -jar app.jar
-Xlog:gc включает журнал событий сборки: видно, когда и какой сборщик отработал, сколько длилась пауза, сколько памяти освободилось.
Вот как выглядят строки такого журнала у G1 и что в них читать:
[0.412s][info][gc] Using G1
[3.105s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 24M->3M(256M) 2.418ms
[9.870s][info][gc] GC(4) Pause Young (Concurrent Start) (G1 Humongous Allocation) 180M->96M(256M) 6.912ms
[9.871s][info][gc] GC(5) Concurrent Mark Cycle
[41.220s][info][gc] GC(12) Pause Full (G1 Compaction Pause) 250M->248M(256M) 812.334ms
Первая строка говорит, какой сборщик работает на самом деле. В каждой строке паузы четыре вещи: номер события, чтобы связать строки одной сборки; тип сборки и её причина в скобках (Evacuation Pause штатная, Humongous Allocation крупный объект, Compaction Pause аварийная полная); занято до и после сборки и размер кучи в скобках; длительность паузы. Норма это частые Pause Young в единицы миллисекунд, после которых занято падает почти до нуля. Тревога это Pause Full и объём после сборки, который не падает: в последней строке из 256 МБ после полной сборки заняты 248, то есть это живые объекты, и добавлять память бесполезно, о чём раздел про долгие паузы ниже.
Как выбрать и не переусердствовать
Главный совет — начинать с настроек по умолчанию. G1 в Java 21 хорошо подходит большинству приложений, и подкрученный «на глаз» флаг чаще делает хуже, чем лучше.
Разумный порядок действий:
- Запустите приложение как есть (G1 по умолчанию).
- Задайте
-Xmxпод реальную доступную память — это самый влиятельный параметр. - Если есть проблема с производительностью — сначала измерьте: включите
-Xlog:gcи посмотрите, действительно ли дело в сборке мусора, а не в коде или базе данных. - Только если паузы реально мешают — пробуйте другой сборщик (ZGC для коротких пауз) или цель
-XX:MaxGCPauseMillis, по одному изменению за раз и с замером до/после.
Когда уходить с G1
G1 по умолчанию — правильный старт, но у него есть три способа не справиться, и каждый виден в логе -Xlog:gc* раньше, чем на графике задержек.
Первый — нехватка места под эвакуацию. G1 копирует живые объекты в свободные регионы; если свободных не хватило, в логе появляется Evacuation Failure (в старых версиях to-space exhausted), а следом Pause Full (G1 Compaction Pause) — полная сборка с уплотнением всей кучи, на больших кучах это секунды. Причины две: куча забита живыми объектами (лечится дампом, см. ниже) или резерв -XX:G1ReservePercent (10 % по умолчанию) мал для всплесков аллокации.
Второй — крупные объекты. Массив больше половины региона (регион от 1 до 32 МБ, JVM выбирает его по размеру кучи, -XX:G1HeapRegionSize задаёт явно) G1 кладёт в отдельные humongous-регионы сразу в старое поколение. Много таких аллокаций — байтовые буферы, большие списки ответов — и старое поколение растёт между циклами, пометка запускается всё чаще; в логе это растущее число в Humongous regions. Чинят размером региона или кодом: не читать ответ на 40 МБ в один массив.
Третий — цель по паузе не выполняется. MaxGCPauseMillis по умолчанию 200 мс — цель, а не гарантия: на куче в десятки гигабайт с большим живым объёмом смешанные сборки в неё не укладываются, и p99 jvm.gc.pause (Micrometer, тег action) уходит в сотни миллисекунд. Рядом смотрят долю времени в GC — сумму пауз за минуту к длине минуты: больше 5–10 % — не хватает не сборщика, а памяти или процессора.
Куда уходить. Пакетная обработка — ночной расчёт, пересчёт витрины, миграция данных — Parallel (-XX:+UseParallelGC): паузы длиннее, но их никто не ждёт, а суммарно на сборку уходит меньше процессора — у Parallel нет remembered set и дорогого барьера записи G1, и цель по умолчанию GCTimeRatio=99 (1 % времени на GC) против 24 у G1. Большая куча и требование к p99 — ZGC: на Java 21 -XX:+UseZGC -XX:+ZGenerational, с 23-й поколенческий режим включён сам, а с 24-й непоколенческого нет вовсе. Паузы не растут с размером кучи, потому что и пометку, и переезд объектов ZGC делает параллельно с приложением. Цена — процессор на фоновую работу и запас памяти: если приложение выделяет быстрее, чем ZGC успевает освобождать, потоки останавливаются на аллокации — в логе Allocation Stall, и лечится это не флагом, а бо́льшим -Xmx или меньшей скоростью аллокации. Один процессор в контейнере — Serial, и его JVM выбирает сама: при одном доступном процессоре эргономика включает UseSerialGC (-XX:ActiveProcessorCount=1 -XX:+PrintFlagsFinal -version показывает это на JDK 21 и 26). Принудительный G1 на одном ядре отдаёт параллельные фазы тому же процессору, что и приложение. Какой сборщик работает на самом деле, скажет первая строка лога GC (Using G1) или jcmd <pid> VM.flags.
Долгие паузы: сначала смотрим, что живёт в куче
Типичная картина: сервис отвечает по секунде-две, в логе GC — редкие, но долгие Full GC, а после каждой сборки куча остаётся почти полной. Ключевая улика — именно объём после сборки: если из 4 ГБ заняты 3,5, значит, это живые объекты, которые сборщик освободить не может. Он обходит и переупаковывает почти всю кучу — и почти ничего не выигрывает.
Увеличивать -Xmx в такой ситуации — отодвинуть проблему и удлинить паузы: живых объектов не станет меньше, а обходить придётся больше. Правильный первый шаг — дамп кучи и анализ, кто удерживает объём:
jcmd <pid> GC.heap_dump /tmp/heap.hprof # снять дамп с работающего процесса
# или заранее: -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/dumps
Сюда же примыкает OutOfMemoryError — и полезно знать, что он не один. Текст в скобках прямо говорит, где искать:
Java heap space— в куче не нашлось места под новый объект. Либо утечка, либо-Xmxдействительно мал под задачу.GC overhead limit exceeded— сборщик работает почти непрерывно, а освобождает крохи. Причина та же, что у первого, просто замеченная чуть раньше.Metaspace— кончилось место не под объекты, а под сами классы. Обычно их бесконтрольно генерируют на лету или перезагружают приложение в одной и той же JVM.unable to create native thread— память тут ни при чём: упёрлись в лимит потоков операционной системы.
Первые два лечит дамп кучи, третий — поиск того, кто плодит классы, четвёртый — счёт потоков, а не флаги сборщика.
Дамп открывают в Eclipse MAT или VisualVM и смотрят «доминаторы» — объекты, через которые достижимо больше всего памяти. Два обычных ответа: утечка (растущий static-список, незакрытые ресурсы, кеш без вытеснения) — чинят код; или честно большой кеш, которому не хватает памяти, — ограничивают его по размеру и времени жизни. И только потом, если живой объём действительно велик, думают о размере кучи и сборщике с короткими паузами.
Глубже: как отслеживаются ссылки, барьеры, карты и переездырасширенное
Этот раздел для тех, кому нужно понимать цену пауз изнутри; для выбора сборщика он не обязателен. Здесь два разных вопроса, и их легко перепутать.
Первый: как собрать молодое поколение, не обходя старое. Если на молодой объект ссылается кто-то из старого поколения, он живой — а обходить ради этого всё старое поколение значит потерять весь выигрыш быстрой молодой сборки.
Решение — записывать такие ссылки в момент их появления. На каждую запись ссылки в поле объекта JVM выполняет несколько добавочных инструкций — барьер записи (write barrier). Барьер помечает «грязной» карту (card) — участок кучи в 512 байт, которому принадлежит изменённое поле. Весь массив таких пометок и есть card table. При молодой сборке сборщик обходит корни и дополнительно только грязные карты. G1 к этому добавляет remembered set на регион: для каждого региона он держит список мест, откуда на него ссылаются, — иначе пришлось бы просматривать карты всей кучи.
Цена известна и мала: несколько инструкций на запись ссылки. Именно поэтому в Java присваивание поля объекта чуть дороже, чем присваивание примитива, и поэтому «просто уберите барьеры» не бывает.
Второй: что делать со ссылками на объект, который переехал. Уплотнение и эвакуация двигают живые объекты, и ссылки на старое место становятся недействительными. Ответов два:
- поправить всё под паузой. Serial, Parallel и G1 переписывают ссылки в момент остановки: к концу паузы недействительных ссылок в куче не остаётся. Просто и дёшево, но длина паузы растёт вместе с числом живых объектов.
- поправлять по дороге. ZGC и Shenandoah разрешают приложению работать, пока часть ссылок ещё указывает на старые места. При каждом чтении ссылки из поля выполняется барьер чтения (load barrier): он проверяет, не переехал ли объект, и если переехал — возвращает новый адрес и заодно переписывает то поле, откуда ссылку прочитали. Такая правка «по касанию» называется самоисцелением (self-healing): каждая ссылка чинится один раз, при первом обращении, и дальше работает напрямую.
Знать, переехал ли объект, нужно мгновенно. ZGC хранит эту пометку прямо в указателе: в 64-битном адресе есть свободные биты, и в них лежит состояние — помечен, перемещён, ремаплен. Отсюда название цветные указатели (colored pointers). Shenandoah идёт другим путём и держит в заголовке объекта поле для нового адреса.
Шаги сверху вниз: смотрите, в какой момент чинится поле - при первом чтении ссылки, а не под общей паузой.
Есть и третий случай, про который спрашивают реже: пометка идёт параллельно с приложением, и приложение в это время переставляет ссылки — значит, сборщик может не увидеть объект, ссылку на который переложили в уже проверенное место. Лечится это тем же барьером записи: G1 и Shenandoah запоминают старое значение поля при каждой перезаписи ссылки (приём называется SATB — snapshot at the beginning). Смысл такой: пометка работает со «снимком» кучи на момент старта, и всё, что было живым тогда, доживёт до конца цикла. Лишний мусор, попавший в снимок, соберётся в следующий раз — это дешевле, чем потерять живой объект.
Глубже: soft, weak и phantom: ссылки, которые не держат объектрасширенное
WeakReference из первого примера объяснена одной фразой, а видов таких ссылок три, и у каждого своё назначение. Общее у них одно: сборщик не считает объект живым только из-за них.
Weak (слабая) — объект собирают при первой же сборке, если обычных ссылок не осталось. На этом построена WeakHashMap: ключи в ней слабые, и запись исчезает, как только ключ перестал быть нужен кому-то ещё; так делают кэш метаданных по классу или по объекту, который не должен переживать сам объект.
Soft (мягкая) — объект собирают не сразу, а когда памяти становится мало: перед тем как бросить OutOfMemoryError, JVM обязана очистить все мягкие ссылки. Задумано под кэши, которые «можно выбросить при нехватке», и на практике для кэшей почти не используется: момент очистки непредсказуем, а перед ним куча заполняется до предела и сборщик работает всё дольше. Кэш с явным лимитом по размеру и времени (Caffeine) ведёт себя понятнее.
Phantom (призрачная) — через неё объект вообще нельзя достать, get() всегда даёт null. Она нужна ради очереди ReferenceQueue: когда объект собран, ссылка попадает в очередь, и по этому событию освобождают связанный с объектом внешний ресурс: нативную память, дескриптор. С Java 9 для этого есть готовая обёртка Cleaner: регистрируете объект и действие, и действие выполнится после того, как объект стал недостижим. Это замена finalize(), который объявлен устаревшим и будет удалён: он выполнялся непредсказуемо, мог воскресить объект и задерживал сборку на цикл.
Правило для прикладного кода: слабые ссылки встречаются, мягкие почти нет, а Cleaner пишут только авторы библиотек с нативными ресурсами; всё остальное закрывают явно через try-with-resources, а не надеются на сборщик.
Коротко
- Сборщик мусора освобождает объекты, до которых нет пути по ссылкам от корней; работа пропорциональна живому, а не мусору, поэтому молодая сборка дёшева.
- Куча делится на молодое (Eden и два Survivor) и старое поколения, переезд по возрасту (
-XX:MaxTenuringThreshold); minor GC часто и быстро, major реже, full GC у G1 аварийный; Metaspace хранит классы вне кучи и освобождается только с загрузчиком. - Пауза stop-the-world это остановка приложения на время сборки; Serial для маленьких приложений, Parallel для пропускной способности, G1 по умолчанию, ZGC и Shenandoah для крошечных пауз на больших кучах.
- Живое убирают пометкой с подметанием, уплотнением или копированием; ссылки из старого поколения в молодое запоминает барьер записи (card table, remembered set), после переезда объектов их чинят под паузой или барьером чтения (ZGC), пометку страхует SATB.
- Мусор в вашем коде порождают склейка строк в цикле, обёртки чисел в коллекциях, промежуточные коллекции, объекты на один вызов и склейка в логах; дорого не короткоживущее, а то, что доживает до старого поколения.
- Weak-ссылка отпускает объект на первой сборке (
WeakHashMap), soft передOutOfMemoryError, phantom сReferenceQueueиCleanerзаменяютfinalizeдля внешних ресурсов. -Xms/-Xmxзадают кучу, в контейнере вместо них-XX:MaxRAMPercentage=75с запасом на Metaspace, стеки и буферы (иначе код 137 безOutOfMemoryError);-XX:+UseG1GC/-XX:+UseZGCвыбирают сборщик,-XX:MaxGCPauseMillisцель по паузам.- Строка лога
Pause Young … 24M->3M(256M) 2.4msчитается как тип и причина, занято до и после, размер кучи, пауза;Pause Fullс высоким «после» значит живые объекты, а не мало памяти. OutOfMemoryErrorбывает разный:Java heap spaceиGC overhead limit exceededведут в дамп кучи,Metaspaceк тому, кто плодит классы,unable to create native threadк лимитам системы.- Начинайте с настроек по умолчанию; G1 не справляется, когда в логе
Evacuation FailureиPause Full, растут humongous-регионы или p99jvm.gc.pauseвыше цели: тогда Parallel для пакетной обработки, ZGC для больших куч (следить заAllocation Stall), Serial на одном процессоре.
Что почитать дальше
- Инструменты разработчика Java — как запускать, собирать и профилировать приложение.
- Коллекции — где живут объекты, которые потом собирает GC.
- Исключения — куда попадает
OutOfMemoryErrorи почему его не ловят по месту. - Record и современный синтаксис — компактные объекты и современные возможности языка.