В настоящее время мы работаем над JIT-компиляцией в нашей собственной реализации Java Virtual Machine. Наша идея заключалась в том, чтобы сделать простой перевод заданного Java-байт-кода в коды операций, записав их в исполняемую память и вызывая непосредственно в начале метода.
Предполагая, что данный код Java будет:
int a = 13372338;
int b = 32 * a;
return b;
Теперь был сделан следующий подход (предполагая, что данная память начинается с 0x1000 & возвращаемое значение ожидается в eax):
0x1000: first local variable - accessible via [eip - 8]
0x1004: second local variable - accessible via [eip - 4]
0x1008: start of the code - accessible via [eip]
Java bytecode | Assembler code (NASM syntax)
--------------|------------------------------------------------------------------
| // start
| mov edx, eip
| push ebx
|
| // method content
ldc | mov eax, 13372338
| push eax
istore_0 | pop eax
| mov [edx - 8], eax
bipush | push 32
iload_0 | mov eax, [edx - 8]
| push eax
imul | pop ebx
| pop eax
| mul ebx
| push eax
istore_1 | pop eax
| mov [edx - 4], eax
iload_1 | mov eax, [edx - 4]
| push eax
ireturn | pop eax
|
| // end
| pop ebx
| ret
Это будет просто использовать стек так же, как виртуальная машина сама.
Вопросы относительно этого решения:
Этот метод компиляции работает, его легко запустить и запустить, и он, по крайней мере, устраняет накладные расходы на интерпретацию. Но это приводит к довольно большим объемам кода и довольно ужасной производительности. Одна большая проблема заключается в том, что он транслитерирует операции стека 1: 1, хотя целевой компьютер (x86) является регистр машина. Как вы можете видеть в размещенном вами фрагменте (а также любой другой код), это всегда приводит к нескольким кодам операций манипуляции стека для каждый операция, поэтому он использует регистры — черт возьми, весь ISA — примерно настолько неэффективно, насколько это возможно.
Вы Можно также поддерживают сложные потоки управления, такие как исключения. Это не очень отличается от реализации в интерпретаторе. Если вы хотите хорошую производительность, вы не хотите выполнять работу каждый раз, когда вы входите или выходите из try
блок. Существуют схемы, позволяющие этого избежать, используемые как C ++, так и другими JVM (ключевое слово: обработка исключений с нулевой стоимостью или на основе таблиц). Они довольно сложны и сложны в реализации, понимании и отладке, поэтому сначала вам нужно выбрать более простую альтернативу. Просто имейте это в виду.
Что касается сгенерированного кода: первая оптимизация, которая вам почти наверняка понадобится, — это преобразование операций стека в трехадресный код или в другое представление, использующее регистры. Есть несколько работ по этому вопросу и реализации этого, поэтому я не буду подробно останавливаться, если вы не хотите, чтобы я это сделал. Затем, конечно, вам нужно отобразить эти виртуальные регистры на физические регистры. Распределение регистров является одной из наиболее хорошо изученных тем в конструкциях компилятора, и существует по крайней мере полдюжины эвристик, которые достаточно эффективны и достаточно быстры для использования в компиляторе JIT. Один из примеров, который стоит передо мной, — это распределение регистров с линейным сканированием (специально для компиляции JIT).
Кроме того, большинство JIT-компиляторов, ориентированных на производительность сгенерированного кода (в отличие от быстрой компиляции), используют один или несколько промежуточных форматов и оптимизируют программы в этой форме. Это в основном ваш прогон комплекта оптимизации компилятора, включая такие ветераны, как постоянное распространение, нумерация значений, повторная ассоциация, движение инвариантного кода цикла и т. Д. — эти вещи не только просты для понимания и реализации, они также были описаны за тридцать лет литературы вплоть до учебников и википедии.
Код, который вы получите с помощью вышеупомянутого, будет очень хорош для строкового кода, использующего примитивы, массивы и поля объекта. Однако вы вообще не сможете оптимизировать вызовы методов. Каждый метод является виртуальным, что означает, что встроенные или даже перемещаемые вызовы методов (например, вне цикла) в принципе невозможны, за исключением очень особых случаев. Вы упомянули, что это для ядра. Если вы можете согласиться с использованием подмножества Java без динамической загрузки классов, вы можете добиться большего успеха (но это будет нестандартно), предполагая, что JIT знает все классы. Затем вы можете, например, обнаружить листовые классы (или, в более общем случае, методы, которые никогда не переопределяются) и встроить их.
Если вам нужна динамическая загрузка классов, но вы ожидаете, что она будет редкой, вы также можете добиться большего, хотя это требует больше работы. Преимущество состоит в том, что этот подход обобщает другие вещи, например, полное исключение операторов регистрации. Основная идея заключается в специализации кода, основанного на некоторых предположениях (например, что это static
не меняется или что новые классы не загружаются), то де-оптимизация если эти предположения нарушаются. Это означает, что вам иногда придется перекомпилировать код во время его работы (это жесткий, но не невозможно).
Если вы пойдете дальше по этому пути, его логический вывод — это компиляция JIT на основе трассировки, которая имеет был применен к Java, но AFAIK не оказался лучше JIT-компиляторов на основе методов. Это более эффективно, когда вам нужно сделать десятки или сотни предположений, чтобы получить хороший код, как это происходит с высокодинамичными языками.
Некоторые комментарии о вашем JIT-компиляторе (надеюсь, я не пишу вещи, которые «delnan» уже написал):
Общие комментарии
Я уверен, что «настоящие» JIT-компиляторы работают аналогично вашему. Однако вы можете провести некоторую оптимизацию (пример: «mov eax, nnn» и «push eax» можно заменить на «push nnn»).
Вы должны хранить локальные переменные в стеке; обычно в качестве локального указателя используется «ebp»:
push ebx
push ebp
sub esp, 8 // 2 variables with 4 bytes each
mov ebp, esp
// Now local variables are addressed using [ebp+0] and [ebp+4]
...
pop ebp
pop ebx
ret
Это необходимо, потому что функции могут быть рекурсивными. Хранение переменной в фиксированном месте (относительно EIP) приведет к тому, что переменные будут вести себя как «статические». (Я предполагаю, что вы не компилируете функцию несколько раз в случае рекурсивной функции.)
Попробуй поймать
Для реализации Try / Catch ваш JIT-компилятор должен смотреть не только на байт-код Java, но также на информацию Try / Catch, которая хранится в отдельном атрибуте в классе Java. Try / catch может быть реализован следующим образом:
// push all useful registers (= the ones, that must not be destroyed)
push eax
push ebp
...
// push the "catch" pointers
push dword ptr catch_pointer
push dword ptr catch_stack
// set the "catch" pointers
mov catch_stack,esp
mov dword ptr catch_pointer, my_catch
... // some code
// Here some "throw" instruction...
push exception
jmp dword ptr catch_pointer
... //some code
// End of the "try" section: Pop all registers
pop dword_ptr catch_stack
...
pop eax
...
// The "catch" block
my_catch:
pop ecx // pop the Exception from the stack
mov esp, catch_stack // restore the stack
// Now restore all registers (same as at the end of the "try" section)
pop dword_ptr catch_stack
...
pop eax
push ecx // push the Exception to the stack
В многопоточной среде каждый поток требует своих собственных переменных catch_stack и catch_pointer!
Определенные типы исключений могут быть обработаны с помощью «instanceof» следующим образом:
try {
// some code
} catch(MyException1 ex) {
// code 1
} catch(MyException2 ex) {
// code 2
}
… на самом деле компилируется так:
try {
// some code
} catch(Throwable ex) {
if(ex instanceof MyException1) {
// code 1
}
else if(ex instanceof MyException2) {
// code 2
}
else throw(ex); // not handled!
}
Объекты
JIT-компилятор упрощенной виртуальной машины Java, не поддерживающей объекты (и массивы), будет довольно простым, но объекты в Java делают виртуальную машину очень сложной.
Объекты просто хранятся в виде указателей на объект в стеке или в локальных переменных. Обычно JIT-компиляторы реализуются следующим образом: для каждого класса существует часть памяти, которая содержит информацию о классе (например, какие методы существуют и по какому адресу находится ассемблерный код метода и т. Д.). Объект — это часть памяти, которая содержит все переменные экземпляра и указатель на память, содержащую информацию о классе.
«Instanceof» и «checkcast» можно реализовать, посмотрев на указатель на память, содержащую информацию о классе. Эта информация может содержать список всех родительских классов и реализованных интерфейсов.
Однако основной проблемой объектов является управление памятью в Java: в отличие от C ++ существует «новый», но нет «delete». Вы должны проверить, как часто объект используется. Если объект больше не используется, его необходимо удалить из памяти и вызвать деструктор.
Проблемы здесь — это локальные переменные (одна и та же локальная переменная может содержать объект или число) и блоки try / catch (блок «catch» должен заботиться о локальных переменных и объектах стека (!) Перед восстановлением указателя стека). ).