сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · JVM и сборка мусора

Сборка мусора в JVM

Сборка мусора: за что платишь и чем

Прогнал один и тот же поток мусора - 1907 МиБ через кучу в 64 МиБ - тремя сборщиками. Serial: 284 мс. Parallel: 347 мс. G1: 419 мс. Победил самый примитивный. Потом добавил живой набор в 450 МиБ и померил паузы: худшая пауза у Serial 271,23 мс, у G1 - 11,74 мс.

Один вопрос, два противоположных ответа, и решает размер живого набора. Поэтому «какой сборщик лучше» проверяет не память на названия, а понимание сделки: автоматическая память в обмен на паузы и процессорное время.

// Формулировки: «как работает поколенческая сборка?», «G1 против ZGC?», «что такое stop-the-world?», «бывают ли в Java утечки памяти?».

Сборщик забирает недостижимое, а не ненужное

Создал weak-ссылку и soft-ссылку на два массива и позвал System.gc(). Weak обнулилась, soft осталась жива. Дальше набивал кучу до отказа - перед самым падением обнулилась и soft. А weak на объект, который держала обычная переменная, пережила всё.

Вот вся механика в одном замере. Сборщик обходит граф от GC roots - стеков потоков, статических полей, ссылок из нативного кода через JNI (Java Native Interface) - и забирает то, до чего не дошёл. Обычная ссылка (strong) держит объект намертво. Weak сдаётся на ближайшей сборке. Soft держится, пока память есть, и жертвует собой перед падением - потому и годится под кэш «лучше медленно, чем упасть».

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

GC roots
точки старта обхода: стеки, статика, JNI
soft / weak
чистится под давлением памяти / на ближайшей сборке

Молодая сборка стоит по выжившим

Те самые 1907 МиБ мусора через кучу 64 МиБ: Serial сделал 328 сборок и потратил на паузы 12 мс суммарно. Двенадцать миллисекунд на два гигабайта мусора - примерно 0,04 мс на сборку.

Так дёшево вышло потому, что в моём цикле почти ничего не выживало. Молодая сборка копирует выживших в survivor и объявляет остаток свободным; мёртвые объекты не обходятся и не стоят ничего. Мусор в Java дёшев ровно до тех пор, пока умирает молодым.

Ломается всё, когда объекты доживают до переезда в old. Full GC берётся за старое поколение целиком, и вот он уже дорогой. Частые full GC - обычно признак преждевременного promotion: «подростки» не успели умереть в young, переехали и умерли там, где уборка стоит дорого. Лечится это чаще снижением аллокационного трафика, чем флагами.

minor / full GC
уборка молодого поколения / всей кучи
stop-the-world
пауза всех прикладных потоков на время работы сборщика

Какой сборщик тебе дали на самом деле

Запустил java в контейнере с лимитом 1791 МиБ и двумя ядрами, распечатал флаги: UseSerialGC=true. Поднял лимит до 1792 МиБ - UseG1GC=true. Вернул 2 ГиБ, но оставил одно ядро - снова Serial.

То есть G1 включается по умолчанию только на «серверной» машине: минимум 1792 МиБ памяти И минимум два ядра, оба условия сразу. Половина продовых подов с лимитом в гигабайт молча работает на Serial - однопоточном сборщике, - и никто об этом не знает, пока не посмотрит флаги.

Чем это оборачивается, видно на живом наборе в 450 МиБ при куче 900 МиБ. Serial: 20 пауз, 769,6 мс суммарно, худшая 271,23 мс. Parallel: 14 пауз, 241,4 мс, худшая 53,13 мс. G1: 13 пауз, 51,6 мс, худшая 11,74 мс. Между худшей паузой Serial и G1 - 23 раза. Дальше по профилю. Нужны единицы миллисекунд на большой куче - ZGC: он переносит объекты одновременно с работой приложения, не останавливая его, и платит за это лишним процессорным временем. Нужна максимальная суммарная пропускная способность, а паузы терпимы (ночной пересчёт, отчёты) - Parallel.

// смотри флаги, а не документацию:
// java -XX:+PrintFlagsFinal -version | grep -E "UseG1GC|UseSerialGC"

// лимит 1791 МиБ, 2 ядра -> UseSerialGC = true   {ergonomic}
// лимит 1792 МиБ, 2 ядра -> UseG1GC     = true   {ergonomic}
// лимит 2 ГиБ,    1 ядро -> UseSerialGC = true   {ergonomic}
G1
региональный сборщик с целевой паузой, дефолт на серверной машине
эргономика JVM
автовыбор сборщика и размеров кучи по железу и лимитам

