Статьи

Минимизируйте использование памяти Java с помощью правильного сборщика мусора

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

Такой подход уменьшает дисковое пространство, время сборки и время запуска. Тем не менее, это не помогает с управлением использованием оперативной памяти. Хорошо известно, что во многих случаях Java потребляет большое количество памяти. В то же время многие не заметили, что Java стала намного более гибкой с точки зрения использования памяти и предоставляла функции, отвечающие требованиям микросервисов. В этой статье мы расскажем о том, как настроить использование ОЗУ в Java-процессе, чтобы сделать его более гибким и получить преимущества от более быстрого масштабирования и снижения совокупной стоимости владения (TCO).

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

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

Существует пять широко используемых решений для сборки мусора для OpenJDK:

  • G1
  • Параллельно
  • ConcMarkSweep (CMS)
  • последовательный
  • Шенандоа

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

Для тестирования мы будем использовать пример Java-приложения, которое помогает отслеживать результаты вертикального масштабирования JVM: https://github.com/jelastic/java-vertical-scaling-test

Следующие параметры запуска JVM будут инициированы для каждого теста GC:

1
java -XX:+Use[gc_name]GC -Xmx2g -Xms32m -jar app.jar [sleep]

где:

  • [gc_name] будет заменено определенным типом сборщика мусора
  • Xms — это шаг масштабирования (в нашем случае 32 МБ)
  • Xmx — это максимальный предел масштабирования (в нашем случае 2 ГБ)
  • [sleep] — интервал между циклами загрузки памяти в миллисекундах, по умолчанию 10

На данный момент для полного освобождения неиспользуемых ресурсов требуется активация Full GC. Это может быть легко инициировано с различными вариантами:

  • jcmd <pid> GC.run — выполнение внешнего вызова
  • System.gc()внутри исходного кода
  • jvisualvm — вручную через отличный инструмент для устранения неполадок VisualVM
  • -javaagent:agent.jar — подключаемый распространенный подход. Надстройка автоматизации с открытым исходным кодом доступна в Github repo Java Memory Agent .

Использование памяти можно отслеживать в выходных журналах или с помощью VisualVM для более глубокого анализа.

G1 Сборщик мусора

Хорошей новостью для экосистемы Java является то, что, начиная с JDK 9, современный сжатый сборщик мусора G1 включен по умолчанию. Если вы используете JDK более низкого выпуска, G1 можно включить с -XX:+UseG1GC .

Одним из главных преимуществ G1 является возможность сжатия свободного пространства памяти без длительных пауз и неиспользуемых неиспользуемых куч. Мы нашли этот GC лучшим вариантом для вертикального масштабирования Java-приложений, работающих на OpenJDK или HotSpot JDK.

Чтобы лучше понять, как JVM ведет себя при разных уровнях нагрузки на память, мы рассмотрим три случая: 1) быстрый, 2) средний и 3) медленный рост использования памяти. Таким образом, мы можем проверить, насколько умна эргономика G1 и как GC справляется с разной динамикой использования памяти.

Быстрый рост использования памяти

1
java -XX:+UseG1GC -Xmx2g -Xms32m -jar app.jar 0

Объем памяти увеличился с 32 МБ до 1 ГБ за 25 секунд.

G1 быстрый рост использования памяти

G1 быстрый рост использования памяти

Если рост использования памяти очень быстрый, эргономика JVM игнорирует шаги масштабирования Xms и резервирует ОЗУ быстрее в соответствии с его внутренним алгоритмом адаптивной оптимизации. В результате мы видим намного более быстрое распределение ОЗУ для JVM (оранжевый) по сравнению с быстрым ростом реального использования (синий). Так что с G1 мы в безопасности, даже в случае скачков нагрузки.

Средний рост использования памяти

1
java -XX:+UseG1GC -Xmx2g -Xms32m -jar app.jar 10

Объем памяти увеличился с 32 МБ до 1 ГБ за 90 секунд за 4 цикла.

Иногда требуется несколько циклов для эргономики JVM, чтобы найти оптимальный алгоритм распределения ОЗУ.

G1 средний рост использования памяти

G1 средний рост использования памяти

Как мы видим, JVM настроил эргономичное распределение ОЗУ на 4- м цикле, чтобы сделать вертикальное масштабирование очень эффективным для повторяющихся циклов

Медленный рост использования памяти

1
java -XX:+UseG1GC -Xmx2g -Xms32m -jar app.jar 100

Объем памяти увеличился с 32 МБ до 1 ГБ с ростом времени дельты около 300 секунд. Очень гибкое и эффективное масштабирование ресурсов, которое соответствует нашим ожиданиям — впечатляет.

G1 медленный рост использования памяти

G1 медленный рост использования памяти

Как вы можете видеть, оранжевая область (зарезервированная ОЗУ) медленно увеличивается в соответствии с ростом синей области (реальное использование). Так что нет чрезмерной или ненужной зарезервированной памяти.

Важно: агрессивная куча или вертикальное масштабирование

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

