Почти каждый Java разработчик знает, что программы, написанные на языке Java изначально компилируются в JVM-байткод и хранятся в виде class-файлов стандартизованного формата. После попадания таких class-файлов внутрь виртуальной машины и пока до них еще не успел добраться компилятор, JVM интерпретирует байткод, содержащийся в этих class-файлах. Данная статься содержит обзор принципов работы интерпретатора применительно к OpenJDK JVM HotSpot.
Содержание статьи:
- Окружение
- Запуск java приложения
- Инициализация интепретатора и перадача управления java-коду
- Пример
Окружение
Для экспериментов используется сборка крайней доступной ревизии OpenJDK JDK12 с autoconf конфигурацией
--enable-debug --with-native-debug-symbols=internal
на Ubuntu 18.04/gcc 7.4.0.
--with-native-debug-symbols=internal
означает, что, при сборке JDK, дебажные символы будут содержаться в самих бинарях.
--enable-debug
— то, что в бинарнике будет содержаться дополнительный дебажный код.
Сборка JDK 12 в таком окружении — это не сложный процесс. Все, что мне потребовалось проделать это поставить JDK11 (для сборки JDK n требуется JDK n-1) и доставить руками необходимые библиотеки о которых сигналил autoconf. Далее выполнив команду
bash configure --enable-debug --with-native-debug-symbols=internal && make CONF=fastdebug images
и немного подождав (на моем ноуте порядка 10 минут), получаем fastdebug сборку JDK 12.
В принципе вполне достаточно было бы просто установить jdk из публичных репозиториев и дополнительно доставить пакет openjdk-xx-dbg с дебажными символами, где xx- версия jdk, но fastdebug сборка предоставляет функции для отладки из gdb, которые могут облегчить жизнь в некоторых случаях. На данный момент я активно использую ps() — функция для просмотра Java-стектрейсов из gdb и pfl() — функция для анализа стек фреймов (очень удобно при отладке интерпретатора в gdb).
Для примера рассмотрим следующий gdb-скрипт
#путь к исполняемому java
file /home/dmitrii/jdk12/build/linux-x86_64-server-fastdebug/images/jdk/bin/java
#не останавливаемся при SEGV-ах, HotSpot
#искусственно генерирует SEGV для проверок.
#например, https://hg.openjdk.java.net/jdk/jdk12/file/06222165c35f/src/hotspot/cpu/x86/vm_version_x86.cpp#l361
handle SIGSEGV nostop noprint
set breakpoint pending on
set pagination off
#Брейкпоинт на методе, который вызывается
#непосредственно перед передачей управления
#в java-метод public static void main(String args[])
b PostJVMInit thread 2
commands
#Буффер для имен методов,
#иначе падаем в корку
set $buf = (char *) malloc(1000)
#Ставим брейкпоинт на точке входа в интерпретируемые функции
#(Подробнее об этом ниже)
b *AbstractInterpreter::_entry_table[0] thread 2
commands
#Указатель на метод хранится в регистре rbx.
#Кастуем его к Method*
set $mthd = ((Method *) $rbx)
#Читаем сигнатуру метода в $buf
call $mthd->name_and_sig_as_C_string($buf, 1000)
#пропускаем все, кроме public static void main(String args)
if strcmp()("Main.main([Ljava/lang/String;)V", $buf) == 0
#ставим брейкпоинт на функции, которая вызывается из шаблона интерпретатора
#вызов ps/pfl напрямую из интерпретатора валит процесс в корку
#(скорее всего это ограничение ps/pfl)
b InterpreterRuntime::build_method_counters(JavaThread*, Method*)
commands
#удаляем все брейкпоинты, чтобы завершиться после
#вызова функций ниже
delete breakpoints
call ps()
call pfl()
c
end
end
c
end
c
end
r -cp /home/dmitrii/jdk12/ Main
Результат запуска такого скрипта имеет вид:
"Executing ps"
for thread: "main" #1 prio=5 os_prio=0 cpu=468,61ms elapsed=58,65s tid=0x00007ffff001b800 nid=0x5bfa runnable [0x00007ffff7fd9000]
java.lang.Thread.State: RUNNABLE
Thread: 0x00007ffff001b800 [0x5bfa] State: _running _has_called_back 0 _at_poll_safepoint 0
JavaThread state: _thread_in_Java
1 - frame( sp=0x00007ffff7fd9920, unextended_sp=0x00007ffff7fd9920, fp=0x00007ffff7fd9968, pc=0x00007fffd828748b)
Main.main(Main.java:10)
"Executing pfl"
for thread: "main" #1 prio=5 os_prio=0 cpu=468,83ms elapsed=58,71s tid=0x00007ffff001b800 nid=0x5bfa runnable [0x00007ffff7fd9000]
java.lang.Thread.State: RUNNABLE
Thread: 0x00007ffff001b800 [0x5bfa] State: _running _has_called_back 0 _at_poll_safepoint 0
JavaThread state: _thread_in_Java
[Describe stack layout]
0x00007ffff7fd99e0: 0x00007ffff7fd9b00 #2 entry frame
call_stub word fp - 0
0x00007ffff7fd99d8: 0x00007ffff7fd9c10 call_stub word fp - 1
0x00007ffff7fd99d0: 0x00007fffd8287160 call_stub word fp - 2
0x00007ffff7fd99c8: 0x00007fffbf1fb3e0 call_stub word fp - 3
0x00007ffff7fd99c0: 0x000000000000000a call_stub word fp - 4
0x00007ffff7fd99b8: 0x00007ffff7fd9ce8 call_stub word fp - 5
0x00007ffff7fd99b0: 0x00007ffff7fd9a80 call_stub word fp - 6
0x00007ffff7fd99a8: 0x00007ffff001b800 call_stub word fp - 7
0x00007ffff7fd99a0: 0x00007ffff7fd9b40 call_stub word fp - 8
0x00007ffff7fd9998: 0x00007ffff7fd9c00 call_stub word fp - 9
0x00007ffff7fd9990: 0x00007ffff7fd9a80 call_stub word fp - 10
0x00007ffff7fd9988: 0x00007ffff7fd9ce0 call_stub word fp - 11
0x00007ffff7fd9980: 0x00007fff00001fa0 call_stub word fp - 12
0x00007ffff7fd9978: 0x0000000716a122b8 sp for #2
locals for #1
unextended_sp for #2
local 0
0x00007ffff7fd9970: 0x00007fffd82719f3
0x00007ffff7fd9968: 0x00007ffff7fd99e0 #1 method Main.main([Ljava/lang/String;)V @ 0
- 1 locals 1 max stack
0x00007ffff7fd9960: 0x00007ffff7fd9978 interpreter_frame_sender_sp
0x00007ffff7fd9958: 0x0000000000000000 interpreter_frame_last_sp
0x00007ffff7fd9950: 0x00007fffbf1fb3e0 interpreter_frame_method
0x00007ffff7fd9948: 0x0000000716a11c40 interpreter_frame_mirror
0x00007ffff7fd9940: 0x0000000000000000 interpreter_frame_mdp
0x00007ffff7fd9938: 0x00007fffbf1fb5e8 interpreter_frame_cache
0x00007ffff7fd9930: 0x00007ffff7fd9978 interpreter_frame_locals
0x00007ffff7fd9928: 0x00007fffbf1fb3d0 interpreter_frame_bcp
0x00007ffff7fd9920: 0x00007ffff7fd9920 sp for #1
interpreter_frame_initial_sp
unextended_sp for #1
Как можно видеть, в случае ps()
мы просто получаем стек вызовов, в случае pfl()
— полную организацию стека.
Запуск java приложения
Прежде чем перейти к рассмотрению непосредственно интерпретатора, сделаем краткий обзор действий, выполняющихся до передачи управления java-коду. Для примера возьмем программу на языке Java, которая "не делает вообще ничего":
public class Main {
public static void main(String args[]){ }
}
и попробуем разобраться в том, что происходит при запуске такого приложения:
javac Main.java && java Main
Первое что нужно сделать, чтобы ответить на этот вопрос, это найти и посмотреть на бинарник java — тот самый, который мы используем для запуска все наших JVM-приложений. В моем случае он располагается по пути
/home/dmitrii/jdk12/build/linux-x86_64-server-fastdebug/images/jdk/bin/java
.
Но смотреть в итоге тут особо не на что. Это бинарник который вместе с дебажными символами занимает всего 20КБ и скомпилирован только из одного исходного файла launcher/main.c.
Все, что он делает это получает аргументы командной строки (char *argv[]), читает аргументы из переменной среды JDK_JAVA_OPTIONS, делает базовый препроцессинг и валидацию (например, нельзя добавить терминальную опцию или имя Main-класса в эту переменну среды) и вызывает функцию JLI_Launch с полученным списком аргументов.
Опреление функции JLI_Launch не содержится в бинарнике java и, если посмотреть на его прямые зависимости:
$ ldd java
linux-vdso.so.1 (0x00007ffcc97ec000)
libjli.so => /home/dmitrii/jdk12/build/linux-x86_64-server-fastdebug/images/jdk/bin/./../lib/libjli.so (0x00007ff27518d000) // <--------- Вот эта либа
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ff274d9c000)
libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007ff274b7f000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007ff27497b000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007ff27475c000)
/lib64/ld-linux-x86-64.so.2 (0x00007ff27559f000)
то можно заметить libjli.so которая к нему прилинкована. Данная библиотека содержит launcher interface — набор функций, которые используются java для инициализации и запуска виртуальной машины, среди которых присутствует и JLI_Launch.
$ objdump -T -j .text libjli.so
libjli.so: file format elf64-x86-64
DYNAMIC SYMBOL TABLE:
0000000000009280 g DF .text 0000000000000038 Base JLI_List_add
0000000000003330 g DF .text 00000000000001c3 Base JLI_PreprocessArg
0000000000008180 g DF .text 0000000000000008 Base JLI_GetStdArgs
0000000000008190 g DF .text 0000000000000008 Base JLI_GetStdArgc
0000000000007e50 g DF .text 00000000000000b8 Base JLI_ReportErrorMessage
000000000000a400 g DF .text 00000000000000df Base JLI_ManifestIterate
0000000000002e70 g DF .text 0000000000000049 Base JLI_InitArgProcessing
0000000000008000 g DF .text 0000000000000011 Base JLI_ReportExceptionDescription
0000000000003500 g DF .text 0000000000000074 Base JLI_AddArgsFromEnvVar
0000000000007f10 g DF .text 00000000000000e9 Base JLI_ReportErrorMessageSys
0000000000005840 g DF .text 00000000000000b8 Base JLI_ReportMessage
0000000000009140 g DF .text 000000000000003a Base JLI_SetTraceLauncher
0000000000009020 g DF .text 000000000000000a Base JLI_MemFree
0000000000008f90 g DF .text 0000000000000026 Base JLI_MemAlloc
00000000000059c0 g DF .text 0000000000002013 Base JLI_Launch
00000000000091c0 g DF .text 000000000000003b Base JLI_List_new
0000000000008ff0 g DF .text 0000000000000026 Base JLI_StringDup
0000000000002ec0 g DF .text 000000000000000c Base JLI_GetAppArgIndex
После передачи управления JLI_Launch происходит ряд действий необходимых для запуска JVM такие как:
I. Загрузка символов JVM HotSpot в память и получение указателя на функцию для создания VM.
Весь код JVM HotSpot располагается в библиотеке libjvm.so. После определения абсолютного пути к libjvm.so происходит загрузка библиотеки в память и выдирание из нее указателя на функцию JNI_CreateJavaVM. Этот указатель на функцию сохраняется и в дальнейшем используется для создания и инициализации виртуальной машины.
Очевидно, что libjvm.so не прилинкована к libjli.so
II. Парсинг аргументов, переданных после препроцессинга.
Функция с говорящим названием ParseArguments разбирает аргументы, переданные из командной строки. Этот парсер аргументов определяет режим запуска приложения
enum LaunchMode { // cf. sun.launcher.LauncherHelper
LM_UNKNOWN = 0,
LM_CLASS,
LM_JAR,
LM_MODULE,
LM_SOURCE
};
Также он преобразует часть аргументов в формат -DpropertyName=propertyValue
, например -cp=/path
приводится к виду -Djava.class.path=/path
. Далее такие SystemProperty
сохраняются в глобальном массиве в JVM HotSpot и пробрасываются в java.lang.System::props
в первой фазе инициализации (В JDK12 механизм инициализации java.lang.System.props был модифицирован, подробнее в этом коммите).
Парсинг аргументов также отбрасывает часть опций, которые не обрабатываются JVM (например --list-modules
, обработка данной опции происходит непосредственно в launcher'e в этом месте).
III. Форк primordial потока и создание в нем VM
Но если что-то пошло не так, то делается попытка запустить JVM в main-треде "just give it a try".
Поизучав вопрос, я нашел одну из возможных причин, по которой JVM запускается не в main-треде. Дело в том, что (по крайней мере в Linux) pthread'ы и main-тред работают со стеком по разному. Размер main-thread'a ограничен значением ulimit -s
, т.е. при выставлении сколь угодно большого значения мы получим сколь угодно большой стек. Main-тред использует нечто похожее на MAP_GROWSDOWN, но не MAP_GROWSDOWN
. Использование MAP_GROWSDOWN
в чистом виде не безопасно и, если мне не изменяет память, задепрекейчено. На моей машине MAP_GROWSDOWN
не добавляет никакого эффекта. Отличие маппинга main-треда от MAP_GROWSDOWN
в том, что никакой другой mmap
, за исключением MAP_FIXED
, не сможет сделать коллизию с областью возможного расширения стека. Все что нужно от софта — это выставить соответствующее значение rsp
и дальше ОС сама разберется: И page-fault обработает и guard выставит. Такое различие пораждает некоторое количество граблей: При определении размера стека текущего потока, при создании guard-pages
Итак, будем считать, что на данный момент у нас успешно распарсились опции и создался поток для VM. После этого, только что форкнутый поток начинает создание виртуальной машины и попадает в функцию Threads::create_vm
В этой функции делается довольно большое количество черной магии инициализаций, нам интересны будут лишь некоторые из них.
Инициализация интепретатора и передача управления java-коду
Для каждой инструкции в JVM HotSpot существует определенный шаблон машинного кода под конкретную архитектуру. Когда интерпретатор приступает к выполнению какой-либо инструкции, первым делом ищется адрес ее шаблона в специальной таблице DispatchTable. Далее происходит jump по адресу данного шаблона и после того как выполнение инструкции завершено, jvm достает адрес следующей по порядку инструкции) и начинает выполнять ее аналогичным образом, и так далее. Такое поведение наблюдается у интерепретатора только для инструкций, которые не "делают dispatch", например, арифметические инструкции (xsub
, xdiv
, etc, где x
— i
, l
, f
, d
). Все, что они делают — это выполняют арифметические операции.
В случае инструкций вызова процедур (invokestatic
, invokevirtual
, и т.д.) следующей к выполнению инструкцей будет первая по порядку инструкция вызываемой процедуры. Такие инструкции самостоятельно проставляют адрес следующей bytecode-инструкции к выполнению в своем шаблоне.
Чтобы обеспечить работу данной машинерии в Threads::create_vm
выполняется ряд инициализаций, от которых зависит интерпретатор:
I. Инициализация таблицы доступных байткодов
Прежде чем приступить к инициализации интерпретатора, необходимо проинициализировать таблицу используемых байткодов. Она выполняется в функции Bytecodes::initialize и представлена в виде очень удобочитаемой таблички. Ее фрагмент выглядит следующим образом:
// Java bytecodes
// bytecode bytecode name format wide f. result tp stk traps
def(_nop , "nop" , "b" , NULL , T_VOID , 0, false);
def(_aconst_null , "aconst_null" , "b" , NULL , T_OBJECT , 1, false);
def(_iconst_m1 , "iconst_m1" , "b" , NULL , T_INT , 1, false);
def(_iconst_0 , "iconst_0" , "b" , NULL , T_INT , 1, false);
def(_iconst_1 , "iconst_1" , "b" , NULL , T_INT , 1, false);
def(_iconst_2 , "iconst_2" , "b" , NULL , T_INT , 1, false);
def(_iconst_3 , "iconst_3" , "b" , NULL , T_INT , 1, false);
def(_iconst_4 , "iconst_4" , "b" , NULL , T_INT , 1, false);
def(_iconst_5 , "iconst_5" , "b" , NULL , T_INT , 1, false);
def(_lconst_0 , "lconst_0" , "b" , NULL , T_LONG , 2, false);
def(_lconst_1 , "lconst_1" , "b" , NULL , T_LONG , 2, false);
def(_fconst_0 , "fconst_0" , "b" , NULL , T_FLOAT , 1, false);
def(_fconst_1 , "fconst_1" , "b" , NULL , T_FLOAT , 1, false);
def(_fconst_2 , "fconst_2" , "b" , NULL , T_FLOAT , 1, false);
def(_dconst_0 , "dconst_0" , "b" , NULL , T_DOUBLE , 2, false);
def(_dconst_1 , "dconst_1" , "b" , NULL , T_DOUBLE , 2, false);
def(_bipush , "bipush" , "bc" , NULL , T_INT , 1, false);
def(_sipush , "sipush" , "bcc" , NULL , T_INT , 1, false);
def(_ldc , "ldc" , "bk" , NULL , T_ILLEGAL, 1, true );
def(_ldc_w , "ldc_w" , "bkk" , NULL , T_ILLEGAL, 1, true );
def(_ldc2_w , "ldc2_w" , "bkk" , NULL , T_ILLEGAL, 2, true );
В соответствии с данной таблицей, для каждого байткода выставляются его длина (размер всегда 1 байт, но может быть еще индекс в ConstantPool
, а также широкие байткоды), имя, байткод и флаги:
bool Bytecodes::_is_initialized = false;
const char* Bytecodes::_name [Bytecodes::number_of_codes];
BasicType Bytecodes::_result_type [Bytecodes::number_of_codes];
s_char Bytecodes::_depth [Bytecodes::number_of_codes];
u_char Bytecodes::_lengths [Bytecodes::number_of_codes];
Bytecodes::Code Bytecodes::_java_code [Bytecodes::number_of_codes];
unsigned short Bytecodes::_flags [(1<<BitsPerByte)*2];
Эти параметры в дальнейшем нужны для генерации кода шаблонов интерпретатора
II. Инициализация код кэша
Для того, чтобы сгенерить код шаблонов интерпретатора, необходимо сперва выделить под это дело память. Резервация памяти под код кэш реализована в функции с одноименным названием CodeCache::initialize(). Как можно видеть из следущего участка кода данной функции
CodeCacheExpansionSize = align_up(CodeCacheExpansionSize, os::vm_page_size());
if (SegmentedCodeCache) {
// Use multiple code heaps
initialize_heaps();
} else {
// Use a single code heap
FLAG_SET_ERGO(uintx, NonNMethodCodeHeapSize, 0);
FLAG_SET_ERGO(uintx, ProfiledCodeHeapSize, 0);
FLAG_SET_ERGO(uintx, NonProfiledCodeHeapSize, 0);
ReservedCodeSpace rs = reserve_heap_memory(ReservedCodeCacheSize);
add_heap(rs, "CodeCache", CodeBlobType::All);
}
код кэш контролируется опциями -XX:ReservedCodeCacheSize
, -XX:SegmentedCodeCache
, -XX:CodeCacheExpansionSize
, -XX:NonNMethodCodeHeapSize
, -XX:ProfiledCodeHeapSize
, -XX:NonProfiledCodeHeapSize
. Краткое описание данных опций можно посмотреть по ссылкам на которые они ведут. Помимо коммандной строки, значения некоторых из этих опций подстраивается эргономикой, например, если используется значение SegmentedCodeCache
по умолчанию (выключен), то при размере кода >= 240Mb
, SegmentedCodeCache
будет включен в CompilerConfig::set_tiered_flags.
После выполнения проверок резервируется область размером в ReservedCodeCacheSize
байт. В случае, если SegmentedCodeCache
оказалась выставленной, то данная область разбивается на части: JIT-скомпилированные методы, стаб рутины, и т.д.
III. Инициализация шаблонов интерпретатора
После того, как таблица байткодов и код кэш проинициализированы, можно приступать к кодогенерации шаблонов интерпретатора. Для этого интерепретатор резервирует буффер из ранее проинициализированного код кэша. На каждый этап кодогенерации из буффера будут отрезаться кодлеты — небольшие участки кода. После завершению текущей генерации, неиспользуемая под код часть кодлета освобождается и становится доступной для последующих кодогенераций.
Рассмотрим каждый из этих этапов по отдельности:
{ CodeletMark cm(_masm, "slow signature handler");
AbstractInterpreter::_slow_signature_handler = generate_slow_signature_handler();
}
signature handler используется для подготовки аргументов для вызовов нативных методов. В данном случае генерится обощенный хэндлер, если, например у нативного метода больше 13 аргументов (В дебаггере не проверял, но судя по коду должны быть так)
{ CodeletMark cm(_masm, "error exits");
_unimplemented_bytecode = generate_error_exit("unimplemented bytecode");
_illegal_bytecode_sequence = generate_error_exit("illegal bytecode sequence - method not verified");
}
VM валидирует классфайлы при инициализации, но это на случай, если аргументы на стеке не того формата который нужен или байткод о котором VM не знает. Эти стабы используются при генерации кода шаблонов для каждого из байткодов.
После вызова процедур необходимо восстановить данные стек фрейма, который был до вызова процедуры из которой делается return.
Используется при вызовах рантайма из интерепретатора.
#define method_entry(kind) \ { CodeletMark cm(_masm, "method entry point (kind = " #kind ")"); \ Interpreter::_entry_table[Interpreter::kind] = generate_method_entry(Interpreter::kind); \ Interpreter::update_cds_entry_table(Interpreter::kind); \ }
Представлен в виде макроса в зависимости от типа метода. В общем случае выполняется подготовка интерпретируемого стек-фрейма, проверка StackOverflow, stack-banging. Для нативных методов определяется signature handler.
Генерация шаблонов для байткодов
// Bytecodes
set_entry_points_for_all_bytes();
// installation of code in other places in the runtime
// (ExcutableCodeManager calls not needed to copy the entries)
set_safepoints_for_all_bytes();
Для выполнения инструкции спецификация VM требует чтобы операнды находились в Operand Stack, но это не запрещает HotSpot кэшировать их в регистре. Для определения текущего состояния вершины стека используется перечисление
enum TosState { // describes the tos cache contents
btos = 0, // byte, bool tos cached
ztos = 1, // byte, bool tos cached
ctos = 2, // char tos cached
stos = 3, // short tos cached
itos = 4, // int tos cached
ltos = 5, // long tos cached
ftos = 6, // float tos cached
dtos = 7, // double tos cached
atos = 8, // object cached
vtos = 9, // tos not cached
number_of_states,
ilgl // illegal state: should not occur
};
Каждая инструкция определяет входные и выходные состояния TosState
вершины стека, и генерация шаблонов происходит в зависимости от этого состояния. Данные шаблоны инициализируются в удобочитаемой таблице шаблонов. Фрагмент этой таблицы выглядит следующим образом:
// interpr. templates
// Java spec bytecodes ubcp|disp|clvm|iswd in out generator argument
def(Bytecodes::_nop , ____|____|____|____, vtos, vtos, nop , _ );
def(Bytecodes::_aconst_null , ____|____|____|____, vtos, atos, aconst_null , _ );
def(Bytecodes::_iconst_m1 , ____|____|____|____, vtos, itos, iconst , -1 );
def(Bytecodes::_iconst_0 , ____|____|____|____, vtos, itos, iconst , 0 );
def(Bytecodes::_iconst_1 , ____|____|____|____, vtos, itos, iconst , 1 );
def(Bytecodes::_iconst_2 , ____|____|____|____, vtos, itos, iconst , 2 );
def(Bytecodes::_iconst_3 , ____|____|____|____, vtos, itos, iconst , 3 );
def(Bytecodes::_iconst_4 , ____|____|____|____, vtos, itos, iconst , 4 );
def(Bytecodes::_iconst_5 , ____|____|____|____, vtos, itos, iconst , 5 );
def(Bytecodes::_lconst_0 , ____|____|____|____, vtos, ltos, lconst , 0 );
def(Bytecodes::_lconst_1 , ____|____|____|____, vtos, ltos, lconst , 1 );
def(Bytecodes::_fconst_0 , ____|____|____|____, vtos, ftos, fconst , 0 );
def(Bytecodes::_fconst_1 , ____|____|____|____, vtos, ftos, fconst , 1 );
def(Bytecodes::_fconst_2 , ____|____|____|____, vtos, ftos, fconst , 2 );
def(Bytecodes::_dconst_0 , ____|____|____|____, vtos, dtos, dconst , 0 );
def(Bytecodes::_dconst_1 , ____|____|____|____, vtos, dtos, dconst , 1 );
def(Bytecodes::_bipush , ubcp|____|____|____, vtos, itos, bipush , _ );
def(Bytecodes::_sipush , ubcp|____|____|____, vtos, itos, sipush , _ );
Нам будут особенно интересны столбцы in
, out
и generator
.
in
— состояние вершины стека на момент начала исполнения инструкции
out
— состояния вершины стека на момент завершения исполнения инструкции
generator
— генератор шаблона машинного кода инструкции
Общий вид шаблона для всех байткодов можно описать в виде:
Если для инструкции не выставлен dispatch bit, то выполняется пролог инструкции (no-op на x86)
Используя
generator
, генерится машинный код
Если для инструкции не выставлен dispatch bit, то выполняется переход к следующей по порядку инструкции в зависимости от
out
состояния вершины стека, которое будет являтсяin
для следующей инструкции
Адрес точки входа для полученного шаблона сохраняется в глобальной таблице и его можно использовать при отладке.
В HotSpot за это отвечает следущий, относительно стремный кусок кода:
void TemplateInterpreterGenerator::set_entry_points(Bytecodes::Code code) {
CodeletMark cm(_masm, Bytecodes::name(code), code);
// initialize entry points
assert(_unimplemented_bytecode != NULL, "should have been generated before");
assert(_illegal_bytecode_sequence != NULL, "should have been generated before");
address bep = _illegal_bytecode_sequence;
address zep = _illegal_bytecode_sequence;
address cep = _illegal_bytecode_sequence;
address sep = _illegal_bytecode_sequence;
address aep = _illegal_bytecode_sequence;
address iep = _illegal_bytecode_sequence;
address lep = _illegal_bytecode_sequence;
address fep = _illegal_bytecode_sequence;
address dep = _illegal_bytecode_sequence;
address vep = _unimplemented_bytecode;
address wep = _unimplemented_bytecode;
// code for short & wide version of bytecode
if (Bytecodes::is_defined(code)) {
Template* t = TemplateTable::template_for(code);
assert(t->is_valid(), "just checking");
set_short_entry_points(t, bep, cep, sep, aep, iep, lep, fep, dep, vep);
}
if (Bytecodes::wide_is_defined(code)) {
Template* t = TemplateTable::template_for_wide(code);
assert(t->is_valid(), "just checking");
set_wide_entry_point(t, wep);
}
// set entry points
EntryPoint entry(bep, zep, cep, sep, aep, iep, lep, fep, dep, vep);
Interpreter::_normal_table.set_entry(code, entry);
Interpreter::_wentry_point[code] = wep;
}
//...
void TemplateInterpreterGenerator::set_short_entry_points(Template* t, address& bep, address& cep, address& sep, address& aep, address& iep, address& lep, address& fep, address& dep, address& vep) {
assert(t->is_valid(), "template must exist");
switch (t->tos_in()) {
case btos:
case ztos:
case ctos:
case stos:
ShouldNotReachHere(); // btos/ctos/stos should use itos.
break;
case atos: vep = __ pc(); __ pop(atos); aep = __ pc(); generate_and_dispatch(t); break;
case itos: vep = __ pc(); __ pop(itos); iep = __ pc(); generate_and_dispatch(t); break;
case ltos: vep = __ pc(); __ pop(ltos); lep = __ pc(); generate_and_dispatch(t); break;
case ftos: vep = __ pc(); __ pop(ftos); fep = __ pc(); generate_and_dispatch(t); break;
case dtos: vep = __ pc(); __ pop(dtos); dep = __ pc(); generate_and_dispatch(t); break;
case vtos: set_vtos_entry_points(t, bep, cep, sep, aep, iep, lep, fep, dep, vep); break;
default : ShouldNotReachHere(); break;
}
}
//...
void TemplateInterpreterGenerator::generate_and_dispatch(Template* t, TosState tos_out) {
if (PrintBytecodeHistogram) histogram_bytecode(t);
#ifndef PRODUCT
// debugging code
if (CountBytecodes || TraceBytecodes || StopInterpreterAt > 0) count_bytecode();
if (PrintBytecodePairHistogram) histogram_bytecode_pair(t);
if (TraceBytecodes) trace_bytecode(t);
if (StopInterpreterAt > 0) stop_interpreter_at();
__ verify_FPU(1, t->tos_in());
#endif // !PRODUCT
int step = 0;
if (!t->does_dispatch()) {
step = t->is_wide() ? Bytecodes::wide_length_for(t->bytecode()) : Bytecodes::length_for(t->bytecode());
if (tos_out == ilgl) tos_out = t->tos_out();
// compute bytecode size
assert(step > 0, "just checkin'");
// setup stuff for dispatching next bytecode
if (ProfileInterpreter && VerifyDataPointer
&& MethodData::bytecode_has_profile(t->bytecode())) {
__ verify_method_data_pointer();
}
__ dispatch_prolog(tos_out, step);
}
// generate template
t->generate(_masm);
// advance
if (t->does_dispatch()) {
#ifdef ASSERT
// make sure execution doesn't go beyond this point if code is broken
__ should_not_reach_here();
#endif // ASSERT
} else {
// dispatch to next bytecode
__ dispatch_epilog(tos_out, step);
}
}
Как только данная кодогенерация завершена, интепретатор можно считать полностью проинициализированным. После интерпретатора выполняется еще много инициализаций различных подсистем JVM. Для некоторых из них требуется вызывать Java-код из кода виртуальной машины. Это реализовано с помощью стандартного механизма JavaCalls. После того как инициализация JVM полностью завершена, этот механизм используется для вызова метода main.
Пример
Для того, чтобы представлять как это все работает на практике, рассмотрим следующий относительно простой пример:
public class Sum{
public static void sum(int a, int b){
return a + b;
}
}
public class Main {
public static void main(String args[]){
Sum.sum(2, 3);
}
}
и попытаемся понять что происходит при вызове метода Sum.sum(II)
.
Скомпилируем эти 2 класса javac -c *.java
и убедимся в том, что компилятор не сделал никаких оптимизаций.
Байткод Sum.sum
:
descriptor: (II)I
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=2
0: iload_0
1: iload_1
2: iadd
3: ireturn
LineNumberTable:
line 3: 0
Байткод Main.main
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: iconst_2
1: iconst_3
2: invokestatic #2 // Method Sum.sum:(II)I
5: pop
6: return
LineNumberTable:
line 13: 0
line 14: 6
Байткод ровно такой, какой нам нужен и первое с чего придется начать — это с анализа вызова статического метода.
Генератор шаблона invokestatic
'а для x86 находится в архитектурно-зависимой секции кода HotSpot и представлен в виде
void TemplateTable::invokestatic(int byte_no) {
transition(vtos, vtos);
assert(byte_no == f1_byte, "use this argument");
prepare_invoke(byte_no, rbx); // get f1 Method*
// do the call
__ profile_call(rax);
__ profile_arguments_type(rax, rbx, rbcp, false);
__ jump_from_interpreted(rbx, rax);
}
byte_no == f1_byte
— это секция ConstantPoolCache
, относящаяся к статическим методам, rbx
— регистр, в котором будет храниться указатель Method *
. В остальном все в принципе понятно: Подготовка вызова, профайлинг, переход на точку входа метода (method_entry
при генерации шаблонов интерпретатора).
Рассмотрим подронее prepare_invoke
. Как известно, следом за байткодом инструкции invokestatic
идет индекс в ConstantPool
на Constant_Methodref_Info
. В случае HotSpot это не совсем так. Следущие 2 байта указывают на индекс в т.н. ConstantPoolCache
. ConstantPoolCache
это структура данных в которой хранится информация, нужная для интерпретатора (например, было ли зарезолвено ConstantPoolCacheEntry
по данному индексу, реализуя таким образом ленивость загрузки классов). После того как это ConstantPoolCacheEntry
зарезолвено, в него записывается номер байткода (изначально там был 0) и этот номер используется при дальнейшем определении зарезолвено/не зарезолвено. Несмотря на то, что при загрузке класса индексы изначально указывают в ConstantPool
, при линковке класса они будут перезаписаны на ConstantPoolCache
индексы в нативном байт ордере (на x86 Little Endian).
Итак, первое, что HotSpot пытается сделать в prepare_invoke
— это достать индекс на ConstantPoolCache
. После того, как индекс получен, делается проверка на зарезолвенность ConstantPoolCacheEntry
по данному индексу
__ get_cache_and_index_and_bytecode_at_bcp(Rcache, index, temp, byte_no, 1, index_size);
__ cmpl(temp, code); // have we resolved this bytecode?
__ jcc(Assembler::equal, resolved);
// resolve first time through
address entry = CAST_FROM_FN_PTR(address, InterpreterRuntime::resolve_from_cache);
__ movl(temp, code);
__ call_VM(noreg, entry, temp);
// Update registers with resolved info
__ get_cache_and_index_at_bcp(Rcache, index, 1, index_size);
__ bind(resolved);
Если нет, значит нужно вызывать InterpreterRuntime::resolve_from_cache
.
В данной функции выполняется загрузка класса receiver'a вызываемого статического метода, если на данный момент класс еще не был загружен. После загрузки выполняется инициализация (линковка, валидация, перезаписывание байткода, создание ConstantPoolCache
и вызов <clinit>
, если такой метод присутствует в байткоде). Не ленивая инициализация может выполняться и сразу после define class, если выставлен флаг EagerInitialization
(флаг девелоперский, поэтому из коммандной строки не доступен, но кто нам запретит у себя его поменять на продакшн :)). Вообще загрузка классов в HotSpot в общем (и CDS в частности) имеет относительно не тривиальную реализацию.
После того, как класс инициализирован и нужный метод в классе найден, инициализируется соответветствующее ConstantPoolCacheEntry
этими байткодом и методом. После этого интерпретатор загружает указатель Method *
в rbx
, достает адрес возврата, делает профайлинг и переходит на точку входа вызовав метода.
Исследуем теперь точку входа при вызове Sum.sum(2, 3)
. Для этого нам потребуется следующий gdb-script sum.gdb
:
#Путь к исполняемому файлу java
file /home/dmitrii/jdk12/build/linux-x86_64-server-fastdebug/images/jdk/bin/java
#Говорим gdb не останавливаться на SEGV'ах
#таких, как этот https://hg.openjdk.java.net/jdk/jdk12/file/06222165c35f/src/hotspot/cpu/x86/vm_version_x86.cpp#l361
handle SIGSEGV nostop noprint
#Символы на данный момент еще не загружены
set breakpoint pending on
#Чтобы быстро проскипать статические методы,
#не относящиеся к эксперименту
set pagination off
#Ставим брейк сразу перед вызовом метода main
b PostJVMInit
commands
#Буффер для сигнатур методов,
#иначе упадем в корку
set $buffer = malloc(1000)
#Точка входа в метод.
#jmp по этому адресу делается в генерируемом
#шаблоне invokestatic
b *AbstractInterpreter::_entry_table[0] thread 2
commands
#В соответсвии с кодом шаблона invokestatic,
#указатель Method* хранится в rbx
set $mthd = (Method *) $rbx
#Получаем сигнатуру метода в $buffer
call $mthd->name_and_sig_as_C_string($buffer, 1000)
if strcmp()($buffer, "Sum.sum(II)I") == 0
#Брейкпоинт на iload_0, вершина стека не закеширована
b *TemplateInterpreter::_normal_table._table[vtos][26] thread 2
#Брейкпоинт на iload_1, вершина стека - int, закеширована
#после выполнения iload_0
b *TemplateInterpreter::_normal_table._table[itos][27] thread 2
#Брейкпоинт на инструкции iadd
b *TemplateInterpreter::_normal_table._table[itos][96] thread 2
end
c
end
c
end
r -cp . Main
Запустив данный скрипт gdb -x sum.gdb
, останавливаемся на точке входа в метод Sum.sum
$453 = 0x7ffff7fdcdd0 "Sum.sum(II)I"
Если открыть layout asm
, то мы увидим код, сгенеренный методом generate_normal_entry. В данном шаблоне делается создание стек-фрейма, проверка StackOverflow, stack-banging и далее делается dispatch на первую инструкцию iload_0
при незакешированной вершине стека. В этом случае код интерпретатора имеет вид:
0x7fffd828fa1f mov eax,DWORD PTR [r14] ;собственно, iload_0 0x7fffd828fa22 movzx ebx,BYTE PTR [r13+0x1] ;загружаем следующий байткод 0x7fffd828fa27 inc r13 ;инкремент bcp (byte code pointer) 0x7fffd828fa2a movabs r10,0x7ffff717e8a0 ;загрузка DispatchTable 0x7fffd828fa34 jmp QWORD PTR [r10+rbx*8] ;jump в зависимости от вершины стека
После этого вершина стека оказалась закешированной в rax
, а значит интерпретатор переходит в следующий шаблон
0x7fffd828fabe push rax ;кладем кешированную вершину на стек
;далее все тоже самое, что и в предыдущем примере
0x7fffd828fabf mov eax,DWORD PTR [r14-0x8] 0x7fffd828fac3 movzx ebx,BYTE PTR [r13+0x1] 0x7fffd828fac8 inc r13 0x7fffd828facb movabs r10,0x7ffff717e8a0 0x7fffd828fad5 jmp QWORD PTR [r10+rbx*8]
Ну а теперь и сама инструкция iadd
:
0x7fffd8292ba7 mov edx,DWORD PTR [rsp] ;загружаем то, что ранее запушили в iload_1 0x7fffd8292baa add rsp,0x8 ;поправляем rsp руками после загрузки 0x7fffd8292bae add eax,edx ;сложение двух интов 0x7fffd8292bb0 movzx ebx,BYTE PTR [r13+0x1] 0x7fffd8292bb5 inc r13 0x7fffd8292bb8 movabs r10,0x7ffff717e8a0 0x7fffd8292bc2 jmp QWORD PTR [r10+rbx*8]
Если посмотреть в gdb
на eax
и edx
сразу перед выполнением сложения, то можно заметить
(gdb) p $eax
$457 = 3
(gdb) p $edx
$458 = 2
А это и есть те самые операнды, которые мы передали функции Sum.sum
.