[Высокая производительность, Java] Как мы переходили на Java 15, или история одного бага в jvm длинной в 6 лет

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
12-Фев-2021 12:34

Мы готовились к выходу Java 15 ради некоторых её новых возможностей. В частности — текстовых блоков. Да, они появились в Java 14 (о новых функциях в Java 14 можно посмотреть здесь), но только как превью-фича, а, начиная с Java 15, она стала доступна в виде окончательно готовой функции.Мы в hh.ru привыкли внедрять и использовать самые современные технологии в разработке ПО. Пробовать что-то новое — одна из ключевых задач команды Архитектура. Пока многие пишут на Java 8, мы уже близки к тому, чтобы отправить на свалку истории Java 11.
Как известно, Java стала выходить куда как чаще, поэтому работы по обновлению версий у нас прибавилось. С одной стороны, пришлось адаптироваться к новым реалиям, менять привычки, что не всегда комфортно. С другой — можно заранее целиться в функции, которые появятся в новой версии языка и не ждать релиза 3 года.Переезд с Java 14 на Java 15. Что-то пошло не такДождавшись выхода новый Java, мы приступили к переезду. Не мудрствуя лукаво, выбрали один из нагруженных сервисов, который уже крутился на Java 14. В теории никаких сложностей при переходе не должно было возникнуть, на практике — так и получилось. Обновление Java 14 на Java 15 не тоже самое, что обновление Java 8 на Java 11.“hh и в продакшн” — сервис обновлён, работа выполнена. Что дальше? А дальше мониторинг работы. Для сбора метрик мы используем  okmeter.  С его помощью мы наблюдали за поведением обновленного сервиса. Никаких аномалий по сравнению с предыдущей версией Java не было, кроме одной — нативная память. В частности, зона Code Cache выросла почти в 2 раза!
До конца 17 ноября Java 14, после Java 15Для сервиса с большим количеством инстансов каждый мегабайт памяти на счету, и, помимо её резкого роста, на графике просматривается возрастающий тренд. Кажется мы имеем дело с утечкой нативной памяти в Code Cache.Что такое вообще этот ваш Code Cache?Code Cache — область нативной памяти, где хранится интерпретатор байткода Java, JIT-компиляторы C1 и C2, и оптимизированный ими код. Основным пользователем является JIT. Весь перекомпилированный им код будет сохранятся в Code Cache.Начиная с Java 9 Code Cache поделен на три отдельных сегмента, в каждом из которых хранится свой тип оптимизированного кода (JEP 197). Но на графике выше видно только одну выделенную область, несмотря на то что там Java 14 и Java 15. Почему одну? Дело в том, что мы тонко настраивали размеры памяти при переводе сервисов в Docker (о том, как это было, можно почитать тут) и умышленно установили флаг размера Code Cache (ReservedCodeCacheSize) равным 72МБ в этом сервисе. Три сегмента можно получить двумя путями: оставить значение ReservedCodeCacheSize по умолчанию (256Мб) или использовать ключ SegmentedCodeCache. Вот как эти зоны выглядят на графике с другого нашего сервиса:
Поиск утечки нативной памяти в Code CacheС чего начать расследование? Первое что приходит на ум — использовать Native Memory Tracking, функцию виртуальной машины HotSpot, позволяющую отслеживать изменение нативной памяти по конкретным зонам. В нашем случае использовать Native Memory Tracking нет необходимости, так как благодаря собранным метрикам, мы уже выяснили, что проблема в Code Cache. Поэтому мы решаем сделать следующее — запустить инстансы сервиса с Java 14 и Java 15 вместе. Так как у нас уже три дня сервис работает на "пятнашке", добавляем один инстанс на 14-ой. Мы решаем продолжить поиск утечки с помощью утилит Java. Начнем с jcmd. Так как мы знаем, что "течет" у нас Code Cache, мы обращаемся к нему. Если сервис запущен в Docker, можно выполнить команду таким образом для каждого инстанса:
docker exec <container_id> jcmd 1 Compiler.CodeHeap_Analytics
Получаем два очень длинных и подробных отчета о состоянии Code Cache. Кропотливо сравнив их, мы обратили внимание на следующий интересный факт, связанный с очисткой Code Cache:
// Java 14
Code cache sweeper statistics:
Total sweep time: 9999 ms
Total number of full sweeps: 17833
Total number of flushed methods: 10681 (thereof 1017 C2 methods)
Total size of flushed methods: 20180 kB
// Java 15
Code cache sweeper statistics:
Total sweep time: 5592 ms
Total number of full sweeps: 236
Total number of flushed methods: 11925 (thereof 1146 C2 methods)
Total size of flushed methods: 44598 kB
Обратите внимание на количество циклов полной очистки — Total number of full sweeps. Вспомним, что сервис на Java 15 работает 3 дня, а на Java 14 всего 20 минут. Но количество полных очисток Code Cache поразительно разнится — почти 18 тысяч за 20 минут, против 236 за трое суток.Как работает очистка Code CacheПришло время углубиться в детали. За очистку Code Cache отвечает отдельный поток jvm CodeCacheSweeperThread, который вызывается с определенной эвристикой. Поток реализован как бесконечный цикл while, внутри которого он блокируется, пока не истечет 24-часовой таймаут, либо не будет снята блокировка вызовом:
CodeSweeper_lock->notify();
После того, как блокировка снята, поток проверяет, истек ли таймаут и имеет ли хотя бы один из двух флагов, запускающих очистку Code Cache, значение true. Только при выполнении этих условий, поток вызовет очистку Code Cache методом sweep(). Давайте подробнее разберем флаги:should_sweep. Этот флаг отвечает за две стратегии очистки Code Cache — нормальную и агрессивную. О стратегиях поговорим дальше.force_sweep. Этот флаг устанавливается в true при необходимости принудительно очистить Code Cache без выполнения условий нормальной и агрессивной стратегий очистки. Используется в тестовых классах jdk.Нормальная очистка
  • Во время вызова GC хранящиеся в Code Cache методы могут изменить свое состояние по следующему сценарию: alive -> notentrant -> zombie. Методы не-alive помечаются как "должны быть удалены из Code Cache при следующем запуске потока очистки".
  • В конце своей работы GC передает ссылку на все не-alive объекты в метод report_state_change.
  • Далее в специальную переменную bytes_changed инкрементируется суммарный размер объектов, помеченных как не-alive в этом проходе GC.
  • Когда bytes_changed достигает порога, задаваемого в переменной sweep_threshold_bytes, флаг should_sweep помечается как true и блокировка потока очистки снимается.
  • Запускается алгоритм очистки Code Cache, в начале которого значение bytes_changed сбрасывается. Сам он состоит из двух фаз: сканирование стека на наличие активных методов, удаление из Code Cache неактивных. На этом нормальная очистка завершена.
