Сборка мусора в 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, остальные разбираются в тренажёре.
- Что такое пауза Stop-the-World при сборке мусора?A)Специальный режим, в котором сборщик останавливается сам, отдавая всё время приложениюB)Ситуация, когда один поток блокирует все остальные на мониторе, — синоним взаимоблокировки потоковC)Момент, когда прикладные потоки приостанавливаются для работы сборщикаD)Пауза, во время которой JVM сохраняет полный образ памяти на диск для последующего восстановления
показать ответ и разбор
+C)Момент, когда прикладные потоки приостанавливаются для работы сборщика// разбор: Stop-the-World — пауза, когда ВСЕ прикладные потоки останавливаются, чтобы сборщик мог безопасно поработать (например, переместить объекты). Любой GC имеет какие-то STW-фазы; вопрос в их ДЛИНЕ. G1 держит паузы умеренными и предсказуемыми, ZGC/Shenandoah выполняют почти всю работу конкурентно и сводят STW к суб-миллисекундам даже на огромных кучах. Долгие STW — частая причина «фризов» и таймаутов в latency-чувствительных сервисах.
- Можно ли гарантированно освободить память, вызвав System.gc()?A)Да, System.gc() немедленно очищает кучу от всех недостижимых объектов синхронноB)Да, но только для молодого поколения; старое поколение этот вызов не затрагиваетC)Да, вызов освобождает память мгновенно, поэтому его рекомендуют ставить в конце каждого методаD)Нет — это лишь подсказка JVM; момент и факт сборки не гарантированы
показать ответ и разбор
+D)Нет — это лишь подсказка JVM; момент и факт сборки не гарантированы// разбор: System.gc() — лишь ПОДСКАЗКА сборщику, что «сейчас удобно бы прибраться»; JVM может её проигнорировать или отложить, а момент и полнота сборки не гарантированы. Более того, ручной вызов часто вредит: провоцирует дорогой Full GC и длинные паузы, мешая собственной эвристике сборщика. Управлять памятью явно в Java не нужно — этим занимается GC; вызовы System.gc() в проде обычно убирают (иногда даже отключают флагом).
- Что является GC-корнями (roots), от которых считается достижимость?A)Локальные переменные активных потоков, статические поля, JNI-ссылки и т.п.B)Только объекты, явно помеченные разработчиком аннотацией @GcRoot при их создании в кодеC)Все объекты в старом поколении кучи, поскольку они уже пережили несколько циклов сборкиD)Первый объект, созданный при старте приложения, от которого выстраивается всё дерево ссылок
показать ответ и разбор
+A)Локальные переменные активных потоков, статические поля, JNI-ссылки и т.п.// разбор: GC-корни — это точки входа, заведомо живые: локальные переменные и параметры в кадрах активных потоков, статические поля загруженных классов, ссылки из JNI/нативного кода, объекты-мониторы, удерживаемые в synchronized. Сборщик обходит граф ссылок ОТ этих корней; всё, до чего дошёл — живо, остальное недостижимо и подлежит сборке. Понимание корней помогает читать heap dump и искать утечки: «что держит объект» — это цепочка от корня.
- Почему полагаться на 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.
- Предотвращает ли сборщик мусора все утечки памяти в Java?A)Да, при наличии GC утечки памяти в Java не выходят, о них можно не думать вообщеB)Да, кроме случаев, когда объект создан через рефлексию, — только такие объекты сборщик пропускаетC)Нет: достижимые, но ненужные объекты GC не собирает — это и есть утечкаD)Нет, потому что сборщик в Java нужно запускать вручную, иначе память не освобождается совсем
показать ответ и разбор
+C)Нет: достижимые, но ненужные объекты GC не собирает — это и есть утечка// разбор: GC освобождает только НЕДОСТИЖИМЫЕ объекты. Утечка в Java — это объекты, до которых ещё есть цепочка ссылок от корня (значит, «живые»), но приложению они уже не нужны: бесконечно растущий static-кэш/коллекция, неудалённые слушатели/колбэки, незачищенные ThreadLocal, удержанные загрузчики классов. Такие объекты копятся, пока не придёт OutOfMemoryError. GC тут бессилен — утечку ищут по heap dump (кто и зачем держит ссылку).
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.