JVM 基本原理(1)
Java 代码是怎么运行的
Java 代码的多种运行方式:
可在开发工具中运行,可双击执行 jar 文件运行,可在命令行中运行,可在网页中运行。
相较于 C++ 的直接将代码编译成 CPU 所能理解的的机器码(无需额外的运行时),Java 代码的执行则离不开 JRE(Java 运行时环境,仅包含运行 Java 程序的必须组件,如 Java 虚拟机以及 Java 核心类库等)。
为什么 Java 要在虚拟机里运行
因为 Java 的语法非常复杂,抽象程度也很高,所以直接在硬件上运行这种复杂的程序并不现实。
在运行 Java 程序之前,要对其进行一番转换,转换的思路如下:
设计一个面向 Java 语言特性的虚拟机,并通过编译器将 Java 程序转换成该虚拟机所能识别的指令序列,也称 Java 字节码(因为 Java 字节码指令的操作码(opcode)被固定为一个字节)。
JVM 可由硬件实现,但更常见的是在现有平台上提供软件实现(意义在于,一旦一个程序被转换成 Java 字节码,那么它便可以在不同平台的虚拟机实现里运行,提供了可移植性)。
虚拟机还带来了一个托管环境(Managed Runtime)。其能够代替我们处理一些代码中冗长而且容易出错的部分。其中最广为人知的当属自动内存管理与垃圾回收。
托管环境还提供了诸如数组越界、动态类型、安全权限等等的动态检测,使我们免于书写这些无关业务逻辑的代码。
JVM 具体是怎样运行 Java 字节码的
从虚拟机视角来看,执行 Java 代码首先需要将它编译而成的 class 文件加载到 JVM 中。加载后的 Java 类会被存放于方法区(Method Area)中。实际运行时,虚拟机会执行方法区内的代码。
除了方法区,JVM 还在内存中划分出堆和栈来存储运行时数据,JVM 内存中的五个区域:
- 方法区(线程共享):
- 堆(线程共享):
- Java 方法栈(线程私有):面向 Java 方法
- 本地方法栈(线程私有):面向本地方法(用 C++ 写的 native 方法)
- PC 寄存器(线程私有):存放各个线程执行位置
在运行过程中,每当调用进入一个 Java 方法,JVM 会在当前线程的 Java 方法栈中生成一个栈帧,用以存放局部变量以及字节码的操作数。这个栈帧的大小是提前计算好的,而且虚拟机不要求栈帧在内存空间里连续分布。
在退出当前执行的方法时,不管是正常返回还是异常返回,JVM 均会弹出当前线程的当前栈帧,并将之舍弃。
从硬件视角来看,Java 字节码无法直接执行。因此,JVM 需要将字节码翻译成机器码。
在 HotSpot 中,上述翻译过程有两种形式:
- 解释执行:逐条将字节码翻译成机器码并执行
优势在于无需等待编译 - 即时编译(Just-In-Time compilation,JIT):将一个方法中包含的所有字节码编译成机器码后再执行
优势在于实际运行速度更快
HotSpot 默认采用混合模式,综合两者优点。它会先解释执行字节码,而后将其中反复执行的热点代码,以方法为单位进行即时编译。
JVM 的运行效率究竟是怎样的
为了满足不同用户场景的需要,HotSpot 内置了多个即时编译器,用以在编译时间和生成代码的执行效率之间进行取舍:
- C1:又叫做 Client 编译器,面向的是对启动性能有要求的客户端 GUI 程序,采用的优化手段相对简单,因此编译时间较短。
- C2:又叫做 Server 编译器,面向的是对峰值性能有要求的服务器端程序,采用的优化手段相对复杂,因此编译时间较长,但同时生成代码的执行效率较高。
- Graal:
从 Java 7 开始,HotSpot 默认采用分层编译的方式:热点方法首先会被 C1 编译,而后热点方法中的热点会进一步被 C2 编译。
为了不干扰应用的正常运行,HotSpot 的即时编译是放在额外的编译线程中进行的。
HotSpot 会根据 CPU 的数量设置编译线程的数目,并且按 1:2 的比例配置给 C1 及 C2 编译器。
在计算资源充足的情况下,字节码的解释执行和即时编译可同时进行。编译完成后的机器码会在下次调用该方法时启用,以替换原本的解释执行。
Java 的基本类型
Java 引进了八个基本类型,来支持数值计算。这么做的原因是使用基本类型能够在执行效率以及内存使用两方面提升软件性能。
Java 虚拟机的 boolean 类型
在 Java 语言规范中,boolean 类型的值只有两种可能,它们分别用符号“true”和“false”来表示。显然,这两个符号是不能被虚拟机直接使用的。
在 Java 虚拟机规范中,boolean 类型则被映射成 int 类型。具体来说,“true”被映射为整数 1,而“false”被映射为整数 0。这个编码规则约束了 Java 字节码的具体实现。
Java 虚拟机规范同时也要求 Java 编译器遵守这个编码规则,并且用整数相关的字节码来实现逻辑运算,以及基于 boolean 类型的条件跳转。
Java 的基本类型
除了上面提到的 boolean 类型外,Java 的基本类型还包括整数类型 byte、short、char、int 和 long,以及浮点类型 float 和 double。
Java 的基本类型都有对应的值域和默认值。可以看到,byte、short、int、long、float 以及 double 的值域依次扩大,而且前面的值域被后面的值域所包含。因此,从前面的基本类型转换至后面的基本类型,无需强制转换。另外一点值得注意的是,尽管他们的默认值看起来不一样,但在内存中都是 0。
在这些基本类型中,boolean 和 char 是唯二的无符号类型。在不考虑违反规范的情况下,boolean 类型的取值范围是 0 或者 1。char 类型的取值范围则是 [0, 65535]。通常我们可以认定 char 类型的值为非负数。这种特性十分有用,比如说作为数组索引等。
Java 的浮点类型采用 IEEE 754 浮点数格式。以 float 为例,浮点类型通常有两个 0,+0.0F 以及 -0.0F。
NaN 有一个有趣的特性:除了“!=”始终返回 true 之外,所有其他比较结果都会返回 false。
Java 基本类型的大小
供解释器使用的解释栈帧(interpreted frame)有两个主要的组成部分,分别是局部变量区,以及字节码的操作数栈。
在 Java 虚拟机规范中,局部变量区等价于一个数组,并且可以用正整数来索引。
除 long 和 double 外(需用两个数组单元来存储),其他基本类型与引用类型在解释执行的方法栈帧中占用的大小是一致的(均占用一个数组单元。HotSpot 中,32 位占 4 字节,64 位占 8 字节),但它们在堆中占用的大小确不同(跟这些类型的值域相吻合。boolean、byte、short、char、int、long、float 和 double 依次为 1 字节、1 字节、2 字节、2 字节、4 字节、8 字节、4 字节和 8 字节)。原因主要是变长数组不好控制,所以就选择浪费一些空间,以便访问时直接通过下标来计算地址。
在将 boolean、byte、char 以及 short 的值存入字段或者数组单元时,Java 虚拟机会进行掩码操作。在读取时,Java 虚拟机则会将其扩展为 int 类型(JVM 的算数运算几乎全部依赖于操作数栈。也就是说,需要将堆中的 boolean、byte、char 以及 short 加载到操作数栈上,而后将栈上的值当成 int 类型来运算)。
Java 虚拟机是如何加载 Java 类的
Java 语言的类型可以分为两大类:基本类型(primitive types)和引用类型(reference types);
- Java 的基本类型,它们是由 Java 虚拟机预先定义好的。
- Java 将引用类型细分为四种:类、接口、数组类和泛型参数。由于泛型参数会在编译过程中被擦除,因此 Java 虚拟机实际上只有前三种。在类、接口和数组类中,数组类是由 Java 虚拟机直接生成的,其他两种则有对应的字节流。
说到字节流,最常见的形式要属由 Java 编译器生成的 class 文件。除此之外,我们也可以在程序内部直接生成,或者从网络中获取(例如网页中内嵌的小程序 Java applet)字节流。这些不同形式的字节流,都会被加载到 Java 虚拟机中,成为类或接口。
Java 虚拟机将字节流转化为 Java 类的过程可分为加载、链接以及初始化三大步骤;
- 加载是指查找字节流,并且据此创建类的过程。加载需要借助类加载器,在 Java 虚拟机中,类加载器使用了双亲委派模型,即接收到加载请求时,会先将请求转发给父类加载器。
- 链接,是指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。链接还分验证、准备和解析三个阶段。其中,解析阶段为非必须的。
- 初始化,则是为标记为常量值的字段赋值,以及执行 < clinit > 方法的过程。类的初始化仅会被执行一次,这个特性被用来实现单例的延迟初始化。
JVM 是如何执行方法调用的
重载与重写
重载指的是方法名相同而参数类型不相同的方法之间的关系,重写指的是方法名相同并且参数类型也相同的方法之间的关系。
Java 虚拟机识别方法的方式略有不同,除了方法名和参数类型之外,它还会考虑返回类型。
重载的方法在编译过程中即可完成识别。具体到每一个方法调用,Java 编译器会根据所传入参数的声明类型(注意与实际类型区分)来选取重载方法。选取的过程共分为三个阶段:与是否考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及是否允许可变长参数的情况下选取重载方法;
如果 Java 编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系。
除了同一个类中的方法,重载也可以作用于这个类所继承而来的方法。
也就是说,如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型不同(如果参数类型相同,如果这两个方法都是静态的,那么子类中的方法隐藏了父类中的方法。如果这两个方法都不是静态的,且都不是私有的,那么子类的方法重写了父类中的方法),那么在子类中,这两个方法同样构成了重载。
JVM 的静态绑定和动态绑定
Java 虚拟机识别方法的关键在于类名、方法名以及方法描述符(由方法的参数类型以及返回类型所构成)。
在 Java 虚拟机中,静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。由于 Java 编译器已经区分了重载的方法,因此可以认为 Java 虚拟机中不存在重载。
调用指令的符号引用
在 class 文件中,Java 编译器会用符号引用指代目标方法。在执行调用指令前,它所附带的符号引用需要被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用为目标方法的指针。对于需要动态绑定的方法调用而言,实际引用为辅助动态绑定的信息。
虚方法调用
虚方法调用包括 invokevirtual(Java 里所有非私有实例方法调用都会被编译成) 指令和 invokeinterface(接口方法调用都会被编译成) 指令。如果这两种指令所声明的目标方法被标记为 final,那么 Java 虚拟机会采用静态绑定。
否则,Java 虚拟机将采用动态绑定,在运行过程中根据调用者的动态类型,来决定具体的目标方法。
方法表
Java 虚拟机的动态绑定是通过方法表这一数据结构来实现的。方法表中每一个重写方法的索引值,与父类方法表中被重写的方法的索引值一致。
在解析虚方法调用时,Java 虚拟机会纪录下所声明的目标方法的索引值,并且在运行过程中根据这个索引值查找具体的目标方法。
内联缓存
Java 虚拟机中的即时编译器会使用内联缓存来加速动态绑定。Java 虚拟机所采用的单态内联缓存将纪录调用者的动态类型,以及它所对应的目标方法。
当碰到新的调用者时,如果其动态类型与缓存中的类型匹配,则直接调用缓存的目标方法。
否则,Java 虚拟机将该内联缓存劣化为超多态内联缓存,在今后的执行过程中直接使用方法表进行动态绑定。
备注
参考资料:
极客时间-深入拆解Java虚拟机