...

суббота, 28 сентября 2019 г.

[Из песочницы] Инициализация и работа интерпретатора байткода в JVM HotSpot под x86

Почти каждый 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).


Пример ps() и pfl()

Для примера рассмотрим следующий 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, где xi, 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.

Используется при вызовах рантайма из интерепретатора.

  // 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 — генератор шаблона машинного кода инструкции

Общий вид шаблона для всех байткодов можно описать в виде:


  1. Если для инструкции не выставлен dispatch bit, то выполняется пролог инструкции (no-op на x86)


  2. Используя generator, генерится машинный код


  3. Если для инструкции не выставлен 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.

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий