Java字节码的即时编译

6
我们目前正致力于我们自己的Java虚拟机实现中的JIT编译部分。我们的想法是将给定的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

这种方法会像虚拟机本身一样简单地使用堆栈。

  • 这种编译方法可行吗?
  • 是否可能以这种方式实现所有Java指令?如何转换类似athrow/instanceof等命令?

1
这与C++有什么关系? - Thomas Matthews
虚拟机和生成方法的实际编译和调用是用C++实现的,这可能不是最相关的标签,但也很重要。 - maxdev
“可行”是指“不会崩溃和烧毁”,还是指“工作良好,导致合理的代码”?更普遍地说,为什么要创建自己的JVM,包括JIT编译器?我会给出不同的答案,无论您是在进行研究,只是学习某些东西,还是试图获得良好的性能。 - user395760
Thomas: 好的,但标签现在并不重要。@delnan:我所说的可行性是指一个能够良好运作且产生合理代码的解决方案。JVM正在被集成到我们专门为托管VM而开发的内核中 - 因此良好的性能是必要的。 - maxdev
当然,这不是一个小项目,但这是我们的爱好,我们已经在内核/虚拟机上花费了很多时间。 :) 此外,我们喜欢自己解决大部分问题以获得更好的理解和学习 - 只需使用Oracle的VM规范实现即可。是的,我们进行了研究,但例如HotSpot的源代码并没有提供真正有见地的信息,除非阅读大量与主题无关的代码... - maxdev
显示剩余2条评论
2个回答

5
这种编译方法易于启动和运行,并且至少消除了解释开销。但是它会产生相当大量的代码和相当差的性能。一个大问题在于它进行逐字转换堆栈操作,即使目标机器(x86)是注册机器。正如您在发布的片段中看到的(以及任何其他代码),这总是导致几个堆栈操作码的多次操作,因此它使用寄存器 - 哎呀,整个ISA - 的效果最低。
您还可以支持复杂的控制流程,例如异常。这与在解释器中实现它并没有太大区别。如果想要良好的性能,则不希望在每次进入或退出try块时执行工作。有一些方案可以避免此问题,这些方案由C ++和其他JVM使用(关键字:零成本或表驱动异常处理)。这些都非常复杂,难以实现,理解和调试,因此您应该首先选择更简单的替代方案。只需记住即可。
至于生成的代码:第一个优化需要将堆栈操作转换为三地址代码或其他使用寄存器的表示形式。有几篇论文和实现介绍了此内容,因此我不打算详细讲解,除非您想要。然后,当然,您需要将这些虚拟寄存器映射到物理寄存器。寄存器分配是编译器构造中研究得最多的主题之一,至少有半打启发式算法可以在JIT编译器中使用且足够快速。我能想到的一个例子是线性扫描寄存器分配(专门为JIT编译创建)。
除此之外,大多数专注于生成代码性能(而不是快速编译)的JIT编译器都使用一种或多种中间格式,并在该格式中对程序进行优化。这基本上是您跑步的精益求精的编译器优化套件,包括常量传播,值编号,重新组合,循环不变代码移动等老手技巧。 - 这些东西不仅简单易懂,而且还被描述在三十年的文献中,包括教科书和维基百科。
以上方法生成的代码对于直线代码及使用原语,数组和对象字段的情况效果良好。但是您将无法优化方法调用。每个方法都是虚拟的,这意味着内联甚至移动方法调用(例如出循环)基本上是不可能的,除非在非常特殊的情况下。您提到这是用于内核。如果可以接受使用没有动态类加载的Java子集,则可以做得更好(但它将是非标准的),假设JIT知道所有类。然后,您可以检测叶类(或更一般地说,永远不会被覆盖的方法)并将其内联。
如果您确实需要动态类加载,但预计这是罕见的情况,您也可以做得更好,虽然需要更多的工作。优点是这种方法可以概括其他事物,比如完全消除日志记录语句。基本思路是根据一些假设(例如,此static不会改变或没有加载新类)专门化代码,然后在违反这些假设时进行解除优化。这意味着有时候必须在运行时重新编译代码(这很困难,但并非不可能)。
如果您走得更远,逻辑上的结论是跟踪为基础的JIT编译,它已经应用到Java中,但据我所知,它并没有被证明优于基于方法的JIT编译器。当您必须做出数十个或数百个假设以获得良好的代码时,它的效果更佳,比如高度动态的语言。

+1,非常好的答案!有一会儿我还想开始写Jitter :)。不过我有两个问题 - 你说OP的建议仍然比解释更好。这与解释器实际执行的操作有何不同?其次,你提到了const-propagation、LICM等等 - 你能指出一份只在运行时可用的JIT优化列表吗?什么会使JIT真正胜过静态编译器? - Leeor
@Leeor OP的建议比普通解释器更好,因为它消除了通常必须在各个操作之间执行的字节码分派和相关代码。这些操作,即代码的繁忙端,实际上做某事的部分,非常像解释器中的操作。 - user395760
@Leeor,你的第二个问题是一个神圣战争的话题,一般来说很难回答。更或多或少确定的是,在某些情况下,即使是预测性地优化(是的,AOT编译器也可以这样做),大部分所需的知识在编译时不可用,但在运行时可用。哪些优化受到影响,以及这有多重要,因人而异,存在激烈的争论。 - user395760
感谢您的努力,非常好的答案!这对我帮助很大。首先,正如您所说,第一件事将是将过度的堆栈使用优化到寄存器中。如果您有兴趣,我可以在我们的实现进入高级阶段后通知您 ;) - maxdev

2

关于您的JIT编译器,我想说几句(希望我没有写重复的内容):

一般性评论

我相信“真正的”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字节码,还需要查看存储在Java类中单独属性中的Try/Catch信息。可以按以下方式实现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!
}

对象

一个不支持对象(和数组)的简化Java虚拟机的即时编译器会很容易,但在Java中的对象使得虚拟机变得非常复杂。

对象只是作为指向堆栈或本地变量中对象的指针进行存储。通常,即时编译器将像这样实现:对于每个类,存在一个包含有关该类的信息的内存块(例如,哪些方法存在以及该方法的汇编代码位于哪个地址等)。对象是一些内存块,其中包含所有实例变量和指向包含有关类别信息的内存的指针。

“Instanceof”和“checkcast”可以通过查看指向包含有关类别信息的内存的指针来实现。此信息可能包含所有父类和实现接口的列表。

然而,对象的主要问题是Java中的内存管理:与C ++不同,Java中有“new”但没有“delete”。您必须检查对象使用的频率。如果对象不再使用,则必须从内存中删除该对象并调用析构函数。

这里的问题是本地变量(相同的本地变量可能包含对象或数字)和try / catch块(“catch”块必须照顾好本地变量和堆栈(!)包含对象,然后才能恢复堆栈指针)。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接