JVM 笔记 1

目录

JVM 学习笔记。

JVM 如何执行 Java 字节码?

  • 执行字节码既将字节码加载到方法区,实际虚拟机会执行方法区的代码
  • JVM 在执行字节码时有两种方式:
    • 1)通过解释执行器解释执行
    • 2)通过即使编译器(Just-In-Time, JIT)进行编译(机器码),使得 CPU 直接执行
  • 解释执行的优势在于无需等待编译,而后者的优势在于实际的运行速度更快
  • HotSpot 默认采用混合模式,综合了解释执行和即时编译两者的优点。它先解释执行字节码,而后将其中反复执行的热点代码即时编译(以方法为单位)

HotSpot 虚拟机的编译器

  • HotSpot 内置了多个编译器:C1、C2、Graal(JDK 10 实验性加入)
    • C1,又称 Client 编译器,是面向启动性能有要求的客户端 GUI 程序
    • C2,又称 Server 编译器,是面向对峰值性能有要求的服务端程序,它内部采用的优化手段相对复杂,因此编译时间更长
  • 从 JDK 7 开始,HotSpot 默认采用分层编译的方式:热点方法首先被 C1 编译,而后热点方法会进一步被 C2 编译。因为编译有时空开销,为了不干扰应用程序运行,HotSpot 的即时编译是放在额外线程中进行的,HotSpot 会根据 CPU 核心数设置编译线程的数目,并按 1:2 比例分配给 C1 和 C2 编译器

基本数据类型

基本数据类型

基本数据类型在 Java 中的实现

  • 栈帧的组成:局部变量表和操作数栈
    • 局部变量表是一个数组。在 32 bit JVM 中,局部变量表中的 long 和 double 占用两个单元,其他基本数据类型及引用类型占一个单元
    • 32 位与 64 位的 HotSpot 操作数栈单元占用空间不一样的。boolean、byte、char、short 和引用类型在 32 位 HotSpot 占 4 个字节,而 64 位占 8 个字节。
      • 虽然这些基本数据类型在栈的局部变量表中的存储空间大,但读取时仍会安装基本数据类型约束的空间读取,例如 short 读取 2 字节,boolean 读一个 bit,这些都是通过掩码屏蔽掉的。
  • boolean 变量在 JVM 中以整型常量存储,false 为 0(iconst_0),而 true 为 1(iconst_1)。
    • 注意:不是所有的正整数常量都为 true,JVM 通过字节码的关键字 ifeq,if_icmpre,JVM 在读取这个整型常量时使用掩码只读取第一个 bit。
	...
         5: ifeq          10
	...
        12: if_icmpne     17
	...

类加载

  • JVM 使用多级类加载器
    • 启动类加载器由 C++ 实现
    • JDK 内置的类加载器 java.long.ClassLoader,是其他内置类加载器的父类(扩展类加载器、应用类加载器)
  • JVM 加载类的方式:双亲委派
    • 不重复加载
    • 类安全加载
  • 确定类的唯一性:由类加载器实例类全限定名共同确定

