[Перевод] Виды компиляции в JVM: сеанс черной магии с разоблачением
Всем привет!
Сегодня вашему вниманию предлагается перевод статьи, в котором на примерах разобраны варианты компиляции в JVM. Особое внимание уделено AOT-компиляции, поддерживаемой в Java 9 и выше.
Приятного чтения!
Полагаю, любой, кому доводилось программировать на Java, слышал о мгновенной компиляции (JIT), а, возможно, и о компиляции перед выполнением (AOT). Кроме того, не приходится объяснять, что такое «интерпретируемые» языки. В этой статье будет рассказано, каким образом все эти возможности реализованы в виртуальной машине Java, JVM.
Вероятно, вы в курсе, что, программируя на языке Java, требуется запускать компилятор (при помощи программы «javac»), собирающий исходный код Java (файлы .java) в байт-код Java (файлы .class). Байт-код Java представляет собой промежуточный язык. Он называется «промежуточным», поскольку не понятен реальному вычислительному устройству (CPU) и не может выполняться компьютером и, таким образом, представляет собой переходную форму между исходным кодом и «нативным» машинным кодом, исполняемым в процессоре.
Чтобы байт-код Java выполнял какую-либо конкретную работу, есть 3 возможности заставить его это сделать:
- Непосредственно выполнить промежуточный код. Лучше и правильнее сказать, что его нужно «интерпретировать». В JVM есть интерпретатор языка Java. Как вы знаете, для работы JVM нужно запустить программу «java».
- Непосредственно перед выполнением промежуточного кода скомпилировать его в нативный код и заставить CPU выполнить этот свежеиспеченный нативный код. Таким образом, компиляция происходит прямо перед выполнением (Just in Time) и называется «динамической».
- 3Самым первым делом, еще до запуска программы, промежуточный код переводится в нативный и прогнать его через CPU с начала до конца. Такая компиляция производится перед выполнением и называется AoT (Ahead of Time).
Итак, (1) — это работа интерпретатора, (2) — результат JIT-компиляции и (3) — результат AOT-компиляции.
Ради полноты картины упомяну, что существует и четвертый подход — напрямую интерпретировать исходный код, но в Java так не принято. Так делается, например, в Python.
Теперь давайте разберемся, как «java» работает в качестве (1) интерпретатора (2) JIT-компилятора и/или (3) AOT-компилятора — и когда.
Если коротко — как правило, «java» делает и (1), и (2). Начиная с Java 9 возможен и третий вариант.
Вот наш класс Test
, который будет использоваться в дальнейших примерах.
public class Test {
public int f() throws Exception {
int a = 5;
return a;
}
public static void main(String[] args) throws Exception {
for (int i = 1; i <= 10; i++) {
System.out.println("call " + Integer.valueOf(i));
long a = System.nanoTime();
new Test().f();
long b = System.nanoTime();
System.out.println("elapsed= " + (b-a));
}
}
}
Как видите, здесь есть метод main
, инстанцирующий объект Test
и циклически вызывающий функцию f
10 раз подряд. Функция f
почти ничего не делает.
Итак, если скомпилировать и запустить вышеприведенный код, то вывод будет вполне ожидаемым (конечно, значения истекшего времени у вас получатся другими):
call 1
elapsed= 5373
call 2
elapsed= 913
call 3
elapsed= 654
call 4
elapsed= 623
call 5
elapsed= 680
call 6
elapsed= 710
call 7
elapsed= 728
call 8
elapsed= 699
call 9
elapsed= 853
call 10
elapsed= 645
А теперь вопрос: является ли этот вывод результатом работы «java» как интерпретатора, то есть, вариант (1), «java» как JIT-компилятора, то есть, вариант (2) либо он каким-то образом связан с AOT-компиляцией, то есть, вариант (3)? В этой статье я собираюсь найти верные ответы на все эти вопросы.
Первый ответ, который хочется дать — скорее всего, здесь имеет место только (1). Я говорю «скорее всего», так как не знаю, не установлена ли здесь какая-либо переменная окружения, которая бы изменяла опции JVM, заданные по умолчанию. Если ничего лишнего не установлено, и именно так «java» работает по умолчанию, то здесь мы 100% наблюдаем именно вариант (1), то есть, код полностью интерпретируемый. Я в этом уверен, так как:
- Согласно документации по «java», опция
-XX:CompileThreshold=invocations
запускается с заданным по умолчанию показателемinvocations=1500
на клиентской JVM (подробнее о клиентской JVM написано ниже). Поскольку я запускаю ее всего 10 раз, а 10 < 1500, о динамической компиляции здесь речь не идет. Как правило, в этой опции командной строки задается, сколько раз (максимум) функция должна быть интерпретирована, прежде чем настанет этап динамической компиляции. Подробнее я остановлюсь на этом ниже. - На самом деле, я запускал этот код с диагностическими флагами, поэтому знаю, подвергался ли он динамической компиляции. Также поясню этот момент чуть ниже.
Обратите внимание: JVM может работать в клиентском или серверном режиме, и опции, задаваемые по умолчанию в первом и во втором случае, будут разными. Как правило, решение о режиме запуска принимается автоматически, в зависимости от окружения или компьютера, где была запущена JVM. Здесь и далее я буду при всех запусках указывать опцию –client
, чтобы не сомневаться, что программа выполняется в клиентском режиме. Эта опция никак не повлияет на аспекты, которые я хочу продемонстрировать в этом посте.
Если запустить «java» с опцией -XX:PrintCompilation
, программа выведет строку, когда функция будет динамически скомпилирована. Не забывайте, что JIT-компиляция выполняется для каждой функции отдельно, некоторые функции в классе могут остаться в виде байт-кода (то есть, не скомпилированными), а другие — уже прошедшими JIT-компиляцию, то есть, готовыми к непосредственному выполнению в процессоре.
Ниже я также добавляю опцию -Xbatch
. Опция -Xbatch
нужна лишь для того, чтобы вывод выглядел более презентабельно; в противном случае JIT-компиляция протекает конкурентно (вместе с интерпретацией), а вывод после компиляции может иногда странно выглядеть во время выполнения (из-за -XX:PrintCompilation
). Однако, опция –Xbatch
отключает фоновую компиляцию, поэтому, перед выполнением JIT-компиляции выполнение нашей программы будет остановлено.
(Ради удобочитаемости я буду писать каждую опцию с новой строки)
$ java -client -Xbatch
-XX:+PrintCompilation
Test
Я не буду вставлять здесь вывод этой команды, поскольку по умолчанию JVM компилирует множество внутренних функций (относящихся, например, к пакетам java, sun, jdk), так что вывод получится очень длинным — так, у меня на экране на внутренние функции приходится 274 строки, а еще несколько — на сам вывод программы). Чтобы это исследование получилось проще, я отменю JIT-компиляцию для внутренних классов или выборочно включу ее только для моего метода (Test.f
). Для этого нужно указать еще одну опцию, -XX:CompileCommand
. Можно указать много команд (компиляции), поэтому было бы проще вынести их в отдельный файл. К счастью, у нас есть и опция -XX:CompileCommandFile
. Итак, переходим к созданию файла. Я назову его hotspot_compiler
по причине, которую вскоре поясню, и напишу следующее:
quiet
exclude java/* *
exclude jdk/* *
exclude sun/* *
В данном случае должно быть совершенно понятно, что мы исключаем все функции (последняя *) во всех классах из всех пакетов, которые начинаются с java, jdk и sun (имена пакетов разделяются символом /, и можно использовать *). Команда quiet
приказывает JVM не писать ничего об исключенных классах, поэтому в консоль будут выведены лишь те, которые сейчас скомпилируются. Итак, я запускаю:
java -client -Xbatch
-XX:+PrintCompilation
-XX:CompileCommandFile=hotspot_compiler
Test
Прежде чем рассказать вам о выводе этой команды, напомню, что я назвал этот файл hotspot_compiler
, поскольку создается впечатление (я не проверял), что в Oracle JDK имя .hotspot_compiler
по умолчанию задается для файла с командами компилятора.
Итак, вывод:
many lines like this 111 1 n 0 java.lang.invoke.MethodHandle::linkToStatic(LLLLLL)L (native) (static)
call 1
some more lines like this 161 48 n 0 java.lang.invoke.MethodHandle::linkToStatic(ILIJL)I (native) (static)
elapsed= 7558
call 2
elapsed= 1532
call 3
elapsed= 920
call 4
elapsed= 732
call 5
elapsed= 774
call 6
elapsed= 815
call 7
elapsed= 767
call 8
elapsed= 765
call 9
elapsed= 757
call 10
elapsed= 868
Во-первых, я не знаю, почему до сих пор компилируются некоторые методы java.lang.invoke.MethodHandler.
Наверное, какие-то вещи отключить просто нельзя. Как пойму в чем дело — обновлю этот пост. Однако, как видите, все остальные шаги компиляции (ранее их было 274 строки) теперь исчезли. В дальнейших примерах я также уберу из вывода логи компиляции java.lang.invoke.MethodHandler
.
Посмотрим, к чему же мы пришли. Теперь у нас есть простой код, где мы запускаем нашу функцию 10 раз. Ранее я упоминал, что эта функция интерпретируется, а не компилируется, поскольку так указано в документации, а теперь мы видим ее в логах (при этом, не видим в логах компиляции, и это означает, что JIT-компиляции она не подвергается). Отлично, вы только что видели инструмент «java» в действии, интерпретирующий и только интерпретирующий нашу функцию в 100% случаев. Итак, можем поставить галочку, что с вариантом (1) разобрались. Переходим к (2), динамической компиляции.
Согласно документации, можно прогнать функцию 1500 раз и убедиться, что JIT-компиляция действительно происходит. Однако, также можно использовать вариант вызова -XX:CompileThreshold=invocations
, установив вместо 1500 нужное нам значение. Давайте укажем здесь 5. Это означает, что мы ожидаем следующего: после 5 «интерпретаций» нашей функции f JVM должна скомпилировать метод, а затем запустить скомпилированную версию.
java -client -Xbatch
-XX:+PrintCompilation
-XX:CompileCommandFile=hotspot_compiler
-XX:CompileThreshold=5
Test
Если вы запустили эту команду, то, возможно, обратили внимание, что по сравнению с вышеприведенным примером ничего не изменилось. То есть, компиляция до сих пор не происходит. Оказывается, согласно документации, -XX:CompileThreshold
работает лишь при отключенной TieredCompilation
, которая действует по умолчанию. Отключается она так: -XX:-TieredCompilation
. Многоуровневая компиляция (Tiered Compilation) — это возможность, введенная в Java 7, для улучшения как запуска, так и крейсерской скорости JVM. В контексте этого поста она не важна, поэтому смело ее отключаем. Давайте теперь снова запустим эту команду:
java -client -Xbatch
-XX:+PrintCompilation
-XX:CompileCommandFile=hotspot_compiler
-XX:CompileThreshold=5
-XX:-TieredCompilation
Test
Вот вывод (напоминаю, у меня пропущены строки, касающиеся java.lang.invoke.MethodHandle
):
call 1
elapsed= 9411
call 2
elapsed= 1291
call 3
elapsed= 862
call 4
elapsed= 1023
call 5
227 56 b Test::
228 57 b Test::f (4 bytes)
elapsed= 1051739
call 6
elapsed= 18516
call 7
elapsed= 940
call 8
elapsed= 769
call 9
elapsed= 855
call 10
elapsed= 838
Приветствуем (hello!) динамически скомпилированную функцию Test.f или Test::
сразу после вызова номер 5, ведь я задал для CompileThreshold значение 5. JVM интерпретирует функцию 5 раз, затем компилирует ее и, наконец, запускает скомпилированную версию. Поскольку функция скомпилирована, она должна выполняться быстрее, но здесь мы этого проверить не можем, так как эта функция ничего не делает. Думаю, это хорошая тема для отдельного поста.
Как вы уже, вероятно, догадались, здесь компилируется и другая функция, а именно Test::
, представляющая собой конструктор класса Test
. Поскольку код вызывает конструктор (новый Test()
) всякий раз при вызове f
он компилируется одновременно с функцией f
, ровно через 5 вызовов.
В принципе, на этом можно закончить обсуждение варианта (2), JIT-компиляцию. Как видите, в данном случае функция сначала интерпретируется JVM, затем динамически компилируется после пятикратной интерпретации. Я хотел бы добавить последнюю деталь относительно JIT-компиляции, а именно, упомянуть об опции -XX:+PrintAssembly
. Как понятно из названия, она выводит в консоль скомпилированную версию функции (скомпилированная версия=нативный машинный код=ассемблерный код). Однако, это сработает, лишь если в библиотечном пути есть дизассемблер. Полагаю, дизассемблер может отличаться в разных JVMs, но в данном случае мы имеем дело с hsdis — дизассемблером для openjdk. Исходный код библиотеки hsdis или ее двоичный файл можно взять в разных местах. В данном случае я скомпилировал этот файл и положил hsdis-amd64.so
в JAVA_HOME/lib/server
.
Итак, теперь мы можем выполнить эту команду. Но прежде должен добавить, что для запуска -XX:+PrintAssembly
также необходимо добавить опцию -XX:+UnlockDiagnosticVMOptions
, и она должна следовать до опции PrintAssembly
. Если этого не сделать, то JVM выдаст вам предупреждение о неправильном использовании опции PrintAssembly
. Давайте запустим этот код:
java -client -Xbatch
-XX:+PrintCompilation
-XX:CompileCommandFile=hotspot_compiler
-XX:CompileThreshold=5
-XX:-TieredCompilation
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintAssembly
Test
Вывод получится длинным, и там будут строки вроде:
0x00007f4b7cab1120: mov 0x8(%rsi),%r10d
0x00007f4b7cab1124: shl $0x3,%r10
0x00007f4b7cab1128: cmp %r10,%rax
Как видите, соответствующие функции компилируются в нативный машинный код.
Наконец, обсудим вариант 3, AOT. Компиляция перед выполнением, AOT, была недоступна в Java до версии 9.
В JDK 9 появился новый инструмент, jaotc — как понятно из названия, это AOT-компилятор для Java. Идея такова: запускаем компилятор Java «javac», потом AOT-компилятор для Java «jaotc», после чего запускаем JVM «java» как обычно. JVM в обычном порядке выполняет интерпретацию и JIT-компиляцию. Однако, если в функции есть AOT-скомпилированный код, она прямо его и использует, а не прибегает к интерпретации или JIT-компиляции. Поясню: вы не обязаны запускать AOT-компилятор, он опционален, а если вы им и воспользуетесь — то можете скомпилировать им до выполнения лишь те классы, которые хотите.
Давайте соберем библиотеку, состоящую из AOT-скомпилированной версии Test::f
. Не забывайте: чтобы сделать это самостоятельно, вам понадобится JDK 9 в сборке 150+.
jaotc --output=libTest.so Test.class
В результате сгенерируется libTest.so
, библиотека, содержащая AOT-скомпилированный нативный код функций, входящих в класс Test
. Можете просмотреть символы, определенные в этой библиотеке:
nm libTest.so
В нашем выводе, среди прочего, будет:
0000000000002120 t Test.f()I
00000000000021a0 t Test.
00000000000020a0 t Test.main([Ljava/lang/String;)V
Итак, все наши функции, конструктор, f
, статический метод main
присутствуют в библиотеке libTest.so
.
Как и в случае с соответствующей опцией «java», в данном случае опцию можно сопровождать файлом, для этого существует опция –compile-commands к jaotc. В JEP 295 приводятся соответствующие примеры, которые я не буду здесь показывать.
Давайте теперь запустим «java» и посмотрим, будут ли использоваться AOT-скомпилированные методы. Если запустить «java» как и раньше, то библиотека AOT использоваться не будет, и это неудивительно. Для использования этой новой возможности предусмотрена опция -XX:AOTLibrary
, которую потребуется указать:
java -XX:AOTLibrary=./libTest.so Test
Можно задать несколько AOT-библиотек, разделенных запятыми.
Вывод этой команды точно такой же, как при запуске «java» без AOTLibrary
, поскольку поведение программы Test ничуть не изменилось. Чтобы проверить, используются ли AOT-скомпилированные функции, можно добавить еще одну новую опцию, -XX:+PrintAOT
.
java
-XX:AOTLibrary=./libTest.so
-XX:+PrintAOT
Test
Перед выводом программы Test
эта команда показывает следующее:
9 1 loaded ./libTest.so aot library
99 1 aot[ 1] Test.main([Ljava/lang/String;)V
99 2 aot[ 1] Test.f()I
99 3 aot[ 1] Test.
Как и планировалось, загружается библиотека AOT, и используются AOT-скомпилированные функции.
Если вам интересно, можете запустить следующую команду и проверить, происходит ли JIT-компиляция.
java -client -Xbatch
-XX:+PrintCompilation
-XX:CompileCommandFile=hotspot_compiler
-XX:CompileThreshold=5
-XX:-TieredCompilation
-XX:AOTLibrary=./libTest.so
-XX:+PrintAOT
Test
Как и предполагалось, JIT-компиляции не происходит, поскольку методы в классе Test скомпилированы до выполнения и предоставлены в виде библиотеки.
Возможен вопрос: если мы предоставляем нативный код функций, то как JVM определяет, не является ли нативный код устаревшим/несвежим? В качестве заключительного примера давайте модифицируем функцию f
и зададим для a значение 6.
public int f() throws Exception {
int a = 6;
return a;
}
Я сделал этого лишь для того, чтобы изменить файл класса. Теперь мы заставим «javac» скомпилировать и запустить ту же команду, что и выше.
javac Test.java
java -client -Xbatch
-XX:+PrintCompilation
-XX:CompileCommandFile=hotspot_compiler
-XX:CompileThreshold=5
-XX:-TieredCompilation
-XX:AOTLibrary=./libTest.so
-XX:+PrintAOT
Test
Как видите, я не запускал «jaotc» после «javac», поэтому код из библиотеки AOT сейчас старый и некорректный, а у функции f
значение a=5.
Вывод команды «java» выше демонстрирует:
228 56 b Test::
229 57 b Test::f (5 bytes)
Это означает, что функции в данном случае были динамически скомпилированы, поэтому код, полученный в результате AOT-компиляции, не использовался. Итак, обнаружено изменение в файле класса. Когда компиляция выполняется при помощи «javac», ее отпечаток заносится в класс, а отпечаток класса также хранится в библиотеке AOT. Поскольку новый отпечаток класса отличается от того, что сохранен в библиотеке AOT, нативный код, скомпилированный заранее (AOT) не использовался. Вот и все, что я хотел рассказать о последнем варианте компиляции, до выполнения.
В этой статье я попытался объяснить и проиллюстрировать на простых реалистичных примерах, как JVM выполняет код Java: интерпретируя его, компилируя динамически (JIT) или заранее (AOT) — причем, последняя возможность появилась только в JDK 9. Надеюсь, вы узнали что-то новое.