Java HotSpot JIT компилятор — устройство, мониторинг и настройка (часть 2)
В предыдущей статье мы рассмотрели устройство JIT компилятора и способы мониторинга его работы. В этой статье мы рассмотрим счетчики, которые JVM использует для принятия решения о необходимости компиляции кода, потоки компиляции, оптимизации, выполняемые JVM при компиляции, а также что такое деоптимизация кода.
Счетчики вызовов методов и итераций циклов
Главным фактором влияющим на решение JVM о компиляции какого-либо кода является частота его исполнения. Решение принимается на основе двух счетчиков: счетчика количества вызовов метода и счетчика количества итераций циклов в методе.
Когда JVM исполняет какой-либо метод, она проверяет значения этих двух счетчиков, и принимает решение о необходимости его компиляции. У этого типа компиляции нет официального названия, но часто его называют стандартной компиляцией.
Аналогично, после каждой итерации цикла проверяется значение счетчика цикла и принимается решение о необходимости его компиляции.
При выключенной многоуровневой компиляции стандартная компиляция управляется параметром -XX:CompileThreshold. Значением по умолчанию является 10000. Несмотря на то, что параметр всего один, превышение порога определяется суммой значений двух счетчиков. В настоящее время этот флаг ни на что не влияет (многоуровневая компиляция включена по умолчанию), но знать о нем полезно, ведь существует довольно много унаследованных систем.
Ранее уменьшением значения этого параметра добивались ускорения старта приложения при использовании серверного компилятора, поскольку это приводило к более ранней компиляции кода. Также уменьшение его значения могло способствовать компиляции методов, которые иначе никогда бы не скомпилировались.
Последнее утверждение довольно интересно. Ведь если программа исполняется бесконечно, не должен ли весь ее код в конце концов скомпилироваться? На самом деле не должен, поскольку значения счетчиков не только увеличиваются при каждом вызове метода или итерации цикла, но и периодически уменьшаются. Таким образом, они являются отражением текущего «нагрева» метода или цикла.
Получается, что до внедрения многоуровневой компиляции методы, выполнявшиеся довольно часто, но недостаточно часто, чтобы превысить порог, никогда бы не были скомпилированы. В настоящее время такие методы будут скомпилированы компилятором C1, хотя, возможно, их производительность была бы выше, будь они скомпилированы компилятором C2. При желании можно поиграть параметрами -XX:Tier3InvocationThreshold (значение по умолчанию 200) и -XX:Tier4InvocationThreshold (значение по умолчанию 5000), но вряд ли в этом есть большой практический смысл. Такие же параметры (-XX:TierXBackEdgeThreshold) существуют и для задания пороговых значений счетчиков циклов.
Потоки компиляции
Компиляторы C1 и C2 имеют собственные очереди, каждая из которых может обрабатывается несколькими потоками. Существует специальная формула для вычисления количества потоков в зависимости от количества ядер. Некоторые значения приведены в таблице:
Ахаха, HotSpot, что ты делаешь, прекрати!
Как вы наверняка уже знаете, скоро в Питере пройдёт очередная конференция Joker. Я собираюсь сделать на ней доклад о том, как расследовать поведение JVM, кажущееся поначалу таинственным и загадочным. Этот пост — тизер, предназначенный для того, чтобы дать вам возможность понять, чего ждать от доклада.
Предположим, что к вам вдруг приходит информация о проблеме: при сборке мусора отображаются причины «Last Ditch Collection» и «No GC», и поиск в интернете не даёт ничего вразумительного. К счастью, HotSpot практически полностью собирается из OpenJDK, и потому, как минимум в теории, мы можем найти ответы на все интересующие нас вопросы прямо в исходниках. Чем мы и займёмся!
Last Ditch Collection
Вооружившись исходным кодом OpenJDK, поищем интересующую нас строку:
Похоже, что это оно! Почитав метод, в котором находится вызов, и в особенности — его комментарии, мы довольно быстро понимаем, что происходит.
Звучит правдоподобно, но стоит научиться стабильно это воспроизводить. Если верить написанному выше, то при активном замусоривании metaspace мы должны увидеть как минимум одну сборку с причиной «Last ditch collection»:
Если это запустить с параметрами по умолчанию, то ждать придётся довольно долго. Но мы можем уменьшить размер metaspace и получить результат быстрее:
В большинстве случаев мы увидим более, чем одну сборку с причиной «last ditch collection». Это вполне ожидаемо, поскольку иначе бы от них не было толку, и вряд ли бы эту фичу вообще реализовали.
Кстати, здесь мы смотрели на исходники Java 9, но логика last ditch collection не менялась очень давно, так что тут беспокоиться не о чем.
No GC
Перейдём к «No GC». Капитан подсказывает нам, что разработчики HotSpot вряд ли бы назвали так нормальную причину сборки мусора. Да и вообще, единственный случай, в котором мы, по логике, должны такое увидеть — это если мы успеем запустить jstat раньше, чем произойдёт хоть одна сборка мусора:
Или если он сделает проверку, когда сборщик мусора не активен:
Так что, если это было обнаружено в другой ситуации, то это, должно быть, баг. А поскольку когда мы разбирали last ditch collection, таких странностей не наблюдалось, то есть шанс того, что он уже исправлен. Проявим немного оптимизма и поищем все подходящие коммиты:
Ага! Похоже, что проблему действительно пофиксили в коммите номер 2097, ещё в феврале 2013 года. Заглянув в файл hotspot_version, мы обнаруживаем, что последняя HotSpot, в которой должна наблюдаться проблема — это 21.0-b01. У меня под рукой была Java 6 с версией HotSpot 20.45-b01:
При запуске нашего примера с котиками, немедленно получается следующий результат:
Это довольно близко, но не совсем то, что мы искали. Однако, при более детальном изучении патча становится очевидным, что нужно просто использовать другой сборщик мусора. Добавление -XX:+UseG1GC даст нам нужный результат:
Успех! Кстати, тот человек, который первым ответит, почему тут какие-то знаки вопроса, и что именно должно навести нас на использование G1, получит приглашение на Joker Unconference 😉 SerCe уже дал правильный ответ.
Послесловие
Итак, только что, на основании одного-единственного твита и используя базовые утилиты командной строки, мы ответили на не гуглящийся вопрос, нашли стародавний баг в HotSpot, смогли благодаря этому оценить сверху версию JVM, и даже узнали, какой использовался сборщик мусора.
Java HotSpot JIT компилятор — устройство, мониторинг и настройка (часть 1)
AOT и JIT компиляторы
Процессоры могут исполнять только ограниченный набор инструкций — машинный код. Для исполнения программы процессором, она должна быть представлена в виде машинного кода.
Существуют компилируемые языки программирования, такие как C и C++. Программы, написанные на этих языках, распространяются в виде машинного кода. После того, как программа написана, специальный процесс — Ahead-of-Time (AOT) компилятор, обычно называемый просто компилятором, транслирует исходный код в машинный. Машинный код предназначен для выполнения на определенной модели процессора. Процессоры с общей архитектурой могут выполнять один и тот же код. Более поздние модели процессора как правило поддерживают инструкции предыдущих моделей, но не наоборот. Например, машинный код, использующий AVX инструкции процессоров Intel Sandy Bridge не может выполняться на более старых процессорах Intel. Существуют различные способы решения этой проблемы, например, вынесение критичных частей программы в библиотеку, имеющую версии под основные модели процессора. Но часто программы просто компилируются для относительно старых моделей процессоров и не используют преимущества новых наборов инструкций.
В противоположность компилируемым языкам программирования существуют интерпретируемые языки, такие как Perl и PHP. Один и тот же исходный код при таком подходе может быть запущен на любой платформе, для которой существует интерпретатор. Минусом этого подхода является то, что интерпретируемый код работает медленнее, чем машинный код, делающий тоже самое.
Язык Java предлагает другой подход, нечто среднее между компилируемыми и интерпретируемыми языками. Приложения на языке Java компилируются в промежуточный низкоуровневый код — байт-код (bytecode).
Название байт-код было выбрано потому, что для кодирования каждой операции используется ровно один байт. В Java 10 существует около 200 операций.
Байт-код затем исполняется JVM также как и программа на интерпретируемом языке. Но поскольку байт-код имеет строго определенный формат, JVM может компилировать его в машинный код прямо во время выполнения. Естественно, старые версии JVM не смогут сгенерировать машинный код, использующий новые наборы инструкций процессоров вышедших после них. С другой стороны, для того, чтобы ускорить Java-программу, ее даже не надо перекомпилировать. Достаточно запустить ее на более новой JVM.
HotSpot JIT компилятор
Единица скомпилированного кода называется nmethod (сокращение от native method).
Многоуровневая компиляция (tiered compilation)
На самом деле в HotSpot JVM существует не один, а два компилятора: C1 и C2. Другие их названия клиентский (client) и серверный (server). Исторически C1 использовался в GUI приложениях, а C2 в серверных. Отличаются компиляторы тем, как быстро они начинают компилировать код. C1 начинает компилировать код быстрее, в то время как C2 может генерировать более оптимизированный код.
Существует 5 уровней компиляции:
| Последовательность | Описание |
|---|---|
| 0-3-4 | Интерпретатор, уровень 3, уровень 4. Наиболее частый случай. |
| 0-2-3-4 | Случай, когда очередь уровня 4 (C2) переполнена. Код быстро компилируется на уровне 2. Как только профилирование этого кода завершится, он будет скомпилирован на уровне 3 и, наконец, на уровне 4. |
| 0-2-4 | Случай, когда очередь уровня 3 переполнена. Код может быть готов к компилированию на уровне 4 все еще ожидая своей очереди на уровне 3. Тогда он быстро компилируется на уровне 2 и затем на уровне 4. |
| 0-3-1 | Случай простых методов. Код сначала компилируется на уровне 3, где становится понятно, что метод очень простой и уровень 4 не сможет скомпилировать его оптимальней. Код компилируется на уровне 1. |
| 0-4 | Многоуровневая компиляция выключена. |
Code cache
Машинный код, скомпилированный JIT компилятором, хранится в области памяти называемой code cache. В ней также хранится машинный код самой виртуальной машины, например, код интерпретатора. Размер этой области памяти ограничен, и когда она заполняется, компиляция прекращается. В этом случае часть «горячих» методов так и продолжит выполняться интерпретатором. В случае переполнения JVM выводит следующее сообщение:
Другой способ узнать о переполнении этой области памяти — включить логирование работы компилятора (как это сделать обсуждается ниже).
Code cache настраивается также как и другие области памяти в JVM. Первоначальный размер задаётся параметром -XX:InitialCodeCacheSize. Максимальный размер задается параметром -XX:ReservedCodeCacheSize. По умолчанию начальный размер равен 2496 KB. Максимальный размер равен 48 MB при выключенной многоуровневой компиляции и 240 MB при включенной.
Начиная с Java 9 code cache разделен на 3 сегмента (суммарный размер по-прежнему ограничен пределами, описанными выше):
Мониторинг работы компилятора
Включить логирование процесса компиляции можно флагом -XX:+PrintCompilation (по умолчанию он выключен). При установке этого флага JVM будет выводить в стандартный поток вывода (STDOUT) сообщение каждый раз после компиляции метода или цикла. Большинство сообщений имеют следующий формат: timestamp compilation_id attributes tiered_level method_name size deopt.
Поле timestamp — это время со старта JVM.
Поле compilation_id — это внутренний ID задачи. Обычно он последовательно увеличивается в каждом сообщении, но иногда порядок может нарушаться. Это может произойти в случае, если существует несколько потоков компиляции работающих параллельно.
Поле attributes — это набор из пяти символов, несущих дополнительную информацию о скомпилированном коде. Если какой-то из атрибутов не применим, вместо него выводится пробел. Существуют следующие атрибуты:
Атрибут «b» означает, что компиляция произошла не в фоне, и не должен встречаться в современных версиях JVM.
Атрибут «n» означает, что скомпилированный метод является оберткой нативного метода.
Поле tiered_level содержит номер уровня, на котором был скомпилирован код или может быть пустым, если многоуровневая компиляция выключена.
Поле method_name содержит название скомпилированного метода или название метода, содержащего скомпилированный цикл.
Поле size содержит размер скомпилированного байт-кода, не размер полученного машинного кода. Размер указан в байтах.
Поле deopt появляется не в каждом сообщении, оно содержит название проведенной деоптимизации и может содержать такие сообщения как «made not entrant» и «made zombie».
Иногда в логе могут появиться записи вида: timestamp compile_id COMPILE SKIPPED: reason. Они означают, что при компиляции метода что-то пошло не так. Есть случаи, когда это ожидаемо:
Параметр -compiler выводит сводную информацию о работе компилятора (5003 — это ID процесса):
Эта команда также выводит количество методов, компиляция которых завершилась ошибкой и название последнего такого метода.
Планы на вторую часть
В следующей части мы рассмотрим пороговые значения счетчиков при которых JVM запускает компиляцию и как можно их поменять. Мы также рассмотрим как JVM выбирает количество потоков компилятора, как можно его поменять и в каких случаях стоит это делать. И наконец, кратко рассмотрим некоторые из оптимизаций выполняемых JIT компилятором.
Материал из Википедии — свободной энциклопедии
| HotSpot | |
|---|---|
| Тип | Java Virtual Machine |
| Разработчик | Oracle (ранее Sun Microsystems) |
| Написана на | C++ |
| Операционная система | Кроссплатформенное ПО |
| Первый выпуск | 1999 [1] |
| Последняя версия | 25.77-b03 |
| Лицензия | GNU General Public License |
| Сайт | openjdk.java.net/groups/… |
«HotSpot» — это основная виртуальная машина Java (JVM) как для клиентских, так и для серверных компьютеров, выпускаемая корпорацией «Oracle». Для повышения производительности обладает технологиями динамической компиляции JIT и адаптивной оптимизации.
История
Этимология
Эта JVM называется «HotSpot» потому что, выполняя байт-кода «Java», она ищет его «горячие» места (англ. «hot spots») — многократно выполняющиеся. Поиск направлен на оптимизацию их выполнения: выделение им больших ресурсов вместе с уменьшением непроизводительных затрат для выполнения менее ресурсоёмкого кода.
Перспективы
Особенности
Client-версия виртуальной машины характеризуется меньшим временем запуска приложений и меньшим потреблением памяти по сравнению с Server-версией, уступая при этом последней в производительности.
JVM-флаги
Лицензия
13 ноября 2006 года виртуальная машина и JDK от Sun Microsystems были открыты [10] под лицензией GPL v2 (см. Sun’s OpenJDK Hotspot page). Этот код стал частью Java 7.
Поддерживаемые платформы
Поддерживаемые Sun Microsystems
Что касается JDK, HotSpot на данный момент поддерживается Oracle в операционных системах Microsoft Windows, Linux и Solaris. Поддержка ISAs представлена платформами IA-32, x86-64 и SPARC (только в Solaris). [11]
Порты от сторонних разработчиков
Доступны также порты сторонних разработчиков для Mac OS X и других операционных систем Unix. Поддерживается несколько различных аппаратных архитектур, включая x86, PowerPC и SPARC (только в Solaris).
Инициализация и работа интерпретатора байткода в JVM HotSpot под x86
Почти каждый Java разработчик знает, что программы, написанные на языке Java изначально компилируются в JVM-байткод и хранятся в виде class-файлов стандартизованного формата. После попадания таких class-файлов внутрь виртуальной машины и пока до них еще не успел добраться компилятор, JVM интерпретирует байткод, содержащийся в этих class-файлах. Данная статься содержит обзор принципов работы интерпретатора применительно к OpenJDK JVM HotSpot.
Окружение
Для экспериментов используется сборка крайней доступной ревизии OpenJDK JDK12 с autoconf конфигурацией
на Ubuntu 18.04/gcc 7.4.0.
—with-native-debug-symbols=internal означает, что, при сборке JDK, дебажные символы будут содержаться в самих бинарях.
—enable-debug — то, что в бинарнике будет содержаться дополнительный дебажный код.
Сборка JDK 12 в таком окружении — это не сложный процесс. Все, что мне потребовалось проделать это поставить JDK11 (для сборки JDK n требуется JDK n-1) и доставить руками необходимые библиотеки о которых сигналил autoconf. Далее выполнив команду
и немного подождав (на моем ноуте порядка 10 минут), получаем fastdebug сборку JDK 12.
В принципе вполне достаточно было бы просто установить jdk из публичных репозиториев и дополнительно доставить пакет openjdk-xx-dbg с дебажными символами, где xx- версия jdk, но fastdebug сборка предоставляет функции для отладки из gdb, которые могут облегчить жизнь в некоторых случаях. На данный момент я активно использую ps() — функция для просмотра Java-стектрейсов из gdb и pfl() — функция для анализа стек фреймов (очень удобно при отладке интерпретатора в gdb).
Для примера рассмотрим следующий gdb-скрипт
Результат запуска такого скрипта имеет вид:
Как можно видеть, в случае ps() мы просто получаем стек вызовов, в случае pfl() — полную организацию стека.
Запуск java приложения
Прежде чем перейти к рассмотрению непосредственно интерпретатора, сделаем краткий обзор действий, выполняющихся до передачи управления java-коду. Для примера возьмем программу на языке Java, которая «не делает вообще ничего»:
и попробуем разобраться в том, что происходит при запуске такого приложения:
javac Main.java && java Main
Первое что нужно сделать, чтобы ответить на этот вопрос, это найти и посмотреть на бинарник java — тот самый, который мы используем для запуска все наших JVM-приложений. В моем случае он располагается по пути
Но смотреть в итоге тут особо не на что. Это бинарник который вместе с дебажными символами занимает всего 20КБ и скомпилирован только из одного исходного файла launcher/main.c.
Все, что он делает это получает аргументы командной строки (char *argv[]), читает аргументы из переменной среды JDK_JAVA_OPTIONS, делает базовый препроцессинг и валидацию (например, нельзя добавить терминальную опцию или имя Main-класса в эту переменну среды) и вызывает функцию JLI_Launch с полученным списком аргументов.
Опреление функции JLI_Launch не содержится в бинарнике java и, если посмотреть на его прямые зависимости:
то можно заметить libjli.so которая к нему прилинкована. Данная библиотека содержит launcher interface — набор функций, которые используются java для инициализации и запуска виртуальной машины, среди которых присутствует и JLI_Launch.
После передачи управления JLI_Launch происходит ряд действий необходимых для запуска JVM такие как:
I. Загрузка символов JVM HotSpot в память и получение указателя на функцию для создания VM.
Весь код JVM HotSpot располагается в библиотеке libjvm.so. После определения абсолютного пути к libjvm.so происходит загрузка библиотеки в память и выдирание из нее указателя на функцию JNI_CreateJavaVM. Этот указатель на функцию сохраняется и в дальнейшем используется для создания и инициализации виртуальной машины.
Очевидно, что libjvm.so не прилинкована к libjli.so
II. Парсинг аргументов, переданных после препроцессинга.
Функция с говорящим названием ParseArguments разбирает аргументы, переданные из командной строки. Этот парсер аргументов определяет режим запуска приложения
III. Форк primordial потока и создание в нем VM
Но если что-то пошло не так, то делается попытка запустить JVM в main-треде «just give it a try».
Итак, будем считать, что на данный момент у нас успешно распарсились опции и создался поток для VM. После этого, только что форкнутый поток начинает создание виртуальной машины и попадает в функцию Threads::create_vm
В этой функции делается довольно большое количество черной магии инициализаций, нам интересны будут лишь некоторые из них.
Инициализация интепретатора и передача управления java-коду
Чтобы обеспечить работу данной машинерии в Threads::create_vm выполняется ряд инициализаций, от которых зависит интерпретатор:
I. Инициализация таблицы доступных байткодов
Прежде чем приступить к инициализации интерпретатора, необходимо проинициализировать таблицу используемых байткодов. Она выполняется в функции Bytecodes::initialize и представлена в виде очень удобочитаемой таблички. Ее фрагмент выглядит следующим образом:
Эти параметры в дальнейшем нужны для генерации кода шаблонов интерпретатора
II. Инициализация код кэша
Для того, чтобы сгенерить код шаблонов интерпретатора, необходимо сперва выделить под это дело память. Резервация памяти под код кэш реализована в функции с одноименным названием CodeCache::initialize(). Как можно видеть из следущего участка кода данной функции
После выполнения проверок резервируется область размером в ReservedCodeCacheSize байт. В случае, если SegmentedCodeCache оказалась выставленной, то данная область разбивается на части: JIT-скомпилированные методы, стаб рутины, и т.д.
III. Инициализация шаблонов интерпретатора
После того, как таблица байткодов и код кэш проинициализированы, можно приступать к кодогенерации шаблонов интерпретатора. Для этого интерепретатор резервирует буффер из ранее проинициализированного код кэша. На каждый этап кодогенерации из буффера будут отрезаться кодлеты — небольшие участки кода. После завершению текущей генерации, неиспользуемая под код часть кодлета освобождается и становится доступной для последующих кодогенераций.
Рассмотрим каждый из этих этапов по отдельности:
signature handler используется для подготовки аргументов для вызовов нативных методов. В данном случае генерится обощенный хэндлер, если, например у нативного метода больше 13 аргументов (В дебаггере не проверял, но судя по коду должны быть так)
VM валидирует классфайлы при инициализации, но это на случай, если аргументы на стеке не того формата который нужен или байткод о котором VM не знает. Эти стабы используются при генерации кода шаблонов для каждого из байткодов.
После вызова процедур необходимо восстановить данные стек фрейма, который был до вызова процедуры из которой делается return.
Используется при вызовах рантайма из интерепретатора.
Представлен в виде макроса в зависимости от типа метода. В общем случае выполняется подготовка интерпретируемого стек-фрейма, проверка StackOverflow, stack-banging. Для нативных методов определяется signature handler.
Для выполнения инструкции спецификация VM требует чтобы операнды находились в Operand Stack, но это не запрещает HotSpot кэшировать их в регистре. Для определения текущего состояния вершины стека используется перечисление
Каждая инструкция определяет входные и выходные состояния TosState вершины стека, и генерация шаблонов происходит в зависимости от этого состояния. Данные шаблоны инициализируются в удобочитаемой таблице шаблонов. Фрагмент этой таблицы выглядит следующим образом:
in — состояние вершины стека на момент начала исполнения инструкции
out — состояния вершины стека на момент завершения исполнения инструкции
generator — генератор шаблона машинного кода инструкции
Общий вид шаблона для всех байткодов можно описать в виде:
Если для инструкции не выставлен dispatch bit, то выполняется пролог инструкции (no-op на x86)
Если для инструкции не выставлен dispatch bit, то выполняется переход к следующей по порядку инструкции в зависимости от out состояния вершины стека, которое будет являтся in для следующей инструкции
Адрес точки входа для полученного шаблона сохраняется в глобальной таблице и его можно использовать при отладке.
В HotSpot за это отвечает следущий, относительно стремный кусок кода:
Как только данная кодогенерация завершена, интепретатор можно считать полностью проинициализированным. После интерпретатора выполняется еще много инициализаций различных подсистем JVM. Для некоторых из них требуется вызывать Java-код из кода виртуальной машины. Это реализовано с помощью стандартного механизма JavaCalls. После того как инициализация JVM полностью завершена, этот механизм используется для вызова метода main.
Пример
Для того, чтобы представлять как это все работает на практике, рассмотрим следующий относительно простой пример:
Байткод ровно такой, какой нам нужен и первое с чего придется начать — это с анализа вызова статического метода.
Генератор шаблона invokestatic ‘а для x86 находится в архитектурно-зависимой секции кода HotSpot и представлен в виде
Ну а теперь и сама инструкция iadd :
Если посмотреть в gdb на eax и edx сразу перед выполнением сложения, то можно заметить





