1. 概述

编程语言根据其抽象层级可以分为高阶语言(如 Java、Python、JavaScript、C++、Go)、低阶语言(汇编)以及机器码。

每种高级语言代码,比如 Java,都需要被翻译成机器原生代码才能执行。这个翻译过程可以是编译(Compilation)或解释(Interpretation),当然也有第三种方式:混合使用两种机制,以兼顾效率与灵活性。

本文将深入探讨 Java 是如何在不同平台上进行编译和运行的,并结合 JVM 的设计特性来判断 Java 到底属于编译型语言、解释型语言,还是两者的结合体。

2. 编译型 vs 解释型语言

我们先来看看 编译型语言与解释型语言的基本区别

2.1. 编译型语言

编译型语言(例如 C++、Go)会通过编译器直接转换为机器原生代码。

它们通常需要一个显式的构建步骤才能运行,所以每次修改代码后都需要重新构建程序。

编译型语言通常比解释型语言更快更高效,但生成的机器码是平台相关的。

2.2. 解释型语言

解释型语言(如 Python、JavaScript)则没有构建步骤。解释器会在程序运行时逐行读取并执行源代码。

过去解释型语言被认为明显慢于编译型语言。但随着即时编译(JIT)技术的发展,性能差距正在缩小。需要注意的是,JIT 编译器也会在运行过程中将解释型语言的代码转化为机器原生代码。

⚠️ 此外,解释型语言的代码可以在 Windows、Linux、Mac 等多个平台上运行,因为它不依赖特定的 CPU 架构。

3. Write Once, Run Anywhere(一次编写,到处运行)

Java 和 JVM 在设计之初就考虑到了可移植性。因此目前大多数主流平台都能运行 Java 代码。

这听起来像是暗示 Java 是纯解释型语言。但实际上,在执行前,**Java 源代码必须先被编译为 字节码**。字节码是一种专用于 JVM 的机器语言。

JVM 在运行时解释并执行这些字节码。

真正为各个平台定制的是 JVM,而不是我们的程序或库。

现代 JVM 还集成了 JIT 编译器。✅ 这意味着 JVM 可以在运行时优化我们的代码,从而获得接近编译型语言的性能表现。

4. Java 编译器

命令行工具 javac 负责编译 Java 源代码到包含平台无关字节码的 class 文件中

$ javac HelloWorld.java

源文件以 .java 结尾,而生成的 class 文件以 .class 结尾。

java compilation 3

5. Java 虚拟机(JVM)

编译后的 class 文件(即字节码)可以由 Java 虚拟机(JVM)执行

$ java HelloWorld
Hello Java!

下面我们将深入了解 JVM 架构,目标是搞清楚字节码是如何在运行时被转化为机器原生代码的。

5.1. 架构概览

JVM 由五个子系统组成:

  • ClassLoader(类加载器)
  • JVM 内存
  • 执行引擎
  • 本地方法接口(JNI)
  • 本地方法库

jvm arhitecture

5.2. ClassLoader(类加载器)

JVM 使用 ClassLoader 子系统将已编译的类文件加载进 JVM 内存

除了加载之外,ClassLoader 还负责链接和初始化,包括:

  • 验证字节码是否存在安全漏洞
  • 为静态变量分配内存
  • 将符号引用替换为原始引用
  • 给静态变量赋初始值
  • 执行所有静态代码块

5.3. 执行引擎

执行引擎负责 读取字节码、将其转化为机器原生代码并执行

它由三个主要组件构成:

  • 因为 JVM 是平台无关的,所以它使用解释器来执行字节码
  • JIT 编译器 通过将字节码编译为原生代码来提升频繁调用的方法性能
  • 垃圾回收器 用来回收不再使用的对象

执行引擎通过 本地方法接口(JNI) 调用本地库和应用程序。

5.4. JIT 编译器

解释器的一个缺点是:每次调用方法都需要重新解释,这比直接运行编译后的原生代码要慢得多。Java 引入 JIT 编译器来解决这个问题。

