Сборка мусора в Java. Часть 2. Прогресс со времени выхода JDK 8

от автора

Команда Spring АйО перевела и адаптировала доклад «Garbage Collection in Java: The progress since JDK 8» Стефана Йоханссона(Stefan Johansson) с последнего Devoxx Belgium.

Доклад получилось поделить на две статьи:

  • В первой мы рассказали про основы работы сборки мусора в Java и различных сборщиках мусора

  • Вторая часть посвящена сравнению производительности сборщиков и их прогрессу с момента выхода JDK 8


Введение

JDK 8 был выпущен более 10 лет назад, но он все еще активно используется в мире Java; произошли значительные улучшения с точки зрения производительности во всех областях платформы Java. И это правда, если вы посмотрите на такие области как сборка мусора, JIT компиляция, JDK библиотеки, все стало лучше за эти 10 лет. Это утверждение также является верным, если вы посмотрите на области внутри процесса сборки мусора. Вы увидите, что сборщики мусора имеют более короткие паузы, что понизился перерасход нативной памяти, и общая пропускная способность тоже стала лучше. В начале этой статьи было упомянуто, что очень трудно оптимизировать под все эти параметры, но в течение последних 10 лет удалось достичь определенного прогресса путем отказа от некоторых старых компромиссов, сделанных в прошлом, и нахождения лучших решений для этих проблем.

Кроме того, появились новые сборщики мусора с низкой задержкой, чтобы сделать платформу Java еще более подходящей для большинства рабочих нагрузок. 

Сравнение производительности сборщиков мусора

Далее будут показаны некоторые интересные цифры на диаграммах и графиках. В качестве бенчмарка будет использоваться спецификация SPECjbb2015. Это довольно известный бенчмарк, разработанный организацией, занимающейся спецификациями и фокусирующейся на бенчмарках. Он предоставляет два рейтинга.

Первый рейтинг составляется по сырой пропускной способности. Он показывает прогресс всей платформы Java, включая сборку мусора и другие ее части. Второй рейтинг относится к пропускной способности с учетом ограничений по задержке, и на этот рейтинг сильно влияют длительности GC пауз. Поэтому, когда улучшается данный параметр, мы видим улучшения и во втором рейтинге. 

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

Serial в это сравнение включен не был, потому что данный график не имеет смысла для Serial. 

Пропускная способность 

Для начала посмотрим на значения пропускной способности (throughput). Итак, JDK 8 используется как точка отсчета для Parallel и, как вы можете видеть, при переходе к JDK 17 появляется прирост в производительности на 30% за счет обновления, и часть этого прироста объясняется улучшениями в самом сборщике мусора, а другая часть — улучшениями в других частях платформы Java.

При переходе с JDK 17 на JDK 21 мы не видим такого большого улучшения, но маленький прирост все же есть. Следует помнить от том, что между JDK 8 и JDK 17 прошли около 7 лет постоянной упорной работы над разработкой более качественных решений, а между JDK 17 и JDK 21 — всего два года. Таким образом, между JDK 8 и JDK 17 было потрачено больше времени на улучшения. 

Если мы посмотрим на G1, улучшения даже более очевидны.

Более 40% улучшения по пропускной способности, что прекрасно. Одна из причин этому – тот факт, что G1 был немножко хуже в JDK 8, поэтому у нас было больше пространства для улучшения, и мы потратили больше ресурсов на G1 в JDK 8, если сравнивать с Parallel.

Если перейти к ZGC, вы увидите, что JDK 8 не включен в сравнение, потому что ZGC не существовало в природе во времена JDK 8, но вместо этого JDK 17 используется  как точка отсчета, потому что это был первый релиз с долгосрочной поддержкой (LTS), в котором ZGC был полностью поддержан. 

JDK 17 затем сравнивается с JDK 21, в котором режим single generation все еще был режимом по умолчанию, и с JDK 23, в котором Generational ZGC является версией по умолчанию для ZGC. И как вы можете видеть, добавление режима generational к ZGC дало прирост на 10% к пропускной способности, что является очень неплохим улучшением. 

Задержка

Более-менее такое же улучшение для Parallel по задержке, как и по пропускной способности, 40%-ное.

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

