Как интерпретатор интерпретирует код?

для простоты представьте себе этот сценарий, у нас есть 2-разрядный компьютер, который имеет пару 2-разрядных регистров, называемых r1 и r2, и работает только с немедленной адресацией.

давайте скажем последовательность битов 00 означает добавить к нашему процессору. Также 01 означает перемещение данных в r1 и 10 означает перемещение данных в r2.

таким образом, для этого компьютера есть язык сборки и ассемблер, где будет написан пример кода как

mov r1,1
mov r2,2
add r1,r2

просто, когда я соберу этот код на родной язык и файл будет что-то вроде:

0101 1010 0001

в 12 бит выше родного код:

Put decimal 1 to R1, Put decimal 2 to R2, Add the data and store in R1. 

так вот как работает скомпилированный код, верно?

допустим, кто-то реализует JVM для этой архитектуры. В Java я буду писать код типа:

int x = 1 + 2;

как именно JVM интерпретирует этот код? Я имею в виду, что в конечном итоге тот же бит-шаблон должен быть переданным процессору, не так ли? Все cpu имеют ряд инструкций, которые он может понять и выполнить, и они в конце концов всего лишь некоторые биты. Скажем, скомпилированный байт-код Java выглядит примерно так:

1111 1100 1001

или что-то еще.. Означает ли это, что интерпретация изменяет этот код на 0101 1010 0001 при выполнении? Если это так, то он уже находится в родном коде, так почему говорится, что JIT запускается только после нескольких раз? Если он не преобразует его точно в 0101 1010 0001, тогда что он делает? Как это делает cpu сделать дополнение?

возможно, есть некоторые ошибки в моих предположениях.

Я знаю, что интерпретация медленная, скомпилированный код быстрее, но не портативный, и виртуальная машина "интерпретирует" код, но как? Я ищу "как именно / технически интерпретировать" делается. Любые указатели (например, книги или веб-страницы) приветствуются вместо ответов.

4 ответов


архитектура процессора, которую вы описываете, к сожалению, слишком ограничены, чтобы сделать это очень ясно, со всеми промежуточными шагами. Вместо этого я напишу псевдо-C и псевдо-x86-ассемблер, надеюсь, таким образом, чтобы это было ясно, не будучи ужасно знакомым с C или x86.

скомпилированный байт-код JVM может выглядеть примерно так:

ldc 0 # push first first constant (== 1)
ldc 1 # push the second constant (== 2)
iadd # pop two integers and push their sum
istore_0 # pop result and store in local variable

интерпретатор имеет (двоичную кодировку) этих инструкций в массиве и индекс, ссылающийся на текущий инструкция. Он также имеет массив констант и область памяти, используемую в качестве стека и для локальных переменных. Тогда цикл интерпретатора выглядит следующим образом:

while (true) {
    switch(instructions[pc]) {
    case LDC:
        sp += 1; // make space for constant
        stack[sp] = constants[instructions[pc+1]];
        pc += 2; // two-byte instruction
    case IADD:
        stack[sp-1] += stack[sp]; // add to first operand
        sp -= 1; // pop other operand
        pc += 1; // one-byte instruction
    case ISTORE_0:
        locals[0] = stack[sp];
        sp -= 1; // pop
        pc += 1; // one-byte instruction
    // ... other cases ...
    }
}

этой код C компилируется в машинный код и запускается. Как вы можете видеть, он очень динамичен: он проверяет каждую инструкцию байт-кода каждый раз, когда эта инструкция выполняется, и все значения проходят через стек (т. е. ОЗУ).

в то время как фактическое добавление, вероятно, происходит в регистре, код, окружающий добавление, довольно отличается от того, что будет излучать компилятор кода Java-to-machine. Вот выдержка из того, что компилятор C может превратить вышеизложенное в (псевдо-x86):

.ldc:
incl %esi # increment the variable pc, first half of pc += 2;
movb %ecx, program(%esi) # load byte after instruction
movl %eax, constants(,%ebx,4) # load constant from pool
incl %edi # increment sp
movl %eax, stack(,%edi,4) # write constant onto stack
incl %esi # other half of pc += 2
jmp .EndOfSwitch

.addi
movl %eax, stack(,%edi,4) # load first operand
decl %edi # sp -= 1;
addl stack(,%edi,4), %eax # add
incl %esi # pc += 1;
jmp .EndOfSwitch

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

