Java代码有很多种不同的运行方式,比如可以在开发工具中运行,可以双击执行 jar文件运行,也可以在命令行中运行,甚至可以在网页中运行。但这些执行方式都离不开JRE(Java Runtime Environment),也就是Java 运行时环境。
Java 程序员经常接触到的 JDK(Java 开发工具包)同样包含了 JRE,并且还附带了一系列开发、诊断工具(如 jps,jstat,jmap,jconsole等),JRE仅包含运行Java 程序的必需组件,包括Java 虚拟机以及Java核心类库等。
Java 作为一门高级程序语言,语法非常复杂,抽象程度也很高。因此,直接在硬件上运行这种复杂的程序并不现实。所以在运行 Java 程序之前需要对其进行一番转换。这个转换具体是怎么操作的呢?当前的主流思路是这样子的:
设计一个面向 Java 语言特性的虚拟机,并通过编译器将 Java 程序转换成该虚拟机所能识别的指令序列,也称 Java 字节码(之所以这么取名,是因为 Java 字节码指令的操作码(opcode)被固定为一个字节)。
我们可以分别用C和java语言来实现hello world举例,将其编译后的代码用反汇编工具转换成汇编语言来做比较:
; C语言实现
; 最左列是偏移;中间列是给机器读的机器码;最右列是给人读的汇编代码
0x00: 55 push rbp
0x01: 48 89 e5 mov rbp,rsp
0x04: 48 83 ec 10 sub rsp,0x10
0x08: 48 8d 3d 3b 00 00 00 lea rdi,[rip+0x3b]
; 加载 "Hello, World!\n"
0x0f: c7 45 fc 00 00 00 00 mov DWORD PTR [rbp-0x4],0x0
0x16: b0 00 mov al,0x0
0x18: e8 0d 00 00 00 call 0x12
; 调用 printf 方法
0x1d: 31 c9 xor ecx,ecx
0x1f: 89 45 f8 mov DWORD PTR [rbp-0x8],eax
0x22: 89 c8 mov eax,ecx
0x24: 48 83 c4 10 add rsp,0x10
0x28: 5d pop rbp
0x29: c3 ret
# Java语言实现
# 最左列是偏移;中间列是给虚拟机读的机器码;最右列是给人读的代码
0x00: b2 00 02 getstatic java.lang.System.out
0x03: 12 03 ldc "Hello, World!"
0x05: b6 00 04 invokevirtual java.io.PrintStream.println
0x08: b1 return
可以看到,Java语言与 C语言编译生成的机器码(中间列)是一样,都是由一个个字节组成的。不同的是,反汇编之后(右边列显示的内容),Java 版本的编译结果相对精简一些。这是因为 Java 虚拟机相对于物理机而言,抽象程度更高。
Java 虚拟机可以由硬件实现 ,但更为常见的是在各个现有平台(如 Windows_x64、Linux_aarch64)上提供软件实现。这么做可以使我们受益于以下几点:
- 一旦一个程序被转换成 Java 字节码,那么它便可以在不同平台上的虚拟机实现里运行。这也就是我们经常说的“一次编写,到处运行”。
- 虚拟机可以为Java代码的运行提供一个托管环境(Managed Runtime)。这个托管环境能够代替我们处理一些代码中冗长而且容易出错的部分。其中最广为人知的当属自动内存管理与垃圾回收,除此之外,托管环境还提供了诸如数组越界、动态类型、安全权限等等的动态检测,使我们免于书写这些无关业务逻辑的代码。
#Java虚拟机具体是怎样运行 Java 字节码的?
下面以标准 JDK 中的 HotSpot 虚拟机为例,从虚拟机以及底层硬件两个角度,介绍一下Java 虚拟机具体是怎么运行 Java 字节码的。
从虚拟机视角来看,执行 Java 代码首先需要将它编译而成的 class 文件加载到 Java 虚拟机中。加载后的 Java 类会被存放于方法区(Method Area)中。实际运行时,虚拟机会执行方法区内的代码。
如果你熟悉 X86 ,会发现这和段式内存管理中的代码段类似。Java 虚拟机同样也在内存中划分出堆
和栈
来存储运行时数据。不同的是,Java 虚拟机会将栈细分为面向Java方法的Java方法栈
,面向本地方法(C++实现的native方法)的本地方法栈
,以及存放各个线程执行位置的PC寄存器
。java 虚拟机内存模型如下图所示:
在运行过程中,每当调用进入一个Java方法,Java 虚拟机会在当前线程的 Java 方法栈中生成一个栈帧
,用以存放局部变量以及字节码的操作数。这个栈帧的大小是提前计算好的,而且 Java 虚拟机不要求栈帧在内存空间里连续分布。
当退出当前执行的方法时,不管是正常返回还是异常返回,Java 虚拟机均会将当前线程的当前栈帧弹出,并将之舍弃。
从硬件视角来看,Java 字节码无法直接执行。因此,Java 虚拟机需要将字节码翻译成机器码。
在 HotSpot 里面,上述翻译过程有两种形式:
HotSpot 采用了多种技术来提升启动性能以及峰值性能,上面提到的即时编译便是其中最重要的技术之一。即时编译建立在程序符合二八定律的假设上,也就是百分之二十的代码占据了百分之八十的计算资源。
对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码,则可以将其编译成机器码,以达到理想的运行速度。
理论上讲,即时编译后的 Java 程序的执行效率,是可能超过 C++ 程序的。这是因为与静态编译相比,即时编译拥有程序的运行时信息,并且能够根据这个信息做出相应的优化。
举个例子,我们知道虚方法是用来实现面向对象语言多态性的。对于一个虚方法调用,尽管它有很多个目标方法,但在实际运行过程中它可能只调用其中的一个。这个信息便可以被即时编译器所利用,来规避虚方法调用的开销,从而达到比静态编译的 C++ 程序更高的性能。
为了满足不同用户场景的需要,HotSpot 内置了多个即时编译器:
从 Java 7 开始,HotSpot 默认采用分层编译的方式:热点方法首先会被 C1 编译,而后热点方法中的热点会进一步被 C2 编译。为了不干扰应用的正常运行,HotSpot 的即时编译是放在额外的编译线程中进行的。HotSpot 会根据 CPU 的数量设置编译线程的数目,并且按 1:2 的比例配置给 C1 及 C2 编译器。
在计算资源充足的情况下,字节码的解释执行和即时编译可同时进行。编译完成后的机器码会在下次调用该方法时启用,以替换原本的解释执行。、
参考<深入拆解 Java 虚拟机>- 郑雨迪