Чтобы улучшить задержку или GC паузы в Parallel между JDK 8 и JDK 17, разработчики оптимизировали внутреннюю систему потоков в Parallel GC, чтобы использовать ту же систему в остальных сборщиках мусора. И это оказалось большой победой, потому что система потоков для других сборщиков мусора оказалась намного более эффективной, чем та, которую использовали в Parallel в прежние времена. Таким образом, оптимизировав код и используя его повторно, разработчики смогли достичь серьезного выигрыша в части производительности. 

Улучшения выглядят еще более впечатляющими для G1.

Между JDK 8 и JDK 17 данный показатель вырос вдвое, затем еще на 10% между JDK  17 и JDK 21. Упорная работа разработчиков, вложенная в G1, по-настоящему отразилась в результатах, потому что между этими релизами разработчики гарантировали, что каждая пауза укоротится по максимуму без потери эффективности. 

Для случая ZGC, улучшения не настолько заметны. 

Около 5% улучшения происходит при переходе на Generational ZGC, и вас может удивить этот факт, ведь ZGC — это сборщик мусора с низкой задержкой, так почему же мы не видим большего улучшения? Причина этого в том, что задержка в JDK 17 была уже настолько хороша для ZGC, что улучшить ее еще больше исключительно трудно. GC паузы в этом сборщике мусора очень короткие, и они не влияют на задержку во всем приложении сколько-нибудь заметным образом.    

 

Чтобы взглянуть на это немного пристальнее, давайте посмотрим на длительность пауз для SPECjbb (используется 99-я процентиль). 

Длительность пауз

Здесь используется немного другой режим SPECjbb,с фиксированной нагрузкой, чтобы сделать более оправданным сборку данных по длительности пауз. Данные по паузам нормализованы, чтобы показать прогресс в терминах сырых чисел, и, как вы можете видеть, разработчикам удалось избавиться от 40% длительности пауз (пауза наихудшего сценария) между JDK 8 и JDK 17, и затем между JDK 17 и JDK 21 значение практически не поменялось. 

То же самое верно и для G1. 

Опять же здесь присутствует большая разница между JDK 8 и JDK 17, но есть и небольшой дополнительный прогресс между JDK 17 и JDK 21.   

И для ZGC улучшения не настолько велики, но все же, режим Generational убирает 50% от наихудшей задержки, хотя это не отображено в рейтинге.

Чтобы немного прояснить этот факт, давайте посмотрим на сырые значения длительностей пауз.

Помните, что это логарифмическая шкала, и поэтому улучшения для Parallel и G1 выглядят не так впечатляюще, но вы видите, что большую часть длительности пауз, которые мы видели на предыдущей иллюстрации, удалось убрать, уменьшив их с примерно 100 миллисекунд до примерно 65 миллисекунд в G1 и Parallel, что очень хорошо для этих сборщиков. 

Но в сравнении с длительностью пауз ZGC это другой порядок чисел, поскольку здесь мы имеем 140 микросекунд для JDK 17, и этот показатель снижается до 120 микросекунд в JDK 23 с Generational ZGC.

И снижение на эти дополнительные 20 микросекунд не влияет заметным образом на рейтинг по задержке, поскольку задержка приложения в этом бенчмарке измеряется в десятых долях миллисекунд. Соответственно, данное улучшение незаметно в рейтингах бенчмарка.

Объем потребляемой памяти (memory footprint)

Последнее, на что мы посмотрим в связи с этим бенчмарком — это пиковый перерасход по нативной памяти. Для Parallel результат выглядит довольно скучно. 

Этот показатель оставался на очень стабильном уровне с JDK 8 до JDK 21. Если бы мы включили JDK 23 для Parallel, мы бы увидели некоторые улучшения. 

И если посмотреть на график для G1, он гораздо интереснее.

Такая разница объясняется тем, что на улучшение G1 разработчики потратили гораздо больше времени и ресурсов. Одной из самых больших проблем G1 в JDK 8 был дополнительный перерасход памяти. И причиной этого была структура данных, называемая remember sets. Remember sets необходимы, чтобы делать сборки мусора, базирующиеся на регионах,поэтому, чтобы иметь возможность собрать мусор в регионах старого поколения, нужны remember sets для этих регионов. С их помощью сборщик мусора узнает, какие объекты необходимо оставить в живых.