цель JIT-компилятора - сделать именно это: создать специализированный код. JIT может анализировать способы использования стека для передачи данных, фактические значения различных констант в программе и последовательность выполняемых вычислений, чтобы генерировать код, который более эффективно делает то же самое. В нашем примере программа выделит локальную переменную 0 в регистр, заменит доступ к таблице констант с движущимися константами в регистры (movl %eax, ), и перенаправить доступ к стеку к правильным регистрам машины. Игнорируя еще несколько оптимизаций (распространение копирования, постоянное сворачивание и устранение мертвого кода), которые обычно выполняются, это может закончиться таким кодом:

movl %ebx,  # ldc 0
movl %ecx,  # ldc 1
movl %eax, %ebx # (1/2) addi
addl %eax, %ecx # (2/2) addi
# no istore_0, local variable 0 == %eax, so we're done

Не все компьютеры имеют одинаковый набор команд. Байт-код Java-это своего рода эсперанто - искусственный язык для улучшения коммуникации. Виртуальная машина Java преобразует универсальный байт-код Java в набор инструкций компьютера, на котором она работает.

Так как же JIT фигурирует здесь? Основная цель JIT-компилятора-оптимизация. Часто существуют различные способы перевода определенного байт-кода в целевой машинный код. Самый performance-идеальный перевод часто неочевидно, потому что это может зависеть от данных. Существуют также ограничения на то, насколько программа может анализировать алгоритм без его выполнения -проблема останова является хорошо известным таким ограничением, но не единственным. Таким образом, компилятор JIT пробует различные возможные переводы и измеряет, как быстро они выполняются с реальными данными, обрабатываемыми программой. Поэтому требуется несколько исполнений, пока JIT-компилятор не найдет идеальный перевод.


одним из важных шагов в Java является то, что компилятор сначала переводит .java код в .class файл, который содержит байт-код Java. Это полезно, Так как вы можете взять .class файлы и запустить их на любой машине, которая понимает это промежуточный язык, к тому времени переводя его на месте строка за строкой, или кусок за куском. Это одна из наиболее важных функций Java compiler + interpreter. Вы можете непосредственно скомпилировать исходный код Java для собственного двоичного кода, но это отрицает идею написания исходного кода один раз и возможность его запуска в любом месте. Это связано с тем, что скомпилированный собственный двоичный код будет работать только на той же архитектуре оборудования/ОС, для которой он был скомпилирован. Если вы хотите запустить его на другой архитектуре, вам придется перекомпилировать источник на этот. При компиляции байт-кода промежуточного уровня вам не нужно перетаскивать исходный код, а байт-код. Это другой вопрос, так как вам сейчас нужен JVM, который может интерпретировать и запускать байт-код. Таким образом, компиляция байт-кода промежуточного уровня, который затем запускается интерпретатором, является неотъемлемой частью процесса.

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


упрощение, интерпретатор представляет собой бесконечный цикл с гигантским переключателем внутри. Он читает байтовый код Java (или некоторое внутреннее представление) и эмулирует процессор, выполняющий его. Таким образом, реальный процессор выполняет код интерпретатора, который эмулирует виртуальный процессор. Это мучительно медленно. Одна виртуальная инструкция добавление двух чисел требует трех вызовов функций и многих других операций. Для выполнения одной виртуальной инструкции требуется несколько реальных инструкций. Это также меньше памяти эффективной как у вас есть как реальный, так и эмулированный стек, регистры и указатели инструкций.

while(true) {
    Operation op = methodByteCode.get(instructionPointer);
    switch(op) {
        case ADD:
            stack.pushInt(stack.popInt() + stack.popInt())
            instructionPointer++;
            break;
        case STORE:
            memory.set(stack.popInt(), stack.popInt())
            instructionPointer++;
            break;
        ...

    }
}

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

for(Operation op : methodByteCode) {
    switch(op) {
        case ADD:
            compiledCode += "popi r1"
            compiledCode += "popi r2"
            compiledCode += "addi r1, r2, r3"
            compiledCode += "pushi r3"
            break;
        case STORE:
            compiledCode += "popi r1"
            compiledCode += "storei r1"
            break;
        ...

    }
}

после создания собственного кода JVM скопирует его где-нибудь, пометит этот регион в качестве исполняемого файла и поручить интерпретатору вызвать его вместо интерпретации байтового кода при следующем вызове этого метода. Одна виртуальная инструкция может по-прежнему принимать несколько собственных инструкций, но это будет почти так же быстро, как и компиляция в машинный код (например, в C или c++). Компиляция обычно намного медленнее, чем интерпретация, но должна выполняться только один раз и только для выбранных методов.