← назад к разделу

В языках вроде C память приходится освобождать вручную: забыл вызвать free — утечка, освободил дважды — крах. В Java этим занимается сборщик мусора (garbage collector, GC): он сам находит объекты, которые больше никому не нужны, и возвращает их память. Разберёмся, как это устроено и какими настройками можно на это влиять.

new Order() — каждый новый объект попадает в Young Young Generation Old Generation корни стек, static tmp tmp tmp tmp tmp session выжил объекты создаются, молодое поколение заполняется minor GC: пауза stop-the-world, обход ссылок от корней 5 из 6 недостижимы — память свободна, выживший в Old

Жив тот объект, до которого есть путь по ссылкам от корней. Молодое поколение чистится часто и быстро — почти всё в нём уже мусор, а редкий выживший переезжает в старое.

Обязательно

Зачем нужна автоматическая сборка мусора

Когда вы пишете 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) — и обходит от них все ссылки. Корни это локальные переменные работающих потоков, статические поля загруженных классов, ссылки, удерживаемые из нативного кода. Всё, до чего обход не дошёл, — мусор, и его не нужно даже перечислять.

корни GC обход по ссылкам потоки, static живой объект путь найден мусор пути нет

Слева корни обхода, справа два исхода: смотрите на подписи стрелок - живым объект делает найденный путь, а не число ссылок на него.

Отсюда важное следствие, из-за которого молодая сборка так дёшева: работа пропорциональна живому, а не мусору. Двести тысяч мёртвых массивов из примера выше не стоили сборщику ничего — он их просто не встретил.

Дальше живое надо как-то убрать с дороги, и способов ровно три:

  • пометить и подмести (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 хорошо подходит большинству приложений, и подкрученный «на глаз» флаг чаще делает хуже, чем лучше.

Разумный порядок действий:

  1. Запустите приложение как есть (G1 по умолчанию).
  2. Задайте -Xmx под реальную доступную память — это самый влиятельный параметр.
  3. Если есть проблема с производительностью — сначала измерьте: включите -Xlog:gc и посмотрите, действительно ли дело в сборке мусора, а не в коде или базе данных.
  4. Только если паузы реально мешают — пробуйте другой сборщик (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 идёт другим путём и держит в заголовке объекта поле для нового адреса.

объект переехал GC перенёс живое приложение не останавливали ссылка на старое место поле ещё не поправили код читает поле барьер чтения проверка при чтении объект нашли на новом месте новый адрес вернулся коду самоисцеление поле переписано дальше без проверки

Шаги сверху вниз: смотрите, в какой момент чинится поле - при первом чтении ссылки, а не под общей паузой.

Есть и третий случай, про который спрашивают реже: пометка идёт параллельно с приложением, и приложение в это время переставляет ссылки — значит, сборщик может не увидеть объект, ссылку на который переложили в уже проверенное место. Лечится это тем же барьером записи: 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-регионы или p99 jvm.gc.pause выше цели: тогда Parallel для пакетной обработки, ZGC для больших куч (следить за Allocation Stall), Serial на одном процессоре.

Что почитать дальше