Но реализация remember sets в JDK 8 поддерживала их жизнь в течение всего времени жизни приложения. Таким образом, эта память никогда не высвобождалась или, по крайней мере, не высвобождалась эффективным образом. 

Впоследствие команда разработчиков осознала, что remember sets нужны только тогда, когда производится сборка мусора в старом поколении, то есть только при смешанных сборках. Поэтому было решено восстанавливать их при работе в режиме одновременной сборки. И это та функциональность, которая была реализована между JDK 8 и JDK 17.

Поступая таким образом и одновременно с этим обеспечив более эффективную в плане использования памяти структуру remember sets, разработчики смогли уменьшить использование памяти почти наполовину. Этот показатель был понижен с почти двух гигабайт до примерно одного гигабайта. 

И это усилие по снижению перерасхода на remember sets продолжилось в JDK 21, гарантируя, что извлечение максимума возможного из реализации структуры remember sets.   

Но помимо этого был разработан более удачный компромисс, позволяющий решить некоторые другие проблемы в G1, такие как, например, отслеживание информации по статусу жизни. До JDK 21 G1 имел два bitmap по маркировке (bitmap по маркировке — это структура данных, управляющая информацией о статусе жизни для старого поколения). На тот момент по определенным причинам их нужно было именно два, но в дальнейшем разработчики создали лучшую альтернативу и смогли убрать один из bitmap.

А что касается размера кучи на 16 ГБ, что соответствует 256 мегабайтам дополнительной памяти, и по этому показателю тоже удалось добиться хорошей экономии.

Переходим к ZGC. 

Мы видим, что использование памяти идет в обратную сторону с появлением Generational ZGC. Это один из тех компромиссов, о которых говорилось ранее. 

Чтобы иметь возможность делать сборку мусора в Generational режиме, мы должны отслеживать больше информации, чем если бы мы использовали Non-Generational ZGC. 

Как бы ни было обидно видеть эту регрессию, это осознанный выбор, потому что иметь возможность собирать мусор в Generational режиме — это очень важная функциональность для сборщика мусора. И в будущем разработчики планируют убрать Single-Generational режим полностью.

Данные, собранные с помощью Cassandra

Остались еще некоторые данные, относящиеся к производительности, которые были сгенерированы с использованием Cassandra. Как известно, Cassandra — это популярная и распространенная NoSQL база данных, доказавшая свою репутацию. Команда разработки сборщиков мусора в Java использует ее для имитации реального мира. Одно дело просто использовать известные бенчмарки и совсем другое — запустить программный продукт, который используется в реальном мире, который очень хорошо подсвечивает многие из важных аспектов, касающихся использования памяти, длительности пауз и пропускной способности. Данные для приведенных ниже графиков собраны с использованием JDK 21.

Посмотрим на первую иллюстрацию.

Здесь красная линия соответствует Single-Generational ZGC, зеленая — это Generational ZGC, плюс у нас есть две линии для G1, где первая соответствует G1 с целевыми паузами по умолчанию, а вторая, где написано @50ms — это для целевых пауз длиной в 50 миллисекунд.

На первом слайде мы смотрим на среднюю задержку. И как вы можете видеть по линиям графика, G1 работает лучше всего, даже несмотря на то, что Generational ZGC находится достаточно близко. На среднюю задержку здесь следует смотреть скорее как на параметр измерения пропускной способности, и именно поэтому G1 выглядит здесь чуть-чуть лучше, чем ZGC.

В целом G1 имеет чуть-чуть лучшую пропускную способность по сравнению с ZGC. Ось X на графике соответствует количеству клиентов. Добавление все большего количества клиентов подвергает систему все большему и большему стрессу, и график дает нам представление о том, как это повлияет на задержку. 

На следующей иллюстрации мы вместо этого посмотрим на “задержку для процентили 99,99”, то есть на задержку наихудшего сценария, когда мы наращиваем нагрузку. 

