Мы готовились к выходу 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 раза!
Для сервиса с большим количеством инстансов каждый мегабайт памяти на счету, и, помимо её резкого роста, на графике просматривается возрастающий тренд. Кажется мы имеем дело с утечкой нативной памяти в 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 работает именно агрессивная стратегия. Дополнительно убедиться в этом нам помог тот же график, но с увеличенным масштабом:
Он имеет зубчатую структуру, которая говорит о том, что очистка происходит часто, и, вероятно, методы по кругу выгружаются из Code Cache, в следующей итерации JIT-компиляции вновь попадают обратно, после снова удаляются etc.
Но как это возможно? Почему работает агрессивная стратегия очистки? По умолчанию она должна запускаться в тот момент, когда свободного места в Code Cache менее 10%, в нашем случаем только при достижении 65 мегабайт, но мы видим, что она происходит и при 30-35 мегабайтах занятой памяти.
Для сравнения, график с запущенной 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. Это касается не только мажорных версий, но и патчевых. Там могут содержаться важные фиксы
Разумное использование метрик помогает обнаружить потенциальные проблемы и аномалии
Переходите на Java 15, оно того стоит. Вот тут список всех фич, которые появились в пятнашке
Если вы используете Java 8, то у вас проблемы агрессивной очистки Code Cache нет, за отсутствием этого функционала как такового. Однако существует риск, что Code Cache может переполниться и JIT-компиляция будет принудительно отключена
Xobotun
Используюем 11 джаву, плюс ломбок и котлин.
Вы провели крутую работу, прямо интересно было читать. Я такими знаниями ещё не обладаю. :)
Но скажите, чего ради ставить на прод 15 версию, когда она ещё не LTS, и мне кажется очевидным, что большинство болячек на ней будет в том или ином виде присутствовать? Мне видится куда выигрышней стратегия "сидеть на 11, а потом перейти на 17". Правда, на ней текстовые блоки и switch новый нельзя, да. Но стоит ли ради плюшек рисковать продом? Я не знаю.
sshikov
>стоит ли ради плюшек рисковать продом
Я вот тоже. Не могу понять, каким образом текстовые блоки могут быть поводом для миграции. Те, кому они нужны прям очень-очень, давно уже и так пишут на скале или груви, и запускаются на JDK 8.
Могут быть важные фиксы? А могут и не быть. Не проще ли прочитать release notes, там же все написано? Если вы не сталкиваетесь с проблемами на условно, 1.8.0_181, как мы, то фиксы вам в общем-то и не нужны.
Тем более смигрировать приложение 24х7 вообще дорого.
nicholasgribanov Автор
>Не проще ли прочитать release notes
Не о всех фиксах пишется в release notes. О фиксе, который описан в статье, в release notes ничего не сказано
sshikov
Ну, наверное такие бывают. Но вы же не хотите сказать, что его и в jira на JDK не было?
Я просто про другой подход к этому делу говорю — если у вас работающее приложение, 24х7, и вы не сталкивались с проблемами на текущей версии JDK/JRE, то вам ставить новую версию вообще говоря не нужно. Чинить вам нечего, а вот сломать она что-то вполне может. А если у вас есть проблемы — то вы их по нормальному, заводите в баг трекер тому же Ораклу, и ждете того релиза, в котором будут исправлены именно они (а не сотня других багов, которые у вас не проявляются).
nehaev
Переезжать и чинить придется все равно — сейчас или через n лет, когда будет следующий LTS. А вот начать экономить копейку из-за фич и оптимизаций, которые завозят в каждую промежуточную версию, лучше сейчас, чем через n лет. Вполне прагматично.
sshikov
Ну, хотя это логично но не всегда можно. И нужно. Вот прям если не лень — можете мне назвать список фич, которые были внедрены с Java 8 — и я вам отвечу, почему для меня они не актуальны. Потому что я это рассматриваю каждую версию, начиная с 9 — и ни одной фичи пока не нашел. Не, одну могу назвать — GC все время улучшается. Это можно было бы рассмотреть.
И если вы посмотрите опрос — то таких как я, на 8, еще 20%.
А если посчитать эти же проценты скажем в ядрах CPU — то я подозреваю, что один наш хадуп стоит десятка компаний поменьше.
Ну и мы не будем никогда его мигрировать на новые версии раз в полгода, как они теперь выходят. Это просто не имеет никакого смысла. Есть куча мест, где можно оптимизировать прикладной софт, и они дают прирост зачастую на порядки (потому что оптимизации в JDK это память и процессор, а мы оптимизируем на уровне база, сеть, диски и т.п. — и это обычно дает больший эффект и сразу). В нашем случае миграция — это скажем построение нового кластера, на новых железках, и новой версии хадупа.
Вот, посмотрите скажем сюда. И вы тут увидите, что поддерживается только 11. И все.
Да и у апача все примерно так же:
Ну так и куда сейчас на 15? Это просто невозможно.
nehaev
Ну джава, к счастью, не заканчивается на хадупе. Те, кто держит большие флоты в облаках для работы своих 24/7 сервисов, будут рады каждому сэкономленному метру памяти и каждой миллисекунде уменьшающегося latency. В зависимисти от масштаба, разница в стоимости виртуального железа на месяц или год может многократно покрыть стоимость перехода, который делается один раз и не очень долго.
Могу перечислить то, из-за отсутствия чего лично я вынужден придумывать многоэтажные костыли уже несколько месяцев: VarHandles (9), нормальный HttpClient на NIO (11), absolute bulk read/write для ByteBuffer (14).
sshikov
>Ну джава, к счастью, не заканчивается на хадупе.
Так я и не имел в виду, что заканчивается. Я скорее это как реальный пример, когда есть в наличии большие кластера, десятки тысяч ядер, и их просто так нельзя мигрировать, некуда. Конечно, есть и другие варианты, и с ними все может быть сильно проще.
>HttpClient на NIO (11) — а разве это нельзя реализовать самому? Или там под капотом какие-то низкоуровневые фичи? Ну в смысле — костыли это понятно, неохота, но если они возможны — то это вполне может быть дешевле миграции. Вот для меня примерно так это и обстоит. Ну то есть это — как те же текстовые блоки, хорошо конечно, что они появились — но я пишу на скале и груви, и у меня они по факту всегда были, сколько помню.
nehaev
Можно, но довольно трудозатратно. Да и зачем, если вот он уже написан. Через несколько лет, он уже будет в каждом утюге, но конкретно сегодня 30-40% людей все еще сидят на 8, к сожалению.
Это же не просто костыли, это тормозные костыли. Локи вместо CAS, массивы байтов в хипе вместо direct buffers.
sshikov
Ну как бы трудозатратно — это один раз. Хотя да, я охотно верю, что в условном спарке где-то в потрохах ускорение HTTP клиента могло бы дать ой какой хороший профит… но это всеж другая история слегка.
bigdata-dev
я проверял на винде, локально map-reduce и spark 2.4 работают на java 15. т.е. что-то делать сейчас на 8, что бы потом переделывать, смысла нет. следующий хадуп уже не запуститься на 8.
sshikov
Ну, одно дело — это локально проверить, что оно вообще запустилось. А рискнуть десятками петабайт на дисках прома — все же немного другое. Тут лучше подготовиться.
> следующий хадуп уже не запуститься на 8.
У нас текущий cloudera 5.14 (2.6). Следующий cloudera 7.1 не просто запускается, но и рекомендует 1.8 все равно. И самосборный 3.3 тоже пока на 1.8 живет, только OpenSDK вроде.
bigdata-dev
у нас миграция с cloudera 5.x на 6.x заняла более года. когда мы будем готовы запустить что-то на кластере уже java 20 будет деприкатед, а клаудера будет мертва.
sshikov
У нас 5.х -> 7.х в планах (на этот год). И я думаю с полгода оно вполне может занять (но отдельный наш проект надеюсь уложится в месяц). Некоторые команды уже смигрировали, кластер разработки функционирует — но мигрируем мы пока все равно на Java 8, чтобы минимизировать объем работ.
transcengopher
Вот вообще киллер-фича: https://openjdk.java.net/jeps/358 — доступна с Java 14.
sshikov
Хм. Вот у меня есть реальный баг в спарке, который мне мешает, вот такой. Вы хотите сказать, что достаточно запустить спарк на Java 11, и мне покажут, какая конкретно переменная? Ой что-то я не верю в это. Это вообще фича компилятора, или JVM?
Maccimo
JDK 14, не 11 и в ней эта фича по умолчанию отключена.
Если этот баг вам мешает и, судя по комментариям к багрепорту, известны способы его воспроизведения, почему бы не поправить его самому? Нужны только отладчик и желание.
Это фича JVM и про это английским по белому написано в этом JEP. Для лучшего эффекта class-файлы с проблемным кодом стоит собрать с полной отладочной информацией. Для
javac
это параметр-g
.sshikov
>Нужны только отладчик и желание.
Ну, не совсем. Спарк не настолько простая фигня, чтобы это можно было сделать скажем за 30 минут. Если это происходит в распределенной среде, уже подцепиться отладчиком не совсем тривиальная задача, потому что вы не знаете в общем случае, на каком узле все будет происходить.
>Для javac это параметр -g.
Ну т.е. компилятор любой версии, но с отладкой? Впрочем, мне это вряд ли поможет, пока что никакой хадуп не поддерживает Java 14. И это не javac, а скала (но это уже мелочи).
На самом деле есть другой способ, не сильно простой, но действенный — залезть в исходники, и вникнуть, что там происходит.
transcengopher
Если в исходниках написано
x().y().i = 99;
, то для вникания придётся ещё вникнуть во все возможные детали реализацииx
иy
(а в скале — ещё и вi
), что и когда они могут возвращать, а если это интерфейсы, то проще сразу с отладчиком влезть, чем вручную выполнять весь этот код в голове.И вот тут-то JEP 358 и должен помочь, потому что призван по крайней мере ограничить круг поиска одним конкретным местом, так как сразу будет ясно, что именно вернуло неожиданное значение. А вот так чтобы всё сразу показали — ну, серебряных пуль не бывает, но из этого никак не следует, что нет смысла даже и пытаться.
sshikov
Вы преувеличиваете.
Вот что падает. Тут вполне очевидно, что именно null. Ну или во всяком случае, подозреваемый вполне явно виден. Нужно лишь пройтись по стеку, и понять, где он там по дороге таким стал.
transcengopher
Вы упорно не хотите понимать, что фича не является серебряной пулей, несмотря на огромную полезность.
Да, в таком коде, где вся работа ведётся с единственным суперобъектом, где лежит всё — почти все случаи ошибок будут связаны именно с суперобъектом (но всё равно не все, потому что в Java
precision()
может возвращатьnull Double
, а конструкторDecimal
при этом — ожидатьdouble
).Но сводить весь язык к сугубо своим ситуациям — это путь в никуда. Вам не нужно обновляться (точнее — вы не можете, зелен виноград), потому вас никакие фичи не переубедят. Остальные обновятся.
sshikov
С чего вы взяли, что я что-то не понимаю? Я вообще не спрашивал ни о чем никого, если на то пошло, а вас тем более, кроме первого вопроса. Это не моя ситуация — это ситуация в Apache Spark, каких-то мелких 28к звездочек на гитхабе, подумаешь, ерунда какая, кому он может быть интересен.
transcengopher
Из ответов ваших я делаю такие выводы. Потому что задачей своей вы с самого начала поставили рассмотрение всех фич в контексте "почему мне это не нужно", и именно отсюда ведёте всю аргументацию.
sshikov
Очень смешно.
bigdata-dev
на жава большинство работает в серьезных канторах, соответственно не будут переделывать проекты, что бы пописать на груви или скале. а вот ради синтаксического сахарка с текстовыми блоками, var, рекордами — почему бы и нет. никто же не требует в одну ночь перевести весь проект, сервисов то много.
пока ты выявишь и углубишься в проблемы на 15, как раз и выйдет 17.
sshikov
Ну почему, у нас во вполне серьезном проекте пытались тесты начать писать на котлине. В любом проекте наверное найдется такое место, где можно провести эксперимент.
nicholasgribanov Автор
Спасибо за высокую оценку!
Вы правы, стратегия обновления с LTS на LTS релевантна, но это медаль с двумя сторонами. Обновляя версии часто неминуемо будут всплывать разного рода проблемы, о которых не говорится в release notes.
И вот здесь кажется, что использовать LTS и не обновляться выгодно. Но, когда выйдет новая LTS, пусть 17-я, вам все равно придется решать проблемы промежуточных версий, и обновление займет значительно больше времени. А так получается «есть слона по частям». Вот такой путь вас может ждать при обновлении с 11 на 17-ю. Это те проблемы, которые мы решали обновляя Java:
При переходе с 11 на 14 изменились логи gc.
При переходе с 14 на 15 проблема описана в статье.
Вам придется решать эти проблемы сразу все. Основной поинт такой: чем длиннее цепочка обновлений, тем тяжелее это обновление сделать.
sshikov
Это пока у вас нет условного хадупа, HBase, или другого крупного продукта, который не является вашим. Там вообще все будет иначе — вы не можете переписать продукт сами, как правило. И это будет совсем другая история.
Ну и вообще говоря, решить проблемы сразу все может оказаться проще. Ну вот смотрите, у нас в телеграмм чатике стенда разработки пасется скажем человек 500 разработчиков (точные цифры тут не важны, главное — что у нас много команд). Представим себе, что мы начали мигрировать с Java 8 (as is) скажем на Java 15. Мы наступили на разные грабли — но весьма вероятно, что разные команды на разные их виды. В итоге, одна команда, решив одну проблему, избавляет другие от повторения процесса. Зато сами стенды мы обновим один раз, а не четыре — т.е. тем кто это будет делать, задача упростится.
Ну т.е. это зависит от того, какова у вас структура компании. Не исключаю, что где-то вообще все эти проблемы решат централизованно, потому что это «одна из ключевых задач команды Архитектура» (кстати, поправьте на Архитектора, а то как-то смешно получилось :)
ipodman
Немного не до конца понял мысль, можете чуть подробнее описать процесс — сколько у вас стендов и как происходит обновление?
sshikov
Да тут все вполне обычно. Просто если у вас стенд разработки колхозный, на несколько команд — то вот этот вот процесс миграции Java (которая общая для всех) — он быстро не происходит. Нужно команды синхронизировать, пока последняя не проверила — дальше ехать нет особого смысла.
ipodman
У нас больше сотни стендов, на которые происходит раскатка ansible-скриптами, при этом каждый сервис на стенде крутится в своем docker-контейнере со своей java. Т.е «перенести сервис на java» это не раскатать на весь стенд новую java и смотреть как все страдают, а обновить нескольких сервисах, словить ошибки и поправить. Получается как раз
sshikov
Так это у вас каждое приложение в контейнере. В хадупе нельзя так, по крайней мере в 2.6. Да по-моему и в 3 тоже нельзя. Ваше приложение запустят на какой-то неизвестной вам заранее узел кластера, поэтому все JVM должны быть одинаковые. И на одной версии, поэтому же.
kruftik
хадуп головного мозга, извините
так уже не мало раз упомянули, что у вас хадуп и вам от него больно, что все окружающие искреннее вам сочувствуют. НО, в мире немало и других, менее неприятных и тяжелых в обращении вещей, где попроще, посмотрите, мб вам понравится и не придется себя мучать.
nicholasgribanov Автор
«Архитектура» — это название команды. Тут все ровно :)
nicholasgribanov Автор
Кавычки добавил, спасибо)