【问题标题】:How does an interpreter interpret the code?解释器如何解释代码?
【发布时间】:2015-03-23 23:48:19
【问题描述】:

为了简单起见,想象一下这个场景,我们有一台 2 位计算机,它有一对 2 位寄存器,分别称为 r1 和 r2,并且只适用于立即寻址。

假设位序列 00 表示 add 到我们的 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?如果是,它已经在 Native Code 中了,那为什么说 JIT 只在多次后才生效呢?如果它没有将它完全转换为 0101 1010 0001,那么它会做什么?它是如何让cpu做加法的?

也许我的假设有一些错误。

我知道解释很慢,编译的代码更快但不可移植,虚拟机“解释”代码,但如何?我正在寻找“如何准确/技术解释”。欢迎任何指针(例如书籍或网页)而不是答案。

【问题讨论】:

    标签: java compilation interpreter bytecode interpreted-language


    【解决方案1】:

    Java 中的一个重要步骤是编译器首先将.java 代码转换为包含Java 字节码的.class 文件。这很有用,因为您可以获取 .class 文件并在任何理解这种中间语言的机器上运行它们,然后在现场逐行或逐块翻译它.这是java编译器+解释器最重要的功能之一。您可以直接将 Java 源代码编译为本机二进制文件,但这否定了编写一次原始代码并能够在任何地方运行它的想法。这是因为编译后的本机二进制代码只能在编译它的硬件/操作系统架构上运行。如果你想在另一个架构上运行它,你必须在那个架构上重新编译源代码。通过编译到中级字节码,您不需要拖拽源代码,而是拖拽字节码。这是一个不同的问题,因为您现在需要一个可以解释和运行字节码的 JVM。因此,编译成中间级字节码,然后解释器运行,是该过程的一个组成部分。

    至于代码的实际实时运行:是的,JVM 最终会解释/运行一些二进制代码,这些二进制代码可能与本机编译的代码相同,也可能不同。在一个单行示例中,它们可能表面上看起来相同。但是解释通常不会预编译所有内容,而是通过字节码并逐行或逐块转换为二进制。这有利有弊(与本机编译的代码相比,例如 C 和 C 编译器)和大量在线资源可供进一步阅读。看我的回答here,或者this,或者this一个。

    【讨论】:

    • "你可以直接将 Java 源代码编译为原生二进制文件,但这否定了编写一次原始代码并能够在任何地方运行它的想法。"怎么样?
    • 如果直接针对特定架构进行编译,则只能在该架构上运行编译后的代码。要在另一种架构上运行代码,您必须为该架构重新编译。也许我应该在答案中澄清这一点。
    • 感谢您的详细回答,但我实际要问的仍然不清楚。您是在说:“因此,编译为解释器然后运行的中间级字节码是该过程不可或缺的一部分。”这是我正在努力学习的。什么样的过程?做什么?你能举一个这个想象中的架构的例子吗?
    • 细节将取决于确切的 JVM,是否实现了某种 JIT(现在总是如此)等。通常,JVM 中的解释器逐行读取字节码,运行直接线。如何?通过修改 JVM 的内部状态(记住 JVM 是一种虚拟机)。在内部,JVM 可以调用 JIT 编译器将字节码编译为本机代码,然后存储以备将来使用,并且可以更快地运行。在任何情况下,细节都是特定于 JVM 实现的。
    【解决方案2】:

    并非所有计算机都有相同的指令集。 Java 字节码是一种世界语——一种用于改善交流的人工语言。 Java VM 将通用 Java 字节码转换为运行它的计算机的指令集。

    那么 JIT 是如何发挥作用的呢? JIT 编译器的主要目的是优化。通常有不同的方法可以将某段字节码翻译成目标机器码。性能最理想的翻译通常不明显,因为它可能取决于数据。程序在不执行算法的情况下分析算法的程度也有限制——halting problem 是一个众所周知的限制,但不是唯一的限制。因此,JIT 编译器所做的是尝试不同的可能翻译,并使用程序处理的真实数据测量它们的执行速度。因此,在 JIT 编译器找到完美的翻译之前,需要多次执行。

    【讨论】:

    • 所以基本上,每次将代码编译为给定架构的本机代码。但是一旦找到了最快的版本,JIT 会最后编译一次吗?
    • @KorayTugay 已经有编译版本了,为什么还要编译呢?
    • 好吧,你说“所以在 JIT 编译器找到完美的翻译之前,它需要多次执行。”。而执行意味着字节码到原生代码不是吗,这叫编译?
    • 编译代码会有一些 CPU 开销,因此为了避免编译所有内容,JVM 会查找频繁的代码路径,将它们识别为热点并只编译那些东西。为此,一个方法必须在服务器 JVM 上执行 10000 次,在客户端 JVM 上执行 1500 次,不确定客户端 vm 上的确切否,它在该值附近
    • 此外,既然 JVM 知道代码最有可能如何执行,它会进行一些不错的优化,例如对静态解析的单态调用、带有某些分支条件的双态调用调度、方法内联、常量折叠、转义分析,基本上编译器比解释器的所有优势加上一些很酷的运行时优化:)
    【解决方案3】:

    不幸的是,您描述的 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 编译器可能会将上述内容转换为 (pseudo-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
    

    【讨论】:

    • 我们可以说在您的示例中 JIT 已经插入以添加值但存储仍然被解释吗?顺便说一句,答案很好,谢谢。
    • @KorayTugay 我不会说“存储仍然被解释”。这些存储的位置发生了变化,存储发生的方式也发生了变化,JIT 非常清楚地知道哪个存储影响了哪块内存。寄存器改组略微次优(进一步优化后,第一条指令将使用eax 而不是ebx,第三条指令将被删除)但它的编译非常清晰。
    • @Koray Tugay:不,实际上,存储意味着修改堆。因此,解释器和编译代码可能有完全不同的方式来处理局部变量和堆栈,只要它们同意堆。对于这个简单的例子,如果 HotSpot 启动,它会发现堆从未被修改过并且没有返回结果,因此它将移除整个计算。编译代码以 1:1 反映相关字节码的时代是 20 年前……
    • @Holger 好吧,我确实试图让问题保持简单
    【解决方案4】:

    简单地说,解释器是一个无限循环,里面有一个巨大的开关。 它读取 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;
            ...
    
        }
    }
    

    native code 生成后,JVM 会将其复制到某处,将此区域标记为可执行,并指示解释器在下次调用此方法时调用它而不是解释字节码。 单个虚拟指令可能仍需要多条本机指令,但这几乎与提前编译为本机代码(如 C 或 C++)一样快。 编译通常比解释慢得多,但只需要执行一次,并且只针对选定的方法。

    【讨论】:

    • 但是它是如何模拟的呢?
    • 看第一个代码sn -p。它不是使用逻辑门和触发器在硅胶中创建 CPU,而是使用控制结构和变量以高级编程语言完成。
    • 根据您的第一句话,我打算投票,但后来我遇到了“需要三个函数调用”,这简直是不真实的。您只是假设堆栈操作是函数调用。在真正的口译员中,他们不会。解释开销只需要包括获取和调度周期。其他一切都是或应该是相同的。
    猜你喜欢
    • 2011-04-11
    • 2018-01-19
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-07-20
    • 2010-10-11
    • 2011-09-25
    相关资源
    最近更新 更多