Тюнинг: сначала данные, потом флаги

Позвал System.gc() и посчитал сборки через GarbageCollectorMXBean: было ноль, стало одна. Добавил -XX:+DisableExplicitGC - осталось ноль. То есть это просьба, которую администратор гасит одним флагом, и в проде обычно гасит.

Смотреть надо не в среднее. У Serial в моём замере средняя пауза вышла 38,48 мс при худшей 271,23 мс - разница в семь раз, и SLA (service level agreement) рвёт именно худшая. Отсюда рабочий порядок: включить GC-логи флагом -Xlog:gc, посмотреть частоту, длительность и темп promotion, а оценивать по p99 - это значение, хуже которого оказался только один замер из ста. Средняя прячет редкие длинные паузы, p99 их показывает.

// А самый действенный тюнинг обычно вообще не в флагах. Тот же цикл с объектами, которые не покидают метод: 97 мс и НОЛЬ сборок, потому что JIT (just-in-time) убрал аллокации. С -XX:-DoEscapeAnalysis - 1045 мс и 985 сборок. Меньше мусора в горячем коде, и настраивать становится нечего.

-Xlog:gc
лог сборок: частота, длительность пауз, promotion

Как отвечать: «Как выберешь сборщик мусора для сервиса?»

Сначала посмотрю, что уже включено. G1 становится дефолтом только от 1792 мегабайт памяти и двух ядер, так что в поде на гигабайт я молча получу Serial - это одна строка с PrintFlagsFinal. Дальше от профиля. Онлайн-сервису с требованием по латентности - G1: на моём замере с живым набором в 450 мегабайт худшая пауза у него была 11,74 мс против 271 мс у Serial. Если нужны единицы миллисекунд на большой куче - ZGC, он перемещает объекты одновременно с работой приложения, а платим за это процессорным временем. Ночному пересчёту, где важна суммарная пропускная способность, а не отклик, лучше Parallel: паузы длиннее, зато совокупно быстрее. Но до смены сборщика я включу -Xlog:gc и посмотрю на длину пауз по p99 и на темп promotion: частые full GC чаще лечатся снижением аллокаций в горячем коде, чем флагами.

Почему это сильный ответ: он начинается с проверки фактического состояния, а не с выбора по названию, у каждого варианта названа цена, и порядок действий взрослый - данные, потом код, потом флаги.