Java 9 引入了模块系统,并且略微更改了上述的类加载器。扩展类加载器被改名为平台类加载器(Platform Class Loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。

链接

链接可以分为验证、准备和解析三个阶段。

  • 验证:验证 JVM 启动约束条件
  • 准备:加载类的静态字段
  • 解析:将符号引用解析成为实际引用。符号引用是在类 class 文件未被加载前类引用、方法引用、字段引用等的符号化标识。在未加载前,类不知道其他类的具体内存地址,也不知到自己的方法、字段的内存地址,Java 编译器会将这些生成一个个符号标识,结合代码符号既:#2#3#4
  // main 方法
  public static void main(String[] args) {
      int a = 5;
      boolean b = false;
      String c = "abc";
      float f = 5.0f;
      System.out.println(f);
  }

  // 反编译 main 方法代码
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=5, args_size=1
         0: iconst_5
         1: istore_1
         2: iconst_0
         3: istore_2
         4: ldc           #2                  // String abc
         6: astore_3
         7: ldc           #3                  // float 5.0f
         9: fstore        4
        11: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        14: fload         4
        16: invokevirtual #5                  // Method java/io/PrintStream.println:(F)V
        19: return

初始化

初始化这个过程主要是为类的成员变量赋值。特殊的静态成员变量在类加载时进行初始化(由 JVM 完成),除此之外的(构造方法中)直接赋值操作以及静态代码块中的代码,则被 Java 编译器置于同一方法 <clinit>,最后 JVM 同步执行 <clinit> 方法。类加载的最后一步是初始化,为标记常量的字段赋值和执行 初始化过程中 JVM 通过加锁来确保此方法只执行一次。

类使用时必须经过初始化,那么什么时候类触发类初始化?

  1. 当虚拟机启动时,初始化用户指定的主类(main 所在的类);
  2. 当遇到创建实例的 new 指令时,初始化 new 指令的目标类;
  3. 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
  4. 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
  5. 子类的初始化会触发父类的初始化;
  6. 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
  7. 使用反射 API 对某个类进行反射调用时,初始化这个类;
  8. 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。

单例模式中,利用静态类使 Singleton 安全初始一个实例,其一利用静态类延迟初始化特性,其二利用 JVM 只会确保类只被加载一次。

public class Singleton {

  private Singleton() {}

  private static class LazyHolder {
    static final Singleton INSTANCE = new Singleton();
  }

  public static Singleton getInstance() {
    return LazyHolder.INSTANCE;
  }
}

重载

重载选择方法的过程:

  1. 在不考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法;
  2. 如果在第 1 个阶段中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;
  3. 如果在第 2 个阶段中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法。
阶段 是否考虑字段拆装箱 是否选取可变长参数
1
2
3

多态

  • 多态得情况
    • 同一个类中方法的重载
    • 不同类(父-子)中方法重写(非静态、非私有方法)
    • 如果子类定义了父类中非私有同名方法,而且方法参数类型不同,那么父子类中这两个方法也构成重载
    • 如果父子类中定义静态同名同参数类型得方法,则子类中隐藏父类的静态方法
  • 方法的重写是多态最重要的一种体现方式:它允许子类在继承父类部分功能同时,拥有自己独特的功能
  • 多态在编译期确定具体调用的方法

JVM 静态绑定和动态绑定

JVM 如何识别方法 —— 依靠类名、方法名以及方法描述符。方法描述符由方法参数类型以及返回值类型构成,如下方代码中 descriptor: ([Ljava/lang/String;)V

在同一个类中如果出现多个名字相同且描述符也相同的方法,那么 JVM 在类验证阶段报错。

  // javap -c -p -v -l -constants -v Foo.class
  public static void main(String[] args) {
    int a = 5;
  }
  // main 方法字节码反编译结果
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=2, args_size=1
         0: iconst_5
         1: istore_1
         2: return
      LineNumberTable:
        line 3: 0
        line 4: 2

Java 字节码中与调用相关的指令

  1. invokestatic:用于调用静态方法。
  2. invokespecial:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法。
  3. invokevirtual:用于调用非私有实例方法。
  4. invokeinterface:用于调用接口方法。
  5. invokedynamic:用于调用动态方法。

对于 invokevirtual 以及 invokeinterface 而言,在绝大部分情况下,虚拟机需要在执行过程中,根据调用者的动态类型,来确定具体的目标方法。

调用指令的符号引用

符号引用既反编译后字节码中的 #数字 ,这些引用指向类的常量池中的值。

常量池 可以简单理解为一个存储类信息的一维数组,它在未加到 JVM 堆中时持久存储在 class 文件中。

// Java 源码
public class Main {
    public static void main(String[] args) {
        int a = 5;
        int b = a;
    }
}

// 反编译后 Main 类的常量池   javap -v Main.class
Constant pool:
   #1 = Methodref          #3.#21         // java/lang/Object."<init>":()V
   #2 = Class              #22            // elltor/jvm/Main
   #3 = Class              #23            // java/lang/Object
   #4 = Utf8               <init>
   #5 = Utf8               ()V
   #6 = Utf8               Code
   #7 = Utf8               LineNumberTable
   #8 = Utf8               LocalVariableTable
   #9 = Utf8               this
  #10 = Utf8               Lelltor/jvm/Main;
  #11 = Utf8               main
  #12 = Utf8               ([Ljava/lang/String;)V
  #13 = Utf8               args
  #14 = Utf8               [Ljava/lang/String;
  #15 = Utf8               a
  #16 = Utf8               I
  #17 = Utf8               b
  #18 = Utf8               MethodParameters
  #19 = Utf8               SourceFile
  #20 = Utf8               Main.java
  #21 = NameAndType        #4:#5          // "<init>":()V
  #22 = Utf8               elltor/jvm/Main
  #23 = Utf8               java/lang/Object

对于非接口符号引用,假定该符号引用所指向的类为 C,则 Java 虚拟机会按照如下步骤进行查找。

  1. 在 C 中查找符合名字及描述符的方法。
  2. 如果没有找到,在 C 的父类中继续搜索,直至 Object 类。
  3. 如果没有找到,在 C 所直接实现或间接实现的接口中搜索,这一步搜索得到的目标方法必须是非私有、非静态的。并且,如果目标方法在间接实现的接口中,则需满足 C 与该接口之间没有其他符合条件的目标方法。如果有多个符合条件的目标方法,则任意返回其中一个。

对于接口符号引用,假定该符号引用所指向的接口为 I,则 Java 虚拟机会按照如下步骤进行查找。

  1. 在 I 中查找符合名字及描述符的方法。
  2. 如果没有找到,在 Object 类中的公有实例方法中搜索。
  3. 如果没有找到,则在 I 的超接口中搜索。这一步的搜索结果的要求与非接口符号引用步骤 3 的要求一致。

经过上述的解析步骤之后,符号引用会被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用是一个指向方法的指针。对于需要动态绑定的方法调用而言,实际引用则是一个方法表的索引。

注意:Java 虚拟机识别方法的方式略有不同,除了方法名和参数类型之外,它还会考虑返回类型。

在 Java 虚拟机中,静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。由于 Java 编译器已经区分了重载的方法,因此可以认为 Java 虚拟机中不存在重载。

在 class 文件中,Java 编译器会用符号引用指代目标方法。在执行调用指令前,它所附带的符号引用需要被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用为目标方法的指针。对于需要动态绑定的方法调用而言,实际引用为辅助动态绑定的信息。

虚方法调用

Java 里所有非私有实例方法调用都会被编译成 invokevirtual 指令,而接口方法调用都会被编译成 invokeinterface 指令。这两种指令,均属于 Java 虚拟机中的虚方法调用。

动态绑定、静态绑定

  • 动态绑定:。在绝大多数情况下,Java 虚拟机需要根据调用者的动态类型,来确定虚方法调用的目标方法(更加耗时),这个过程我们称之为动态绑定。动态绑定的情况:调用接口实现方法(invokeinterface)、调用非私有实例方法(invokevirtual)
  • 静态绑定:直接进行调用无需根据调用方的参数类型等判断。静态绑定的情况:调用静态方法(invokestatic)、调用构造器和私有方法(invokespecial),另外如果方法被 final 修饰 JVM 可以选择以静态绑定方式调用。

方法表

Java 虚拟机中采取了一种用空间换取时间的策略来实现动态绑定。它为每个类生成一张方法表,用以快速定位目标方法。

  • 虚方法表(Virtual Method Table,vtable)
  • 接口方法表(Interface Method Table,itable)

方法表满足两个特质:其一,子类方法表中包含父类方法表中的所有方法;其二,子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同。

动态绑定的实现

在执行过程中,Java 虚拟机将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法。这个过程便是动态绑定。

abstract class Passenger {
  abstract void passThroughImmigration();
  @Override
  public String toString() { ... }
}
class ForeignerPassenger extends Passenger {
	 @Override
 	void passThroughImmigration() { /* 进外国人通道 */ }
}
class ChinesePassenger extends Passenger {
  @Override
  void passThroughImmigration() { /* 进中国人通道 */ }
  void visitDutyFreeShops() { /* 逛免税店 */ }
}

Passenger passenger = ...
passenger.passThroughImmigration();

image.png