Java代码是怎么运行的?

写在前面

Java代码有很多种不同的运行方式,比如在开发工具中运行、双击jar文件运行、在命令行中运行、在网页中运行等,不过这些执行方式都离不开JRE,即Java运行时环境(Java Runtime Environment)。

实际上,JRE仅包含Java程序的必需组件,包括Java虚拟机以及Java核心类库等。我们通常接触的JDK(Java开发工具包)同样包含了JRE,并且还附带了一系列开发、诊断工具。

为什么 Java 要在虚拟机里运行?

Java作为一门高级程序语言,它的语法非常复杂,抽象程度也很高。因此直接在硬件上运行这种复杂的程序不太现实。所以,在运行 Java 程序之前需要对其进行一番转换。(C++ 的策略是直接编译成目标架构的机器码,Java的策略是编译成一个虚拟架构的机器码。)

这个转换操作是怎么操作的呢?当前主流思路是这样:设计一个面向Java语言特性的虚拟机,并通过编译器将 Java 程序转换成该虚拟机能识别的指令序列,即 Java 字节码(Java字节码指令的操作码被固定为一个字节)。

Java虚拟机可以由硬件(Java procesor)实现,但更为常见的是在各个现有平台(如Windows ——x64、Linux_aarch64)上提供软件实现。这样一来,一旦一个程序被转换成 Java 字节码,它就可以在不同平台实现的虚拟机实现里运行,即“一次编译,到处运行。”

除此之外,虚拟机还提供了一个托管环境(Managed Runtime)。这个托管环境能够代替我们处理一些代码中冗长而且容易出错的部分。比如:自动内存管理与垃圾回收、数据越界、动态类型、安全权限等等动态监测。

Java 虚拟机具体怎样运行 Java 字节码的?

虚拟机角度

执行 Java 代码首先需要将它编译成的 class 文件加载到 Java 虚拟机中。加载后的 Java 类会被存放于方法区(Method Area)中,实际运行时,虚拟机会执行方法区内的代码。

Java虚拟机会将内存划分为堆和栈来存储运行时数据。Java 虚拟机将栈细分为面向 Java 方法的 Java 方法栈,面向本地方法(用C++写的 native 方法)的本地方法栈,以及存放各个线程执行位置的 PC 寄存器。

在运行过程中,每当调用进入一个 Java 方法,Java虚拟机会在当前线程的 Java 方法栈中生成一个栈帧,用以存放局部变量以及字节码的操作数。这个栈帧的大小是提前计算好的,而且 Java 虚拟机不要求栈帧在内存空间里连续分布。

当退出当前执行的方法时,不管是正常返回还是异常返回,Java 虚拟机均会弹出当前线程的当前栈帧,并将之舍弃。

硬件角度

Java 字节码无法直接执行,需要 Java 虚拟机将字节码翻译成机器码。

在HotSpot中,上述翻译过程有两种形式:第一种是解释执行,即逐条将字节码翻译成机器码并执行;第二种是即时翻译(Just-In-Time compilation, JIT),即将一个方法中包含的所有字节码翻译成机器码后再执行。

前者优势是无需等待编译,后者优势在于实际运行速度更快。HotSpot 默认采用混合模式,综合解释执行和即时编译的优点。它会先解释执行字节码,而后将其中反复执行的热点代码,以方法为单位进行即时编译。

Java 虚拟机的运行效率是怎么样的?

HotSpot 采用了多种技术来提升启动性能以及峰值性能。

即时编译建立在程序符合二八定律的假设之上,即百分之二十的代码占用了百分之八十的计算资源。对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。

理论上讲,即时编译后的 Java 程序的执行效率是可能超过 C++ 程序的。这是因为与静态编译相比,即时编译拥有程序的运行时信息,并且可以根据这个信息作出相应优化。比如:一个虚方法的调用,尽管它有很多个目标方法,但是在实际运行过程中它可能只调用其中的一个。这个信息就可以被即时编译器所利用,来规避虚方法调用的开销,从而达到比静态编译的 C++ 程序更高的性能。

为了满足不同用户场景的需要,HotSpot 内置了多个即时编译器:C1、C2 和 Graal。其中 Graal 是Java 10正式引入的实验性即时编译器。这样以来就可以在编译时间和生成代码的执行效率上进行取舍。

C1 又叫做 Client 编译器,面向的是对启动性能有要求的客户端 GUI 程序,采用的优化手段相对简单,因此编译时间较短。

C2 又叫做 Server 编译器,面向的是对峰值性能有要求的服务端程序,采用的优化手段相对复杂,因此编译时间较长,但同时生成代码的执行效率较高。

从 Java 7 开始,HotSpot 的即时编译是放在额外的编译栈程中进行的。HotSpot 会根据 CPU 的数量设置编译线程的数目,并且按 1:2 的比例配置还给 C1 和 C2编译器。

在资源充足的情况下,字节码的解释执行和即时编译可同时进行。编译完成后的机器码会在下次调用该方法时启用,以替换原本的解释执行。

问题

Java 语言和 Java 虚拟机看待boolean类型的方式是否不同?
Java 虚拟机将 boolean 类型看成 int 型 0 和 1 进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
// Foo.java
public class Foo {
public static void main(String[] args) {
boolean flag = true;
if (flag) {
System.out.println("Hello, Java!");
}
if (flag == true) {
System.out.println("Hello, JVM!");
}
}
}
1
2
3
4
5
6
7
8
9
10
$ # linux环境
$ javac Foo.java
$ java Foo
$ # 两个都打印
$ # 反汇编器与汇编器 将flag对应的值iconst_1改成了iconst_2
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jdis.Main Foo.class > Foo.jasm.1
$ awk 'NR==1,/iconst_1/{sub(/iconst_1/, "iconst_2")} 1' Foo.jasm.1 > Foo.jasm
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jasm.Main Foo.jasm
$ java Foo
$ # 第二个不打印

其他

热点代码探测

热点代码探测有两种算法——基于采样的热点探测和基于计数器的热点探测。

一般采样的是基于计数器的热点探测,这种算法有两个计数器,方法调用计数器和回边计数器,它们在 C1 和 C2 中有不同的阈值。

对于性能要求高的 web 应用为什么不直接使用即时编译器在启动时全部编译成机器码呢?

对于长时间运行的程序来说,大部分编译就发生在前几个小时。再之后的即时编译主要是一些非热点代码,以及即时编译器中的 bug 造成的反复去优化重新编译。JVM 这么做主要也是看中字节码的可移植性,从而牺牲了启动性能。JVM 也引入了 AOT 编译,在线下将 Java 代码编译成可链接库。

为什么理论上比 C++ 快?

实际上会插入一些虚拟机相关的代码。现代编译器一般都分为平台无关的前端和平台相关的后端。如果要生成某个平台的代码,编译器会选择相应的后端。因此,无论是 C 编译器还是 JIT 编译器,都是基于目标 CPU 的指令集来做优化的。