На чём валят

  • «В Java есть сборщик, значит утечек нет». Достижимый мусор - кэш без выселения, статическая коллекция, листенер - не заберут никогда.
  • Считать, что G1 включён. Ниже 1792 МиБ памяти или на одном ядре JVM молча ставит Serial; проверяется одной командой с -XX:+PrintFlagsFinal.
  • Судить о сборщике по средней паузе. У меня средняя была 38 мс при худшей 271 мс - SLA рвёт вторая.
  • Мерить сборщики на потоке чистого мусора. Там побеждает Serial, потому что копировать нечего; на живом наборе картина переворачивается.
  • System.gc() в проде «для порядка». Это внеплановая пауза на ровном месте, а на многих стендах вызов ещё и выключен флагом.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 17, остальные разбираются в тренажёре.

  1. #garbage_collection1 / 5
    Что такое пауза Stop-the-World при сборке мусора?
    A)Специальный режим, в котором сборщик останавливается сам, отдавая всё время приложению
    B)Ситуация, когда один поток блокирует все остальные на мониторе, — синоним взаимоблокировки потоков
    C)Момент, когда прикладные потоки приостанавливаются для работы сборщика
    D)Пауза, во время которой JVM сохраняет полный образ памяти на диск для последующего восстановления
    показать ответ и разбор
    +C)Момент, когда прикладные потоки приостанавливаются для работы сборщика

    // разбор: Stop-the-World — пауза, когда ВСЕ прикладные потоки останавливаются, чтобы сборщик мог безопасно поработать (например, переместить объекты). Любой GC имеет какие-то STW-фазы; вопрос в их ДЛИНЕ. G1 держит паузы умеренными и предсказуемыми, ZGC/Shenandoah выполняют почти всю работу конкурентно и сводят STW к суб-миллисекундам даже на огромных кучах. Долгие STW — частая причина «фризов» и таймаутов в latency-чувствительных сервисах.

  2. #garbage_collection2 / 5
    Можно ли гарантированно освободить память, вызвав System.gc()?
    A)Да, System.gc() немедленно очищает кучу от всех недостижимых объектов синхронно
    B)Да, но только для молодого поколения; старое поколение этот вызов не затрагивает
    C)Да, вызов освобождает память мгновенно, поэтому его рекомендуют ставить в конце каждого метода
    D)Нет — это лишь подсказка JVM; момент и факт сборки не гарантированы
    показать ответ и разбор
    +D)Нет — это лишь подсказка JVM; момент и факт сборки не гарантированы

    // разбор: System.gc() — лишь ПОДСКАЗКА сборщику, что «сейчас удобно бы прибраться»; JVM может её проигнорировать или отложить, а момент и полнота сборки не гарантированы. Более того, ручной вызов часто вредит: провоцирует дорогой Full GC и длинные паузы, мешая собственной эвристике сборщика. Управлять памятью явно в Java не нужно — этим занимается GC; вызовы System.gc() в проде обычно убирают (иногда даже отключают флагом).

  3. #garbage_collection3 / 5
    Что является GC-корнями (roots), от которых считается достижимость?
    A)Локальные переменные активных потоков, статические поля, JNI-ссылки и т.п.
    B)Только объекты, явно помеченные разработчиком аннотацией @GcRoot при их создании в коде
    C)Все объекты в старом поколении кучи, поскольку они уже пережили несколько циклов сборки
    D)Первый объект, созданный при старте приложения, от которого выстраивается всё дерево ссылок
    показать ответ и разбор
    +A)Локальные переменные активных потоков, статические поля, JNI-ссылки и т.п.

    // разбор: GC-корни — это точки входа, заведомо живые: локальные переменные и параметры в кадрах активных потоков, статические поля загруженных классов, ссылки из JNI/нативного кода, объекты-мониторы, удерживаемые в synchronized. Сборщик обходит граф ссылок ОТ этих корней; всё, до чего дошёл — живо, остальное недостижимо и подлежит сборке. Понимание корней помогает читать heap dump и искать утечки: «что держит объект» — это цепочка от корня.

  4. #garbage_collection4 / 5
    Почему полагаться на finalize() для освобождения ресурсов — плохо?
    A)Finalize() вызывается строго сразу после того, как объект стал недостижим, поэтому ресурс держится долго
    B)Момент вызова не гарантирован, он устарел (deprecated) и вредит производительности
    C)Finalize() работает только для примитивных типов, а для объектов с ресурсами он не вызывается
    D)Finalize() выполняется дважды на каждый объект, из-за чего ресурс освобождается и тут же занимается снова
    показать ответ и разбор
    +B)Момент вызова не гарантирован, он устарел (deprecated) и вредит производительности

    // разбор: finalize() вызывается сборщиком в НЕОПРЕДЕЛЁННЫЙ момент (или вообще не вызывается, если объект не собран), может неопределённо задерживать сборку и воскрешать объекты — поэтому он ненадёжен, медленный и объявлен deprecated (с Java 9). Ресурсы (файлы, соединения) освобождают детерминированно: try-with-resources (AutoCloseable) или, для страховки, java.lang.ref.Cleaner/PhantomReference. Правило: закрытие ресурса не отдают на откуп GC.

  5. #garbage_collection5 / 5
    Предотвращает ли сборщик мусора все утечки памяти в Java?
    A)Да, при наличии GC утечки памяти в Java не выходят, о них можно не думать вообще
    B)Да, кроме случаев, когда объект создан через рефлексию, — только такие объекты сборщик пропускает
    C)Нет: достижимые, но ненужные объекты GC не собирает — это и есть утечка
    D)Нет, потому что сборщик в Java нужно запускать вручную, иначе память не освобождается совсем
    показать ответ и разбор
    +C)Нет: достижимые, но ненужные объекты GC не собирает — это и есть утечка

    // разбор: GC освобождает только НЕДОСТИЖИМЫЕ объекты. Утечка в Java — это объекты, до которых ещё есть цепочка ссылок от корня (значит, «живые»), но приложению они уже не нужны: бесконечно растущий static-кэш/коллекция, неудалённые слушатели/колбэки, незачищенные ThreadLocal, удержанные загрузчики классов. Такие объекты копятся, пока не придёт OutOfMemoryError. GC тут бессилен — утечку ищут по heap dump (кто и зачем держит ссылку).

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.