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

Для простоты представьте этот сценарий: у нас есть 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 при выполнении? Если это так, то это уже есть в собственном коде, так почему же сказано, что JIT срабатывает только после определенного количества раз? Если он не преобразует его точно в 0101 1010 0001, то что он делает? Как он заставляет процессор выполнять добавление?

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

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


person Koray Tugay    schedule 25.01.2015    source источник


Ответы (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 компилируется в машинный код и запускается. Как видите, он очень динамичный: он проверяет каждую инструкцию байт-кода каждый раз, когда эта инструкция выполняется, и все значения проходят через стек (то есть 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
person Community    schedule 25.01.2015
comment
Можно ли сказать, что в вашем примере JIT пробился для добавления значений, но сохранение все еще интерпретируется? Кстати, отличный ответ, спасибо. - person Koray Tugay; 25.01.2015
comment
@KorayTugay Я бы не сказал, что хранение все еще интерпретируется. Изменилось расположение этих хранилищ, изменился способ, которым они располагаются, и JIT очень четко понимала, какое хранилище влияет на какой участок памяти. Перестановка регистров немного неоптимальна (после дальнейшей оптимизации первая инструкция будет использовать eax вместо ebx, а третья инструкция будет удалена), но она очень четко скомпилирована. - person ; 25.01.2015
comment
@Koray Tugay: нет, собственно, хранение означает изменение кучи. Таким образом, интерпретатор и скомпилированный код могут иметь совершенно другой способ обработки локальных переменных и стека, если они согласны относительно кучи. В этом простом примере, если HotSpot сработает, он обнаружит, что куча никогда не модифицируется и результат не возвращается, поэтому он удалит все вычисления. Время, когда скомпилированный код отражал связанный байт-код 1: 1, было двадцать лет назад ... - person Holger; 26.01.2015
comment
@Holger ну, я действительно пытался сделать вопрос простым - person Koray Tugay; 26.01.2015

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

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

person Philipp    schedule 25.01.2015
comment
По сути, каждый раз, когда код компилируется в собственный код для данной архитектуры. Но как только самая быстрая версия будет найдена, JIT компилирует ее в последний раз? - person Koray Tugay; 25.01.2015
comment
@KorayTugay, у него уже есть скомпилированная версия, так зачем компилировать ее снова? - person Philipp; 25.01.2015
comment
Итак, вы говорите, что требуется несколько выполнений, пока JIT-компилятор не найдет идеальный перевод. А выполнение означает байт-код в собственный код, не так ли, что называется компиляцией? - person Koray Tugay; 25.01.2015
comment
Компиляция кода будет иметь некоторую нагрузку на ЦП, поэтому, чтобы избежать компиляции всего, JVM ищет частые пути кода, идентифицирует их как горячие точки и компилирует только эти вещи. Для этого метод должен выполняться 10000 раз на серверной JVM и 1500 раз на клиентской JVM, не уверен в точном нет на клиентской виртуальной машине, его где-то около этого значения - person Arkantos; 27.01.2015
comment
Кроме того, теперь, когда JVM знает, как код, скорее всего, будет выполняться, он выполняет некоторые приличные оптимизации, такие как мономорфные вызовы статического разрешения, отправка биморфных вызовов с некоторым условием ветвления, встраивание методов, сворачивание констант, анализ Escape, в основном все преимущества, которые есть у компилятора через интерпретатор плюс несколько классных оптимизаций времени выполнения :) - person Arkantos; 27.01.2015

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

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

person Martin Dinov    schedule 25.01.2015
comment
Вы можете напрямую скомпилировать исходный код Java в собственный двоичный код, но это отрицает идею написания исходного кода один раз и возможности запускать его где угодно. Как? - person Koray Tugay; 25.01.2015
comment
Если вы напрямую компилируете для конкретной архитектуры, вы можете запускать скомпилированный код только на этой архитектуре. Чтобы запустить код на другой архитектуре, вам придется перекомпилировать для этой. Может мне стоит уточнить это в ответе. - person Martin Dinov; 25.01.2015
comment
Спасибо за подробный ответ, но то, что я на самом деле спрашиваю, все еще не ясно. Вы говорите: по сути, компиляция в байт-код промежуточного уровня, который затем запускает интерпретатор, является неотъемлемой частью процесса. Это то, чему я пытаюсь научиться. Что за процесс? Что сделано? Вы можете привести пример с этой воображаемой архитектурой? - person Koray Tugay; 25.01.2015
comment
Детали будут зависеть от конкретной JVM, если какой-то JIT реализован (что всегда будет иметь место в наши дни) и т. Д. Обычно интерпретатор в JVM считывает байт-код построчно, выполняя эту строку напрямую. Как? Изменяя внутреннее состояние JVM (помните, что JVM - это тип виртуальной машины). Внутри JVM может вызывать JIT-компилятор для компиляции байт-кода в собственный код, который затем сохраняет на будущее и может работать быстрее. В любом случае детали зависят от реализации JVM. - person Martin Dinov; 25.01.2015

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

person Piotr Praszmo    schedule 25.01.2015
comment
Но как это имитировать? - person Koray Tugay; 26.01.2015
comment
Посмотрите на первый фрагмент кода. Вместо того, чтобы создавать ЦП из силикона с использованием логических вентилей и триггеров, это делается на языке программирования высокого уровня с использованием управляющих структур и переменных. - person Piotr Praszmo; 26.01.2015
comment
Основываясь на вашем первом предложении, я собирался проголосовать за, но потом наткнулся на «требуется три вызова функций», что просто не соответствует действительности. Вы просто предполагаете, что операции со стеком - это вызовы функций. В настоящем переводчике они бы не были. Накладные расходы на интерпретацию должны состоять только из цикла выборки и отправки. Все остальное есть или должно быть таким же. - person user207421; 27.01.2015