一、为什么要学习Java虚拟机
1.Java虚拟机概述
Java虚拟机即Java Virtual Machine,主要功能是执行Java字节码,Java虚拟机屏蔽了与具体操作系统平台相关的信息,使得Java程序只需生成在Java虚拟机上运行的字节码,就可以在多种平台上不加修改地运行——Java跨平台的本质。Java虚拟机底层功能主要包含:
• JVM内存管理。内存管理主要是将JVM的内存容量划分成不同的模块,使用不同的管理方式,核心目的是为了提高内存的利用率以及制定恰当的内存回收策略;
• JVM垃圾回收。垃圾回收之所以存在是因为JVM是运行在内存之中的,它的内存空间是有限的,当加载进内存的对象越来越多的时候,会影响JVM的运行性能,所以,要回收一些内存空间,垃圾回收的关键是识别出垃圾以及使用不影响JVM运行的回收策略;
• JVM性能优化。内存管理和垃圾回收本质上也是提升JVM性能,减少因为JVM问题导致的系统故障。
2.Java代码是怎么运行的?
Java作为一门高级程序语言,它的语法非常复杂且抽象程度也很高。因此直接在硬件上运行这种复杂的程序并不现实,所以在运行Java程序之前,我们需要对其进行一番转换。转换的主流思想是设计一个面向Java语言特性的虚拟机,并通过编译器将Java程序转换成该虚拟机所能识别的指令序列,也称Java字节码。Java虚拟机可以由硬件实现,但更为常见的是在各个现有平台(如Windows_x64、Linux_aarch64)上提供软件实现。这么做的意义在于,一旦一个程序被转换成Java字节码,那么它便可以在不同平台上的虚拟机实现里运行。这也就是我们经常说的“一次编写,到处运行”。虚拟机的另外一个好处是它带来了一个托管环境(Managed Runtime)。这个托管环境能够代替我们处理一些代码中冗长而且容易出错的部分。主要包括自动内存管理、垃圾回收、数组越界、动态类型、安全权限等等的动态检测,使我们免于书写这些无关业务逻辑的代码。
从虚拟机视角来看,执行Java代码首先需要将它编译而成的class文件加载到Java虚拟机中。加载后的Java类会被存放于方法区(Method Area)中。实际运行时,虚拟机会执行方法区内的代码。在运行过程中,每当调用进入一个Java方法,Java虚拟机会在当前线程的Java方法栈中生成一个栈帧,用以存放局部变量以及字节码的操作数。这个栈帧的大小是提前计算好的,而且Java虚拟机不要求栈帧在内存空间里连续分布。当退出当前执行的方法时,不管是正常返回还是异常返回,Java虚拟机均会弹出当前线程的当前栈帧,并将之舍弃。从硬件视角来看,Java字节码无法直接执行,因此Java虚拟机需要将字节码翻译成机器码。在HotSpot里面,上述翻译过程有两种形式:第一种是解释执行,即逐条将字节码翻译成机器码并执行;第二种是即时编译(Just-In-Time compilation,JIT),即将一个方法中包含的所有字节码编译成机器码后再执行。前者的优势在于无需等待编译,而后者的优势在于实际运行速度更快。HotSpot默认采用混合模式,综合了解释执行和即时编译两者的优点。它会先解释执行字节码,而后将其中反复执行的热点代码,以方法为单位进行即时编译。
3.Java基本数据类型
Java的8种基本类型都有对应的值域和默认值。由下图可知byte、short、int、long、float以及double的值域依次扩大,而且前面的值域被后面的值域所包含。因此,从前面的基本类型转换至后面的基本类型,无需强制转换。另外一点值得注意的是,尽管他们的默认值看起来不一样,但在内存中都是0。在这些基本类型中,boolean和char是唯二的无符号类型。在不考虑违反规范的情况下,boolean类型的取值范围是0或者1。char类型的取值范围则是[0,65535]。通常我们可以认定char类型的值为非负数。这种特性十分有用,比如说作为数组索引等。
4.Java类的加载
• 类加载器
加载,是指查找字节流,并且据此创建类的过程。前面提到,对于数组类来说,它并没有对应的字节流,而是由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.lang.ClassLoader的子类,因此有对应的Java对象。这些类加载器需要先由另一个类加载器,比如说启动类加载器,加载至Java虚拟机中,方能执行类加载。
Java9引入了模块系统,并且扩展类加载器被改名为平台类加载器(platform class loader)。Java SE中除了少数几个关键模块,比如说java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。除了由Java核心类库提供的类加载器外,我们还可以加入自定义的类加载器,来实现特殊的加载方式。举例来说,我们可以对class文件进行加密,加载时再利用自定义的类加载器对其解密。除了加载功能之外,类加载器还提供了命名空间的作用。在Java虚拟机中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。
• 链接
链接,是指将创建成的类合并至Java虚拟机中,使之能够执行的过程。它可分为验证、准备以及解析三个阶段:
• 验证阶段的目的,在于确保被加载类能够满足Java虚拟机的约束条件,一般来说Java编译器生成的类文件必然满足Java虚拟机的约束条件。
• 准备阶段的目的,则是为被加载类的静态字段分配内存。Java代码中对静态字段的具体初始化,则会在稍后的初始化阶段中进行。除了分配内存外,部分Java虚拟机还会在此阶段构造其他跟类层次相关的数据结构,比如说用来实现虚方法的动态绑定的方法表。在class文件被加载至Java虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。举例来说,对于一个方法调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接收参数类型以及返回值类型的符号引用,来指代所要调用的方法。
• 解析阶段的目的,正是将这些符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化。)Java虚拟机规范并没有要求在链接过程中完成解析。它仅规定了:如果某些字节码使用了符号引用,那么在执行这些字节码之前,需要完成对这些符号引用的解析。
• 初始化
在Java代码中如果要初始化一个静态字段,我们可以在声明时直接赋值,也可以在静态代码块中对其赋值。如果直接赋值的静态字段被final所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被Java编译器标记成常量值(ConstantValue),其初始化直接由Java虚拟机完成。除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被Java编译器置于同一方法中,并把它命名为<clinit >。类加载的最后一步是初始化,便是为标记为常量值的字段赋值,以及执行< clinit >方法的过程。Java虚拟机会通过加锁来确保类的< clinit>方法仅被执行一次。只有当初始化完成之后,类才正式成为可执行的状态。那么,类的初始化何时会被触发呢?JVM规范枚举了下述多种触发情况:
1. 当虚拟机启动时,初始化用户指定的主类;
2. 当遇到用以新建目标类实例的new指令时,初始化new指令的目标类;
3. 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
4. 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
5. 子类的初始化会触发父类的初始化;
6. 如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
7. 使用反射API对某个类进行反射调用时,初始化这个类;
8. 当初次调用MethodHandle实例时,初始化该MethodHandle指向的方法所在的类。
public class Singleton {
private Singleton() {}
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}
上面这段代码是在著名的单例延迟初始化例子中,只有当调用Singleton.getInstance时,程序才会访问LazyHolder.INSTANCE,才会触发对LazyHolder的初始化(对应第4种情况),继而新建一个Singleton的实例。由于类初始化是线程安全的,并且仅被执行一次,因此程序可以确保多线程环境下有且仅有一个Singleton实例。