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

Для простоты представьте этот сценарий, у нас есть 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 будет интерпретировать этот код? Я имею в виду, в конце концов, тот же бит-шаблон должен быть передан процессору, не так ли? У всех процессоров есть несколько инструкций, которые он может понять и выполнить, и в конце концов они просто некоторые бит. Допустим, скомпилированный байт-код Java выглядит примерно так:

1111 1100 1001

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

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

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

Ответ 1

Архитектура процессора, которую вы описываете, к сожалению, слишком ограничена, чтобы сделать это действительно понятным со всеми промежуточными шагами. Вместо этого я напишу псевдо-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 скомпилирован в машинный код и запускается. Как вы можете видеть, он очень динамичен: он проверяет каждую инструкцию байткода каждый раз, когда выполняется эта команда, и все значения проходят через стек (то есть RAM).

В то время как фактическое добавление, вероятно, происходит в регистре, код, окружающий добавление, сильно отличается от того, что испускает компилятор кода 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, $1) и перенаправлять доступ стека к правильным машинным регистрам. Игнорируя еще несколько оптимизаций (распространение копии, постоянное сгибание и удаление мертвого кода), которые обычно выполняются, это может привести к следующему коду:

movl %ebx, $1 # ldc 0
movl %ecx, $2 # 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

Ответ 2

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

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

Ответ 3

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

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

Ответ 4

Упрощение, интерпретатор - это бесконечный цикл с гигантским переключателем внутри. Он считывает байт-код 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 или С++). Компиляция обычно намного медленнее, чем интерпретация, но должна выполняться только один раз и только для выбранных методов.