Начиная с Java 15 пороговым значением можно управлять с помощью флага jvm SweeperThreshold — он принимает значение в процентах от общего количества памяти Code Cache, заданном флагом ReservedCodeCacheSize.Агрессивная очисткаЭтот тип очистки появился еще в Java 9, как один из способов борьбы с переполнением Code Cache. Выполняется в тот момент, когда свободного места в памяти Code Cache становится меньше заранее установленного процента. Этот процент можно установить самостоятельно, используя ключ StartAggressiveSweepingAt, по умолчанию он равен 10. В отличие от нормальной очистки, где мы ждем наполнения буфера "мертвыми" методами, проверка на старт агрессивной очистки выполняется при каждой попытке аллокации памяти в Code Cache. Другими словами, когда JIT-компилятор хочет положить новые оптимизированные методы в Code Cache, запускается проверка на необходимость запуска очистки перед аллокацией. Проверка эта довольно простая, если свободного места меньше, чем указано в StartAggressiveSweepingAt, очистка запускается принудительно. Алгоритм очистки такой же, как и при нормальной стратегии. И только после выполнения очистки, JIT сможет положить новые методы в Code Cache.Что у нас?В нашем случае размер Code Cache был ограничен 72 МБ, а флаг StartAggressiveSweepingAt мы не задавали, значит по умолчанию он равен 10. Если взглянуть на статистику очистки Code Cache, может показаться, что на Java 14 работает именно агрессивная стратегия. Дополнительно убедиться в этом нам помог тот же график, но с увеличенным масштабом:
Java 14Он имеет зубчатую структуру, которая говорит о том, что очистка происходит часто, и, вероятно, методы по кругу выгружаются из Code Cache, в следующей итерации JIT-компиляции вновь попадают обратно, после снова удаляются etc.Но как это возможно? Почему работает агрессивная стратегия очистки? По умолчанию она должна запускаться в тот момент, когда свободного места в Code Cache менее 10%, в нашем случаем только при достижении 65 мегабайт, но мы видим, что она происходит и при 30-35 мегабайтах занятой памяти.Для сравнения, график с запущенной Java 15 выглядит иначе:
Java 15Зубчатая структура отсутствует, есть плавный рост, затем очистка и снова рост. Разгадка где-то рядом.Утечка не утечкаТак как работой Code Cache управляет jvm, мы отправились искать ответы в исходниках openJDK, сравнивая версии Java 14 и Java 15. В процессе поисков мы обнаружили интересный баг. Там сказано, что агрессивная очистка Code Cache работает неправильно с того момента, как ее внедрили в Java 9. Вместо старта агрессивной очистки при 10% свободного места, она вызывалась при 90% свободного места, то есть почти всегда. Другими словами, оставляя опцию StartAggressiveSweepingAt = 10, на деле мы оставляли StartAggressiveSweepingAt = 90. Баг был исправлен 3 июля 2020 года. А все дело было в одной строчке:
Этот фикс вошел во все версии Java после 9-ки. Но почему тогда его нет в нашей Java 14? Оказывается, наш docker-образ  Java 14 был собран 15 апреля 2020 года, и тогда становится понятно, почему фикс туда не вошел:
Так значит и утечки нативной памяти в Code Cache нет? Просто всё время очистка работала неправильно, впустую потребляя ресурсы cpu. Понаблюдав еще несколько дней за сервисом на Java 15, мы сделали вывод, что так и есть. Общий график нативной памяти вышел на плато и перестал показывать тренд к росту:
скачок на графике - это переход на java 15Выводы
  • Как можно чаще обновляйте свою Java. Это касается не только мажорных версий, но и патчевых. Там могут содержаться важные фиксы
  • Разумное использование метрик помогает обнаружить потенциальные проблемы и аномалии
  • Переходите на Java 15, оно того стоит. Вот тут список всех фич, которые появились в пятнашке
  • Если вы используете Java 8, то у вас проблемы агрессивной очистки Code Cache нет, за отсутствием этого функционала как такового. Однако существует риск, что Code Cache может переполниться и JIT-компиляция будет принудительно отключена

===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_vysokaja_proizvoditelnost (Высокая производительность), #_java, #_java15, #_java, #_jmm, #_highload, #_jvm, #_openjdk, #_blog_kompanii_headhunter (
Блог компании HeadHunter
)
, #_vysokaja_proizvoditelnost (
Высокая производительность
)
, #_java
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 08:33
Часовой пояс: UTC + 5