1. Введение
В этом руководстве мы рассмотрим основные проблемы управления памятью в Java и необходимость постоянного поиска более эффективных решений. Основное внимание будет уделено новому экспериментальному сборщику мусора, представленному в Java, под названием Shenandoah, и его сравнению с другими сборщиками мусора.
2. Понимание трудностей при сборке мусора
Сборщик мусора — это форма автоматического управления памятью, при которой среда выполнения, такая как JVM, управляет распределением и освобождением памяти для пользовательских программ. Существует несколько алгоритмов реализации сборщика мусора, включая подсчёт ссылок, пометку и удаление (mark-sweep), пометку и сжатие (mark-compact) и копирование.
2.1. Особенности выбора сборщика мусора
В зависимости от используемого алгоритма сборка мусора может выполняться либо при приостановке пользовательской программы, либо параллельно с её выполнением. Первый вариант обеспечивает более высокую пропускную способность за счёт высокой задержки из-за длительных пауз (так называемых stop-the-world пауз). Второй стремится к меньшей задержке, жертвуя при этом производительностью.
На практике большинство современных сборщиков используют гибридную стратегию, совмещающую оба подхода. Обычно это реализуется путём разделения кучи на молодое и старое поколения. Генерационные сборщики применяют stop-the-world сборку в молодом поколении и конкурентную сборку в старом поколении, возможно, поэтапно, чтобы сократить паузы.
Тем не менее, идеальным вариантом является сборщик мусора, работающий с минимальными паузами и обеспечивающий высокую пропускную способность — при этом с предсказуемым поведением при любом размере кучи — от малого до очень большого. Именно этот вызов на протяжении многих лет стимулирует развитие технологий сборки мусора в Java.
2.2. Существующие сборщики мусора в Java
Некоторые из традиционных сборщиков мусора включают Serial GC и Parallel GC. Они являются генерационными сборщиками и используют копирующий алгоритм для молодого поколения и пометку с последующей компрессией (mark-compact) для старого поколения:
Хотя они обеспечивают хорошую пропускную способность, им присуща проблема длительных stop-the-world пауз.
Сборщик Concurrent Mark Sweep (CMS), представленный в Java 1.4, является генерационным, конкурентным и низкопау́зным сборщиком. Он использует копирующий алгоритм в молодом поколении и пометку и удаление (mark-sweep) в старом поколении:
Он старается минимизировать время паузы, выполняя большую часть работы параллельно с пользовательской программой. Тем не менее, у него остаются проблемы, приводящие к непредсказуемым паузам, он требует больше времени процессора и не подходит для кучи размером более 4 ГБ.
В качестве долгосрочной замены CMS был представлен сборщик Garbage First (G1) в Java 7. G1 — это генерационный, параллельный, конкурентный и инкрементально сжимающий сборщик с низкими паузами. Он использует копирование в молодом поколении и пометку с последующей компрессией (mark-compact) в старом поколении:
Однако G1 также является региональным сборщиком и структурирует область кучи на более мелкие регионы. Это даёт ему преимущество в виде более предсказуемых пауз. Нацеленный на многопроцессорные машины с большим объёмом памяти, G1 тоже не свободен от пауз.
Таким образом, гонка за созданием лучшего сборщика мусора продолжается — особенно такого, который ещё сильнее сокращает время паузы. JVM в последнее время представила ряд экспериментальных сборщиков, таких как ZGC, Epsilon и Shenandoah. Кроме того, G1 продолжает активно развиваться и получать улучшения.
Цель — как можно ближе приблизиться к работе Java без пауз!
3. Сборщик мусора Shenandoah
Shenandoah — это экспериментальный сборщик мусора, представленный в Java 12, позиционируемый как специалист по низкой задержке. Он стремится сократить время пауз, выполняя большую часть работы по сборке мусора параллельно с пользовательской программой.
Например, Shenandoah пытается выполнять перемещение и сжатие объектов конкурентно. Это означает, что время паузы в Shenandoah больше не зависит напрямую от размера кучи, и поэтому он способен обеспечивать стабильное поведение с низкими паузами независимо от объёма памяти.
3.1. Структура кучи
Shenandoah, как и G1, является региональным сборщиком. Это означает, что он делит область кучи на набор одинаково размеченных регионов. Регион — это по сути единица распределения или освобождения памяти:
Однако, в отличие от G1 и других генерационных сборщиков, Shenandoah не делит кучу на поколения. Поэтому ему приходится помечать большинство живых объектов при каждом цикле, чего генерационные сборщики могут избежать.
3.2. Разметка объекта
В Java объекты в памяти содержат не только поля данных — они также включают дополнительную информацию. Эта дополнительная информация состоит из заголовка, который содержит указатель на класс объекта и маркерное слово (mark word).
Маркерное слово используется для различных целей, таких как:
- указатели перенаправления (forwarding pointers),
- биты возраста (age bits),
- блокировка (locking),
- хеширование (hashing).
Shenandoah добавляет дополнительное машинное слово в структуру объекта. Оно служит указателем косвенной ссылки (indirection pointer) и позволяет Shenandoah перемещать объекты без необходимости обновлять все ссылки на них. Это также известно как указатель Брукса (Brooks pointer).
3.3. Барьеры
Выполнение цикла сборки в режиме stop-the-world проще, но сложность резко возрастает, когда сборка происходит параллельно с пользовательской программой. Это создаёт дополнительные сложности для таких фаз, как конкурентная пометка и сжатие.
Решение заключается в перехвате всех обращений к куче с помощью так называемых барьеров (barriers). Shenandoah и другие конкурентные сборщики, такие как G1, используют барьеры для обеспечения целостности кучи. Однако барьеры — это дорогостоящие операции, которые, как правило, снижают общую производительность сборщика.
Например, операции чтения и записи в объект могут быть перехвачены сборщиком с использованием барьеров:
Shenandoah использует несколько типов барьеров на разных этапах, таких как SATB-барьер, барьер чтения и барьер записи. Мы увидим, где именно они применяются, в следующих разделах.
3.4. Режимы, эвристики и аварийные режимы
Режимы определяют, как работает Shenandoah — какие барьеры он использует и каковы его характеристики производительности. Доступны три режима: normal/SATB, iu и passive. По умолчанию используется режим normal/SATB.
Эвристики определяют, когда должна начинаться сборка и какие регионы кучи она должна включать. Доступны варианты: adaptive, static, compact и aggressive. По умолчанию используется эвристика adaptive. Например, она может выбрать регионы, содержащие 60% или более "мусора", и запустить сборку, когда будет выделено 75% регионов.
Shenandoah должен собирать кучу быстрее, чем пользовательская программа успевает её заполнять. Однако иногда он не успевает — это приводит к аварийным режимам. К ним относятся:
- pacing (замедление аллокации),
- degenerated collection (упрощённая сборка),
- и в худшем случае — полная сборка (full GC).
4. Фазы сборки Shenandoah
Цикл сборки Shenandoah в основном состоит из трёх фаз:
- пометка (mark),
- эвакуация (evacuate),
- обновление ссылок (update references).
Хотя основная часть работы в этих фазах выполняется параллельно с пользовательской программой, всё же есть небольшие участки, которые необходимо выполнять в режиме stop-the-world.
4.1. Пометка
Пометка — это процесс определения всех объектов в куче (или её части), которые являются недостижимыми. Это достигается путём обхода графа объектов, начиная с корневых объектов, с целью найти все достижимые объекты:
Пометка в режиме stop-the-world проще, но в конкурентном режиме она усложняется. Это связано с тем, что пользовательская программа одновременно изменяет граф объектов во время выполнения пометки. Shenandoah решает эту проблему с помощью алгоритма Snapshot At the Beginning (SATB).
Это означает, что любой объект, который был жив в начале пометки, или который был выделен после начала пометки, считается живым. Shenandoah использует SATB-барьер, чтобы поддерживать корректное представление кучи в соответствии с SATB.
Хотя большая часть пометки выполняется конкурентно, некоторые этапы всё же выполняются в режиме stop-the-world:
- init-mark — сканирование корневого множества,
- final-mark — очистка всех очередей и повторное сканирование корней.
Фаза final-mark также формирует набор для эвакуации (collection set), то есть регионы, которые подлежат перемещению.
4.2. Очистка и эвакуация
После завершения пометки регионы с мусором готовы к очистке — это те регионы, в которых нет живых объектов. Очистка этих регионов выполняется конкурентно.
Следующим шагом является перемещение живых объектов из набора для эвакуации в другие регионы. Это необходимо для снижения фрагментации памяти и называется компактацией. Эвакуация (или компактация) в Shenandoah также выполняется полностью конкурентно.
Именно здесь Shenandoah отличается от других сборщиков. Конкурентное перемещение объектов — задача непростая, так как пользовательская программа продолжает читать и записывать эти объекты. Shenandoah решает это с помощью операции compare-and-swap над указателем Брукса (Brooks pointer) объекта, чтобы он указывал на его копию в новой области (to-space):
Кроме того, Shenandoah использует барьеры чтения и записи, чтобы обеспечить строгую инвариантность to-space во время конкурентной эвакуации. Это означает, что операции чтения и записи должны происходить только из новой области (to-space), которая гарантированно переживёт эвакуацию.
4.3. Обновление ссылок
На этом этапе сборочного цикла происходит обход кучи с целью обновления всех ссылок на объекты, которые были перемещены в ходе эвакуации:
Фаза обновления ссылок также выполняется в основном конкурентно. Есть короткие периоды init-update-refs, которые инициализируют фазу обновления ссылок, и final-update-refs, которые повторно обновляют корневое множество и освобождают регионы из набора эвакуации. Только эти части требуют режима stop-the-world.
5. Сравнение с другими экспериментальными сборщиками
Shenandoah — не единственный экспериментальный сборщик мусора, недавно появившийся в Java. Другие примеры включают ZGC и Epsilon. Давайте посмотрим, как они соотносятся с Shenandoah.
5.1. Сборщик Z (Z Collector)
Представленный в Java 11, ZGC — это однопоколенный, низкопаузный сборщик мусора, разработанный для очень больших куч — речь идёт о размере в несколько терабайт. ZGC выполняет большую часть своей работы конкурентно с пользовательской программой и использует барьер загрузки (load barrier) для управления ссылками на кучу.
Кроме того, ZGC использует 64-битные указатели и технику, называемую окрашиванием указателей (pointer coloring). Окрашенные указатели содержат дополнительную информацию об объектах в куче. ZGC использует эти данные для переключения объектов и снижения фрагментации памяти.
В общем и целом, цели ZGC схожи с целями Shenandoah — оба стремятся к низким паузам, не зависящим от размера кучи. Однако в Shenandoah доступно больше параметров настройки, чем в ZGC.
5.2. Сборщик Epsilon (Epsilon Collector)
Epsilon, также представленный в Java 11, реализует совершенно иной подход к сборке мусора. Это по сути пассивный или “нулевой” сборщик (no-op collector), что означает, что он распределяет память, но не освобождает её! Когда куча заканчивается, JVM просто завершает работу.
Зачем вообще нужен такой сборщик?
Любой сборщик мусора оказывает опосредованное влияние на производительность приложения. Поэтому бывает сложно точно оценить производительность и влияние GC на приложение.
Именно это и решает Epsilon — он полностью устраняет влияние сборщика, позволяя запускать приложение в изоляции. Но при этом ожидается, что разработчик чётко понимает объём памяти, необходимый приложению. В таких случаях можно добиться максимальной производительности.
Очевидно, что цели у Epsilon кардинально отличаются от целей Shenandoah.
6. Заключение
В этой статье мы рассмотрели основы сборки мусора в Java и необходимость её постоянного совершенствования. Мы подробно изучили самый свежий экспериментальный сборщик — Shenandoah, а также сравнили его с другими доступными в Java экспериментальными сборщиками.
Создание универсального идеального сборщика мусора — задача не ближайшего будущего. Поэтому, несмотря на то что G1 остаётся сборщиком по умолчанию, новые реализации предоставляют нам гибкие варианты для использования Java в условиях низкой задержки. Тем не менее, не стоит воспринимать их как прямую замену высокопроизводительным сборщикам.
Оригинал статьи: https://www.baeldung.com/jvm-experimental-garbage-collectors