Если вы читаете это, вы, скорее всего, программировали на Java. Вы пишете свой код в файле .java и запускаете в нем команду javac. Ваш код компилируется в файлы .class, которые вы затем запускаете с помощью команды java. Красиво и свежо. Однако здесь присутствует целая магия. Мы часто склонны игнорировать волшебника, эту настоящую рабочую лошадку — виртуальную машину Java.

Скиньте это

Некоторые языки, такие как C, C++ и Haskell, являются компилируемыми языками, т. е. компилятор преобразует код в исполняемые файлы. Компиляция и выполнение — разные фазы. Например, компилятор gcc преобразует программы C в инструкции машинного уровня и сохраняет их в исполняемый файл. Такие языки, как Python, Perl и JavaScript, являются интерпретируемыми языками, т. е. интерпретатор берет высокоуровневые инструкции и выполняет их, переводя на лету. Нет фазы «интерпретации», аналогичной фазе компиляции, есть только код и его выполнение.

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

# test.java file contains a test class with code to print "Hello"
$ javac test.java    # Compilation
$ ls
test.java test.class
$ java test          # Execution
Hello

Код высокого уровня в файле .java компилируется в промежуточный байт-код с помощью команды javac. Этот байт-код организован в виде .class файлов. Затем байт-код интерпретируется JVM во время выполнения, т.е. каждый раз, когда вы вызываете команду java для фактического запуска кода,

Небольшое отступление: одним из самых больших преимуществ процесса компиляции Java является обеспечиваемая им переносимость. В отличие от компилятора C, который выводит исполняемый файл для конкретной машины, компилятор Java выводит байт-код, специфичный для JVM. Таким образом, пока у вас работает соответствующая JVM, вы можете использовать один и тот же байт-код (Практический вариант использования: кластер машин с различным оборудованием/ОС. Вы можете просто запустить одну и ту же JVM на всех вместо отдельной компиляции для всех).

Оттягивая несколько слоев

Давайте углубимся в то, как выполняется байт-код. В конце концов, модель компиляции и выполнения Java должна была максимально использовать как компиляцию, так и интерпретацию. Имея это в виду, у JVM есть 3 варианта на выбор:

  1. Интерпретация байт-кода — выполнять инструкции построчно во время выполнения
  2. Преобразование байт-кода в машинные инструкции во время выполнения (точно в срок)
  3. Преобразование байт-кода в собственные машинные инструкции перед выполнением (заблаговременно)

JVM действительно может делать все 3 из них. Итак, то, что мы сказали ранее об интерпретации байт-кода, не совсем верно. Это решение должно быть принято, скорее всего, во время казни.

Небольшое отступление: разница между своевременной (JIT) и опережающей (AOT) компиляцией нетривиальна. Компилятор AOT имеет много времени для выполнения расширенных оптимизаций, в то время как компилятор JIT лучше знает среду выполнения. Простым примером может быть то, что JIT-компилятор знает тип виртуальных функций во время выполнения и может соответствующим образом оптимизировать.

В зеркало

Если вы запустите java -version в командной строке, вы увидите что-то похожее на изображение выше. JDK 8 использует HotSpot JVM. Мы рассматриваем JDK 8, так как это одна из наиболее широко используемых версий JDK. Более новые версии, начиная с JDK 9, также имеют компилятор AOT. Однако пока мы будем придерживаться JDK 8 и HotSpot.

HotSpot запускается холодным, то есть начинает с интерпретации байт-кода. Компилятор JIT работает в фоновом режиме вместе с интерпретатором. По мере выполнения кода он идентифицирует горячие точки (в честь которых он назван), которые затем пытается устранить с помощью JIT-оптимизации. Эти горячие точки в основном включают в себя один и тот же код, интерпретируемый несколько раз. Эта избыточность может нам дорого обойтись (в чем мы позже убедимся, отключив JIT-компилятор). Компилятор JIT также может сократить свою оптимизацию (деоптимизацию), когда указанные горячие точки уже не так горячи. Обратите внимание, что компилятор AOT не может выполнять эту оптимизацию "на лету" или онлайн.

Сочные вещи

Вот фрагмент кода (Источники: GraalVM Docs), который принимает предложение в качестве входных данных и подсчитывает количество заглавных букв миллион раз, а сам повторяет этот цикл. Каждый раз он выводит время, затраченное на этот конкретный цикл из миллионов итераций. Хотя это никоим образом не считается большой рабочей нагрузкой, она достаточно велика, чтобы сгладить любые нежелательные несоответствия.

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

На самом деле мы можем посмотреть на JIT-компиляцию в действии, используя параметр -XX:+PrintCompilation с командой java. Этот параметр печатает журнал каждый раз, когда вызывается JIT-компилятор. Давайте посмотрим, как выглядит вывод -

Поскольку вывод довольно длинный, я его обрезал. Вы можете увидеть две соответствующие части здесь. На первом изображении мы видим, как JIT-компилятор компилирует некоторые внутренние (статические?) методы в собственные машинные инструкции. Вы можете найти некоторую информацию о том, как читать вывод в этом сообщении в блоге. На втором изображении мы видим вывод (из операторов печати), чередующийся с операторами журнала JIT-компилятора. Мы видим, что некоторые вызовы методов делаются зомби (пример деоптимизации).

Давайте теперь посмотрим, что произошло бы, если бы у нас не было JIT-компилятора, а запускался байт-код только на интерпретаторе. Для этого запускаем команду java с командой -Xint. Установка этого параметра заставляет JVM запускать каждую функцию в интерпретаторе. Вот как выглядит вывод

Я установил параметр -XX:+PrintCompilation только для того, чтобы убедиться, что мы знаем, работает ли что-нибудь на JIT-компиляторе. Разница в выполнении разительна — если предыдущий запуск занимал в среднем 350–400 мс и всего 3 с, то этот запуск занимает в среднем 17–18 с и всего 180 с.

Это показывает нам, что много времени тратится на выполнение избыточного неоптимизированного кода. Более того, внутренние функции, которые вначале компилировались JIT-компилятором, теперь выполняются интерпретатором, возможно, более одного раза.

В заключении

JVM — это очень надежное программное обеспечение, которое произвело революцию в процессе компиляции. Изучение его внутренностей раскрывает богатый срез процесса компиляции и оптимизации. В этой статье я попытался выделить два режима компиляции/выполнения, их взаимодействие и влияние, которое оно оказывает на производительность.