【问题标题】:Just-in-Time compilation of Java bytecodeJava 字节码的即时编译
【发布时间】:2013-10-01 12:19:59
【问题描述】:

我们目前正在开发我们自己的 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 指令?如何翻译诸如throw/instanceof 和类似命令之类的内容?

【问题讨论】:

  • 这和C++有什么关系?
  • 嗯,虚拟机以及实际编译和生成方法的调用都是用 C++ 实现的,可能不是最相关的标签,但也很重要。
  • 您发布的代码都不是 C++。您在问如何在程序集中实现 Java 字节码。
  • “可行”是指“不会崩溃和烧毁”还是“运行良好,代码合理”?更一般地说,您为什么要创建自己的 JVM,包括 JIT 编译器?无论您是在做研究、只是在学习一些东西,还是试图获得良好的表现,我给出的答案都会有所不同。
  • Thomas:好的,但是标签现在应该不重要了.. @delnan:我所说的可行是指一个运行良好并产生合理代码的解决方案。 JVM 正在集成到我们开发的内核中,该内核仅用于托管 VM - 因此需要良好的性能。

标签: c++ assembly jvm jit vm-implementation


【解决方案1】:

这种编译方法有效,易于启动和运行,并且至少消除了解释开销。但它会导致大量代码和非常糟糕的性能。一个大问题是它以 1:1 音译堆栈操作,即使目标机器 (x86) 是 register 机器。正如您在发布的 sn-p 中看到的(以及 任何 其他代码),这总是会导致 每个 操作的多个堆栈操作操作码,因此它使用寄存器 - 哎呀,整个 ISA - 尽可能低效。

可以还支持复杂的控制流,例如异常。它与在解释器中实现它并没有太大区别。如果您想要良好的性能,您不想每次进入或退出try 块时都执行工作。 C++ 和其他 JVM 都使用了一些方案来避免这种情况(关键字:零成本或表驱动的异常处理)。这些实现、理解和调试都非常复杂且复杂,因此您应该首先使用更简单的替代方案。请记住这一点。

至于生成的代码:第一个优化,您几乎肯定会需要的,是将堆栈操作转换为三地址代码或其他使用寄存器的表示。有几篇关于这个和实现的论文,所以除非你想要我,否则我不会详细说明。然后,当然,您需要将这些虚拟寄存器映射到物理寄存器。寄存器分配是编译器构造中研究最充分的主题之一,至少有六种启发式方法相当有效且足够快,可以在 JIT 编译器中使用。我想到的一个例子是线性扫描寄存器分配(专门为 JIT 编译创建)。

除此之外,大多数专注于生成代码性能(而不是快速编译)的 JIT 编译器使用一种或多种中间格式并以这种形式优化程序。这基本上是您运行的磨机编译器优化套件,包括诸如常量传播、值编号、重新关联、循环不变代码运动等老手——这些东西不仅易于理解和实现,而且已经被描述三十年的文学史,包括教科书和维基百科。

您将通过上面获得的代码非常适合使用原语、数组和对象字段的直线代码。但是,您根本无法优化方法调用。每个方法都是虚拟的,这意味着内联甚至移动方法调用(例如循环外)基本上是不可能的,除非在非常特殊的情况下。您提到这是针对内核的。如果您可以接受在没有动态类加载的情况下使用 Java 的子集,那么您可以通过假设 JIT 知道所有类来做得更好(但这将是非标准的)。然后,例如,您可以检测叶类(或更一般地从不被覆盖的方法)并内联它们。

如果您确实需要动态类加载,但预计这种情况很少见,您也可以做得更好,尽管这需要更多的工作。优点是这种方法可以推广到其他事情,比如完全消除日志语句。基本思想是基于一些假设(例如,static 不会更改或没有加载新类)对代码进行专门化,然后对这些假设进行反优化如果。这意味着您有时必须在代码运行时重新编译代码(这很难,但并非不可能)。

如果你在这条路上走得更远,它的逻辑结论是基于跟踪的 JIT 编译,已经应用于 Java,但 AFAIK 并没有证明它优于基于方法的编译JIT 编译器。当您必须做出数十或数百个假设才能获得好的代码时,它会更有效,因为它发生在高度动态的语言中。

【讨论】:

  • +1,很好的答案!有一分钟我想开始写一个 Jitter :)。不过有两个问题-您说OP的建议仍然比解释要好。这与口译员实际所做的有什么不同?其次,您提到了 const-propagation、LICM 等 - 您能否指出仅在 @runtime 可用的 JIT 优化列表?是什么让 JIT 真正超越静态编译器?
  • @Leeor OP 的建议比普通解释器更好,因为它删除了通常必须在各个操作之间执行的字节码调度和相关代码。这些操作,即代码的忙碌结束,实际上做某事的部分,非常像解释器中的。
  • @Leeor 你的第二个问题是关于圣战的话题,一般来说很难回答。或多或少可以肯定的是,在某些 情况下,即使是推测性(是的,AOT 编译器也可以这样做)优化所需的大部分知识在编译时都不可用但在运行时可用。哪些优化会受此影响,以及这有多重要,各不相同,并受到激烈争论。
  • 感谢您的努力,很好的回答!这很有帮助。正如您所说,第一件事是优化寄存器的过多堆栈使用。如果您有兴趣,我可以在我们的实施处于高级状态时通知您;)
【解决方案2】:

关于你的 JIT 编译器的一些 cmets(我希望我不要写“delnan”已经写过的东西):

通用 cmets

我确信“真正的”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 虚拟机的 JIT 编译器会很容易,但 Java 中的对象会使虚拟机变得非常复杂。

对象只是作为指针存储在堆栈上或局部变量中。典型的 JIT 编译器会像这样实现:对于每个类,存在一块内存,其中包含有关类的信息(例如,存在哪些方法以及方法的汇编代码位于哪个地址等)。对象是一段内存,包含所有实例变量和指向包含类信息的内存的指针。

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

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

这里的问题是局部变量(同一个局部变量可能包含一个对象或一个数字)和 try/catch 块(“catch”块在恢复之前必须注意局部变量和包含对象的堆栈(!)堆栈指针)。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-10-18
    • 2011-09-20
    • 2018-05-13
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多