И здесь видно, что для Single-Generational ZGC производительность буквально падает в пропасть, когда мы добавляем больше 75 клиентов. На самом деле, на этой отметке ZGC начинает испытывать проблему, называемую allocation stalls.  Allocation stalls — это то, что происходит, когда ZGC (а ZGC является concurrent — сборщиком мусора) освобождает память в то время, когда приложение все еще работает и выделяет память под большее число объектов. Итак, если вы попали в allocation stalls, значит, ZGC не хватило времени, чтобы освободить память достаточно быстро, чтобы удовлетворить всем выделениям памяти, которые приходят в систему. Поэтому в потоках происходит застревание, и когда вы видите это в ZGC, вы должны реально посмотреть на свою конфигурацию и либо добавить размера кучи в ZGC, либо увеличить количество потоков, поскольку ZGC не может угнаться за процессом. Либо вы переключаетесь на Generational ZGC, если вы все еще сидите на Single-Generational ZGC, потому что, как вы можете видеть, Generational ZGC справляется с этой проблемой намного лучше Single-Generational ZGC, а также лучше всех остальных с довольно ощутимым запасом.

Еще одно небольшое уточнение. Здесь сказано, что используется куча на 32 ГБ; те из вас, кого действительно заботят подробности, должны знать, что G1 на самом деле использует чуть меньший размер кучи, чем 32 ГБ, чтобы иметь возможность использовать функциональность под названием Compressed OOP. Compressed OOPs — это очень важная функциональность, которую ZGC, увы, не может поддерживать. Однако по нашему графику мы видим, что, хотя ZGC не может использовать Compressed OOPs, а G1 может, ZGC все равно имеет лучшие показатели. 

Действительно приятно видеть, что G1 с целевой паузой по умолчанию удерживается ниже этого значения для задержки наихудшего сценария.   Для целевой паузы в 50 мс задержка наихудшего сценария постепенно заползает выше искомого показателя, так что разработчикам, вероятно, предстоит еще поработать над этим вопросом, пытаясь удержать этот показатель ниже целевой паузы в 50 мс. Но все же очень приятно видеть, что настолько просто изменить профиль задержки G1, используя один единственный флаг, чтобы снизить задержку еще больше.   

Планы на будущее

Теперь немного поговорим о планах на будущее. Двигаясь вперед, разработчики ставят своей целью дальнейшие улучшения продукта. В частности, многочисленные интересные функции запланированы для ZGC. Детали реализации ZGC позволяют нам воплотить некоторые реально крутые идеи, которые позволят еще больше усовершенствовать процесс сборки мусора в Java.

Если говорить о G1, в планах стоит улучшение показателей по пропускной способности. Чуть раньше в данной статье было упомянуто, что ZGC использует так называемые load barriers (барьеры по загрузке). В G1 и остальных сборщиках мусора, поддерживающих generational сборку, есть еще и понятие write barriers (барьеры по записи), что означает, что некий дополнительный код исполняется в JVM во время записи объекта. И в G1 эти барьеры гораздо более дорогостоящие по сравнению с Parallel, что как раз является причиной несколько худшей производительности у G1 по сравнению с Parallel.

Существуют также планы на JDK 24 по упрощению экспериментов с барьерами G1. Разработчики уже начали экспериментировать, пытаясь переместить барьеры G1, чтобы они были более похожи на используемые в Parallel, и результаты по производительности выглядят весьма многообещающими.

Поэтому есть надежда, что мы увидим еще больше улучшений рейтингов по пропускной способности в G1.

Последнее, конечно — это продолжать снижать объем потребляемой памяти. В Parallel уже были произведены определенные улучшения по сравнению с JDK 21. Как вы могли видеть для случая Generational ZGC, использование памяти идет вверх, и у разработчиков есть некоторые идеи насчет того, как его уменьшить, обеспечивая ситуацию, при которой будет использоваться только та нативная память, которая  действительно нужна для поддержки сборок мусора. Таким образом, идеи на тему того, как продолжать улучшать сборщики мусора в Java, имеются по всем секторам, так что советуем вам продолжать обновляться и таким образом получать бесплатные улучшения производительности.

Ключевые выводы

Итак, подведем итоги. С JDK 8 произошли значительные улучшения, и если вы еще на этой версии, пора задуматься об обновлении. Это может заметно повысить производительность вашего приложения.

Кроме того, важно выбрать подходящий сборщик мусора: его оптимальный выбор серьезно влияет на эффективность приложения. Учитывайте задачи, которые оно решает, и требования к памяти.

И главное — обновляйтесь регулярно. После перехода с JDK 8 дальнейшие обновления будут происходить легче и с меньшими затратами.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

Ждем всех, присоединяйтесь!


ссылка на оригинал статьи https://habr.com/ru/articles/858166/