Одной из многих широко используемых настроек является активация Aggressive Heap в попытке максимально использовать физическую память для кучи. Давайте проанализируем, что происходит при использовании этой конфигурации.

1
java -XX:+UseG1GC -Xmx2g -Xms2g

или же

1
java -XX:+UseG1GC -Xmx2g -XX:+AggressiveHeap
G1 агрессивная куча

G1 агрессивная куча

Как мы видим, зарезервированная куча (оранжевая) постоянна и не изменяется во времени, поэтому вертикальное масштабирование JVM в контейнере отсутствует. Даже если ваше приложение использует только небольшую часть доступной оперативной памяти (синего цвета), остальное нельзя использовать совместно с другими процессами или другими контейнерами, поскольку оно полностью выделено для JVM.

Поэтому, если вы хотите масштабировать приложение по вертикали, убедитесь, что агрессивная куча не включена (параметр должен быть -XX:-AggressiveHeap ), а также не -Xmx2g -Xms2g (например, не -Xmx2g -Xms2g ) ,

Параллельный сборщик мусора

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

1
java -XX:+UseParallelGC -Xmx2g -Xms32m -jar app.jar 10
Параллельный сборщик мусора

Параллельный сборщик мусора

Как мы видим, неиспользуемая ОЗУ не возвращается обратно в ОС. JVM с Parallel GC сохраняет его навсегда, даже не обращая внимания на явные вызовы Full GC.

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

Серийный и ConcMarkSweep Сборщик мусора

Serial и ConcMarkSweep также сокращают сборщики мусора и могут масштабировать использование памяти в JVM по вертикали. Но по сравнению с G1 им требуется 4 полных цикла GC для освобождения всех неиспользуемых ресурсов.

Давайте посмотрим результаты теста для обоих этих сборщиков мусора:

1
java -XX:+UseSerialGC -Xmx2g -Xms32m -jar app.jar 10
Серийный сборщик мусора

Серийный сборщик мусора

1
java -XX:+UseConcMarkSweepGC -Xmx2g -Xms32m -jar app.jar 10
ConcMarkSweep сборщик мусора

ConcMarkSweep сборщик мусора

Начиная с JDK9, высвобождение памяти можно ускорить с помощью новой опции JVM -XX: -ShrinkHeapInSteps, которая приводит к отключению выделенной оперативной памяти сразу после первого цикла полного GC.

Шенандоа Сборщик мусора

Shenandoah — восходящая звезда среди сборщиков мусора, которая уже может считаться лучшим будущим решением для вертикального масштабирования JVM.

Основным отличием по сравнению с другими является возможность асинхронного сжатия (отмены передачи и освобождения неиспользуемой оперативной памяти в ОС) без необходимости вызова Full GC. Shenandoah может сжимать живые объекты, очищать мусор и возвращать ОЗУ обратно в ОС практически сразу после обнаружения свободной памяти. А возможность пропуска Full GC приводит к устранению снижения производительности.

Давайте посмотрим, как это работает на практике:

1
java -XX:+UseShenandoahGC -Xmx2g -Xms32m -XX:+UnlockExperimentalVMOptions -XX:ShenandoahUncommitDelay=1000 -XX:ShenandoahGuaranteedGCInterval=10000 -jar app.jar 10

Здесь мы добавили некоторые дополнительные параметры, доступные в Шенандоа:

  • -XX:+UnlockExperimentalVMOptions — необходим для включения опции -XX:+UnlockExperimentalVMOptions указанной ниже
  • -XX:ShenandoahUncommitDelay=1000 — сборщик мусора начнет освобождать память, которая не использовалась более этого времени (в миллисекундах). Обратите внимание, что слишком низкая задержка может привести к задержкам выделения, когда приложение захочет вернуть память. В реальных развертываниях рекомендуется сделать задержку больше 1 секунды
  • -XX:ShenandoahGuaranteedGCInterval=10000 - это гарантирует цикл GC в пределах указанного интервала (в миллисекундах)
Шенандоа сборщик мусора

Шенандоа сборщик мусора

Шенандоа очень эластичен и выделяет только необходимые ресурсы. Он также сжимает используемую оперативную память (синяя) и высвобождает неиспользованную зарезервированную оперативную память (оранжевую) обратно в операционную систему на лету и без дорогостоящих вызовов Full GC. Обратите внимание, что этот GC является экспериментальным, поэтому ваши отзывы о стабильности будут полезны для его создателей.

Вывод

Java постоянно совершенствуется и адаптируется к постоянно меняющимся требованиям. Таким образом, в настоящее время его аппетит к ОЗУ больше не является проблемой для микросервисов и облачного хостинга традиционных приложений, поскольку уже есть правильные инструменты и способы для его правильного масштабирования, очистки мусора и освобождения ресурсов для необходимых процессов. Благодаря интеллектуальной настройке Java может быть экономически эффективным для всех диапазонов проектов — от облачных стартапов до унаследованных корпоративных приложений.