深入拆解 Java 虚拟机
Java SE文档https://docs.oracle.com/en/java/javase/ (opens new window) Java SE8文档https://docs.oracle.com/javase/8/ (opens new window) HotSpot虚拟机垃圾回收调优指南:https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/index.html (opens new window) 虚拟机规范:https://docs.oracle.com/javase/specs/index.html (opens new window) Java SE8虚拟机规范:https://docs.oracle.com/javase/specs/jvms/se8/html/index.html (opens new window)
# Java代码是怎么运行的
通过编译器将 Java 程序转换成该虚拟机所能识别的指令序列,也称 Java 字 节码。这里顺便说一句,之所以这么取名,是因为 Java 字节码指令的操作码(opcode) 被固定为一个字节。
从虚拟机视角来看,执行 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的基本类型
其中,boolean 类型在 Java 虚拟机中被映射为整数类型:“true”被映射为 1, 而“false”被映射为 0。Java 代码中的逻辑运算以及条件跳转,都是用整数相关的字节码来实现的。 除 boolean 类型之外,Java 还有另外 7 个基本类型。它们拥有不同的值域,但默认值在 内存中均为 0。这些基本类型之中,浮点类型比较特殊。基于它的运算或比较,需要考虑 +0.0F、-0.0F 以及 NaN 的情况。 除 long 和 double 外,其他基本类型与引用类型在解释执行的方法栈帧中占用的大小是一 致的,但它们在堆中占用的大小确不同。在将 boolean、byte、char 以及 short 的值存入 字段或者数组单元时,Java 虚拟机会进行掩码操作。在读取时,Java 虚拟机则会将其扩展 为 int 类型。
# Java虚拟机是如何加载Java类的
我们知道 Java 语言的类型可以分为两大类:基本类型(primitive types)和引用类型 (reference types)。
- Java 的基本类型,它们是由 Java 虚拟机预先定义好的。
- 至于另一大类引用类型,Java 将其细分为四种:类、接口、数组类和泛型参数。由于泛型参数会在编译过程中被擦除,因此 Java 虚拟机实际上只有前三种。在类、接口和数组类中,数组类是由 Java 虚拟机直接生成的,其他两种则有对应的字节流。
说到字节流,最常见的形式要属由 Java 编译器生成的 class 文件。除此之外,我们也可以在程序内部直接生成,或者从网络中获取(例如网页中内嵌的小程序 Java applet)字节流。这些不同形式的字节流,都会被加载到 Java 虚拟机中,成为类或接口。为了叙述方便,下面我就用“类”来统称它们。
无论是直接生成的数组类,还是加载的类,Java 虚拟机都需要对其进行链接和初始化。 Java 虚拟机动态加载、链接和初始化类和接口。
- 加载是查找具有特定名称的类或接口类型的二进制表示并从该二进制表示创建类或接口的过程。
- 链接是获取类或接口并将其组合到 Java 虚拟机的运行时状态以便可以执行的过程。
- 类或接口的初始化包括执行类或接口的初始化方法
<clinit>
。
# 加载
加载,是指查找字节流,并且据此创建类的过程。前面提到,对于数组类来说,它并没有对应的字节流,而是由 Java 虚拟机直接生成的。对于其他的类来说,Java 虚拟机则需要借助类加载器来完成查找字节流的过程。
- 启动类加载器(bootstrap class loader)是由 C++ 实现的,没有对应的 Java 对象,因此在 Java 中 只能用** null** 来指代。
除了启动类加载器之外,其他的类加载器都是 java.lang.ClassLoader 的子类,因此有对应 的 Java 对象。这些类加载器需要先由另一个类加载器,比如说启动类加载器,加载至 Java 虚拟机中,方能执行类加载。 在 Java 虚拟机中,有个潜规则,叫双亲委派模型。每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。 在 Java 9 之前,启动类加载器负责加载最为基础、最为重要的类,比如存放在 JRE 的 lib 目录下 jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)。除了启动类加载器之外,另外两个重要的类加载器是扩展类加载器(extension class loader)和应用类加载器(application class loader),均由 Java 核心类库提供。
- 扩展类加载器的父类加载器是启动类加载器。它负责加载相对次要、但又通用的类,比如存 放在 JRE 的 lib/ext 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类)。
- 应用类加载器的父类加载器则是扩展类加载器。它负责加载应用程序路径下的类。(这里的 应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。
Java 9 引入了模块系统,并且略微更改了上述的类加载器。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。
除了由 Java 核心类库提供的类加载器外,我们还可以加入自定义的类加载器,来实现特殊的加载方式。举例来说,我们可以对 class 文件进行加密,加载时再利用自定义的类加载器对其解密。 在 Java 虚拟机中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。
# 链接
链接,是指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。它可分为验证、准备以及解析三个阶段。
- 验证阶段的目的,在于确保被加载类能够满足 Java 虚拟机的约束条件。
- 准备阶段的目的,则是为被加载类的静态字段分配内存。Java 代码中对静态字段的具体初始化,则会在稍后的初始化阶段中进行。除了分配内存外,_部分 Java 虚拟机_还会在此阶段构造其他跟类层次相关的数据结构,比如说用来实现虚方法的动态绑定的方法表。 在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java 编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。举例来说,对于一个方法调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接收参数类型以及返回值类型的符号引用,来指代所要调用的方法。
- 解析阶段的目的,正是将这些符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化。) Java 虚拟机规范并没有要求在链接过程中完成解析。它仅规定了:如果某些字节码使用了符号引用,那么在执行这些字节码之前,需要完成对这些符号引用的解析。
# 初始化
在 Java 代码中,如果要初始化一个静态字段,我们可以在声明时直接赋值,也可以在静态 代码块中对其赋值。 如果直接赋值的静态字段被 final 所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被 Java 编译器标记成常量值(ConstantValue),其初始化直接由 Java 虚拟机完成。除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被 Java 编译器置于同 一方法中,并把它命名为 <clinit>。 类加载的最后一步是初始化,便是为标记为常量值的字段赋值,以及执行 <clinit> 方法的 过程。Java 虚拟机会通过加锁来确保类的<clinit> 方法仅被执行一次。
JVM 规范枚举了多种类的初始化触发情况:
- 当虚拟机启动时,初始化用户指定的主类;
- 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;
- 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
- 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
- 子类的初始化会触发父类的初始化;
- 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
- 使用反射 API 对某个类进行反射调用时,初始化这个类;
- 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。
在初始化之前,必须链接一个类或接口,即验证、准备和可选地解析 被动引用并不会引发类的初始化,如引用类的静态常量,引用父类的静态字段不会初始化子类,数组定义来引用类不会导致初始化。
# 重载与重写
在 Java 程序里,如果同一个类中出现多个名字相同,并且参数类型相同的方法,那么它无法通过编译。也就是说,在正常情况下,如果我们想要在同一个类中定义名字相同的方法, 那么它们的参数类型必须不同。这些方法之间的关系,我们称之为重载。 重载的方法在编译过程中即可完成识别。具体到每一个方法调用,Java 编译器会根据所传入参数的声明类型(注意与实际类型区分)来选取重载方法。选取的过程共分为三个阶段:
- 在不考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法;
- 如果在第 1 个阶段中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;
- 如果在第 2 个阶段中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法。
如果 Java 编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系。
当传入 null 时,它既可以匹配第一个方法中声明为 Object 的形式参数,也可以匹配第二个方法中声明为 String 的形式参数。由于 String 是 Object 的子类, 因此 Java 编译器会认为第二个方法更为贴切。
除了同一个类中的方法,重载也可以作用于这个类所继承而来的方法。也就是说,如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型不同,那么在子类中, 这两个方法同样构成了重载。 那么,如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型相同, 那么这两个方法之间又是什么关系呢?
- 如果这两个方法都是静态的,那么子类中的方法隐藏了父类中的方法。
- 如果这两个方法都不是静态的,且都不是私有的,那么子类的方法重写了父类中的方法。
重写会根据调用者的动态类型,来选取实际的目标方法
# JVM 的静态绑定和动态绑定
Java 虚拟机识别方法的关键在于类名、方法名以及方法描述符(method descriptor)。 方法描述符,它是由方法的参数类型以及返回类型所构成。在同一个类中,如果同时出现多个名字相同且描述符也相同的方法,那么 Java 虚拟机会在类的验证阶段报错。 可以看到,Java 虚拟机与 Java 语言不同,它并不限制名字与参数类型相同,但返回类型不同的方法出现在同一个类中,对于调用这些方法的字节码来说,由于字节码所附带的方法描述符包含了返回类型,因此 Java 虚拟机能够准确地识别目标方法。 Java 虚拟机中关于方法重写的判定同样基于方法描述符。也就是说,如果子类定义了与父类中非私有、非静态方法同名的方法,那么只有当这两个方法的参数类型以及返回类型一 致,Java 虚拟机才会判定为重写。 对于 Java 语言中重写而 Java 虚拟机中非重写的情况,编译器会通过生成桥接方法来实现 Java 中的重写语义。 由于对重载方法的区分在编译阶段已经完成,我们可以认为 Java 虚拟机不存在重载这一概念。因此,在某些文章中,重载也被称为静态绑定(static binding),或者编译时多态 (compile-time polymorphism);而重写则被称为动态绑定(dynamic binding)。
- Java 虚拟机中的静态绑定指的是在解析时便能够直接识别目标方法的情况
- 而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。
具体来说,Java 字节码中与调用相关的指令共有五种。
- invokestatic:用于调用静态方法。
- invokespecial:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实 例方法或构造器,和所实现接口的默认方法。
- invokevirtual:用于调用非私有实例方法。
- invokeinterface:用于调用接口方法。
- invokedynamic:用于调用动态方法。