为简单起见,想象一下这个场景,我们有一台 2 位计算机,它有一对称为 r1 和 r2 的 2 位寄存器,并且仅适用于即时寻址。
假设位序列 00 表示添加到我们的 CPU。同样 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究竟将如何解释这段代码?我的意思是最终必须将相同的位模式传递给 CPU,不是吗?所有CPU都有许多可以理解和执行的指令,它们毕竟只是一些位。假设编译的 Java 字节码如下所示:
1111 1100 1001
什么的..这是否意味着解释在执行时将此代码更改为 0101 1010 0001?如果是,它已经在本机代码中,那么为什么说 JIT 仅在多次后启动呢?如果它没有将其完全转换为 0101 1010 0001,那么它会做什么?它如何使 CPU 进行添加?
也许我的假设中有一些错误。
我知道解释很慢,编译的代码更快但不便携,虚拟机"解释"代码,但是如何解释呢?我正在寻找"如何准确/技术解释"完成。欢迎任何指针(如书籍或网页)而不是答案。
不幸的是,您描述的 CPU 架构过于有限,无法通过所有中间步骤真正清楚地说明这一点。相反,我将编写伪 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到机器代码编译器发出的代码有很大不同。以下是 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
并非所有计算机都有相同的指令集。Java字节码是一种世界语 - 一种改善沟通的人工语言。Java VM 将通用 Java 字节码转换为运行它的计算机的指令集。
那么JIT在这里是如何发挥作用的呢?JIT 编译器的主要目的是优化。通常有不同的方法可以将某段字节码转换为目标机器代码。最理想的性能转换通常并不明显,因为它可能取决于数据。程序在不执行算法的情况下分析算法的程度也有限制 - 停止问题是一个众所周知的此类限制,但不是唯一的限制。因此,JIT 编译器所做的是尝试不同的可能转换,并测量它们使用程序处理的真实数据执行的速度。因此,在 JIT 编译器找到完美的翻译之前,需要多次执行。
中的一个重要步骤是编译器首先将.java
代码转换为包含 Java 字节码的.class
文件。这很有用,因为您可以获取.class
文件并在任何理解这种中间语言的机器上运行它们,然后逐行或逐块地翻译它。这是java编译器+解释器最重要的功能之一。你可以直接将Java源代码编译为原生二进制文件,但这否定了编写一次原始代码并能够在任何地方运行它的想法。这是因为编译的本机二进制代码将仅在编译它的相同硬件/操作系统体系结构上运行。如果要在另一个体系结构上运行它,则必须在该体系结构上重新编译源代码。编译到中级字节码后,不需要拖动源代码,而是拖动字节码。这是一个不同的问题,因为你现在需要一个可以解释和运行字节码的JVM。因此,编译为中级字节码,然后由解释器运行,是该过程的一个组成部分。
至于代码的实际实时运行:是的,JVM最终将解释/运行一些二进制代码,这些代码可能与本机编译的代码相同,也可能不同。在一行示例中,它们表面上可能看起来相同。但是解释通常不会预编译所有内容,而是通过字节码并逐行或逐块转换为二进制。这有利有弊(与本机编译的代码相比,例如 C 和 C 编译器)和大量在线资源可供进一步阅读。在这里看到我的答案,或者这个,或者这个。
简化一下,解释器是一个无限循环,里面有一个巨大的开关。它读取Java字节码(或一些内部表示)并模拟执行它的CPU。这样,真正的 CPU 执行模拟虚拟 CPU 的解释器代码。这是非常缓慢的。单个虚拟指令添加两个数字需要三次函数调用和许多其他操作。单个虚拟指令需要几个真实的指令来执行。这也降低了内存效率,因为您同时拥有真实和模拟堆栈、寄存器和指令指针。
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++)一样快。编译通常比解释慢得多,但只需要执行一次,并且只能针对选定的方法进行。