JIT 编译器并不会完全取代解释器,执行引擎仍然会使用解释器。但 JVM 会根据方法的调用频率决定是否启用 JIT 编译。

JIT 编译器会将整个方法的字节码编译为机器原生代码,这样就可以重复使用。就像传统编译器一样,包括中间代码生成、优化和最终生成原生代码的过程。

JIT 编译器中的 Profiler 组件负责识别热点代码(Hotspot)。JVM 根据运行时收集的 Profile 信息决定哪些代码需要被 JIT 编译。

jit compiler1

这也意味着 Java 程序可能在运行几次之后变得越来越快。一旦 JVM 识别出热点代码,就可以提前编译为原生代码,提高执行速度。

6. 性能对比测试

我们来看看 JIT 编译是如何提升 Java 运行时性能的。

6.1. 斐波那契数列性能测试

我们使用一个简单的递归方法来计算第 n 个斐波那契数:

private static int fibonacci(int index) {
    if (index <= 1) {
        return index;
    }
    return fibonacci(index-1) + fibonacci(index-2);
}

为了测量重复方法调用带来的性能差异,我们将斐波那契方法运行 100 次:

for (int i = 0; i < 100; i++) {
    long startTime = System.nanoTime();
    int result = fibonacci(12);
    long totalTime = System.nanoTime() - startTime;
    System.out.println(totalTime);
}

首先,正常编译并运行 Java 代码:

$ java Fibonacci.java

然后,禁用 JIT 编译器再次运行:

$ java -Djava.compiler=NONE Fibonacci.java

最后,我们还实现了相同的算法并运行了 C++ 和 JavaScript 版本作为对比。

6.2. 测试结果分析

以下是运行斐波那契递归测试后测得的平均性能(单位:纳秒):

  • ✅ Java 使用 JIT 编译器 —— 2726 ns —— 最快
  • ❌ Java 禁用 JIT 编译器 —— 17965 ns —— 慢了 559%
  • ❌ C++ 未开启 O2 优化 —— 9435 ns —— 慢了 246%
  • ⚠️ C++ 开启 O2 优化 —— 3639 ns —— 慢了 33%
  • ❌ JavaScript —— 22998 ns —— 慢了 743%

在这个例子中,使用 JIT 编译器的 Java 性能提升了 500% 以上。当然,JIT 编译器需要运行几轮才会生效。

有趣的是,即使 C++ 启用了 O2 优化,Java 的表现依然比它快了 33%。正如预期的那样,在前几次运行中,C++ 明显优于 Java,因为此时 Java 还处于解释执行阶段。

同时,Java 也远超 Node.js 运行的 JavaScript 代码(Node.js 也使用 JIT)。结果显示 Java 快了 700% 多。主要原因在于 Java 的 JIT 编译器启动得更快

7. 其他值得思考的问题

从技术角度看,任何静态编程语言都可以直接编译为机器码;同样地,任何语言的代码也可以被逐行解释执行。

像很多现代语言一样,Java 采用的是编译器 + 解释器的组合策略,目的是兼具两者的优点:✅ 高性能 + 平台无关性

本文主要基于 HotSpot JVM 展开讲解。HotSpot 是 Oracle 默认的开源 JVM 实现。Graal VM 也是基于 HotSpot 的,因此适用相同原理。

当前主流的 JVM 实现大多采用 解释器 + JIT 编译器 的混合模式。当然,也可能存在采用其他机制的实现方式。

8. 总结

本文深入剖析了 Java 和 JVM 的内部机制,目的是判断 Java 属于编译型语言还是解释型语言。我们分析了 Java 编译器和 JVM 执行引擎的工作流程。

结论是:✅ Java 是编译型语言和解释型语言的结合体

我们写的 Java 源代码首先在构建过程中被编译为字节码。然后 JVM 解释执行这些字节码。但在运行时,JVM 也会借助 JIT 编译器对热点代码进行编译优化,从而提升整体性能。

一如既往,示例代码可以在 GitHub 上获取


原始标题:Is Java a Compiled or Interpreted Language? | Baeldung