JVM 深入浅出 :一文看懂 JVM 类加载机制


类的生命周期一共分为七个阶段:

JVM-类加载机制

类加载过程为加载、验证、准备、解析和初始化五个部分,其中验证、准备和解析三个部分又被称为 连接(Linking)

这些过程并不是严格的线性过程,中间会穿插执行。比如加载为完成前,连接过程可能已经开始(比如文件格式的校验);比如解析可能发生在初始化前也可能在初始化后等等。

现在对这 5 个阶段做详细分析。

1. 加载

把编译后的 Class 文件载入内存,创建一个 java.lang.Class 对象。

这个阶段要完成三件事情:

  • 使用类的 全限定名 来获取类的二进制字节流。
  • 将二进制字节流的 静态结构 进行转化,存储为方法区的 运行时结构
  • 在内存中生成 java.lang.Class 对象,作为访问的入口。经常使用 Java 反射会对 java.lang.Class 对象非常熟悉,因为可以从该对象中获取到很多类的信息,比如字段、方法等。

Class 文件的来源可以多种多样,由不同的类加载器实现不同来源加载,比如:

  • 本地的 Class 文件直接加载。比如 classpath 路径下的 .class 文件。
  • 网络加载。
  • 压缩包中加载,zip、jar 或者 war 加载。比如第三方类库打包压缩在 jar 中,部署到 tomcat 的 Class 文件打包在 war 中。
  • 运行时创建。Java 提供了一些工具类来实现,比如java.lang.reflect 包中的 Proxy 工具,可以直接在内存中创建动态代理对象。
  • 由其他文件生成,比如 JSP 文件。

JVM-类加载机制-加载

我们可以也自定义类加载器来实现从其他渠道加载 Class 文件。

载入内存后,生成 java.lang.Class 对象非常特别,因为它被存放在方法区中而不是堆中。

2. 验证

验证是连接的第一步。对 Class 文件的格式检查,确保满足 JVM 规范,避免产生虚拟机的安全问题。

因为上面说到 Class 文件不一定是 javac 编译产生的,有各种方式可以创建,对于编译时做的数组越界、对象类型转换错误等问题,类加载过程还需要再次校验。

不过验证还是挺耗性能的,如果 Class 文件已经被反复验证多次,可以使用 -Xverify:none 来缩短类加载时间。

验证主要有四种验证:文件格式验证、元数据验证、字节码验证和符号引用验证。

2.1. 文件格式验证

文件格式验证,确保符合 Class 文件规范,能被当前版本的虚拟机处理。比如:

  • 检查魔数(Magic Number)是否是 Class 文件(0xCAFEBABY)。

  • 检查主次版本对不对。

  • 检查常量池的类型和索引是否正确。

    ……

在经历过这个阶段后,字节流才会被存储到方法区中。所以文件校验发生在 加载 阶段还未结束前。经过文件格式校验后,后续的校验都是基于方法区执行的。

2.2. 元数据验证

元数据验证,字节码语义分析,看是否符合语法规范。比如:

  • 是否有父类。除了 java.lang.Object,所有的类都要有父类。

  • 是否继承了不允许被继承的类。比如被 final 修饰类不允许被继承。

  • 如果不是抽象类,是否实现了父类或接口要求实现的方法。

  • 是否类的字段和方法和父类有矛盾。比如覆盖了父类的 final 字段。

    ……

这些语法规范如果是编译的 Class 文件,在编译期间也会做校验。但因为 Class 文件来源多样,并不能确保遵循语法规范,所以这里还要再进行一次验证。

2.3. 字节码验证

字节码校验。对字节码 数据流控制流 进行分析,确定语义正确。这个是所有校验最复杂的阶段。比如:

  • 正确的操作指令。操作操作局部变量表和操作数栈的数据,指令类型要正确。

  • 正确的跳转指令。跳转到正确的字节码。

  • 正确的类型转换。比如子类可以赋值给父类,但父类不允许赋值子类。

    ……

这里举个校验的例子。

在执行字节码执行的时候,对局部变量表和操作数栈的数据进行操作时,需要使用正确的指令类型。比如局部变量表索引 1 是 int 类型,加载它需要用 iload_1 字节码,而不是 lload_1fload_1dload_1 。在元数据校验阶段就可以做强制约束,排除错误使用。

  public int add(int, int);
    descriptor: (II)I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=3
         0: iload_1					 	# 因为局部变量类型为 int,使用 iload 加载						
         1: iload_2
         2: iadd
         3: ireturn
      LineNumberTable:
        line 32: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       4     0  this   Loblee/demo/jvm/stack/SimpleObject;
            0       4     1     a   I	# 局部变量类型为 int
            0       4     2     b   I	# 局部变量类型为 int

经过了复杂的字节码验证,其实还是无法确保运行的安全。属于著名的 “Halting Problem”

复杂的验证也十分消耗性能,HotSpot 虚拟机做了很多优化。

2.4. 符号引用验证

符号引用校验。这个发生在符号引用转换为直接引用的时候,属于 解析 阶段的验证,用来确认引用一定会被访问到。比如:

  • 符号引用的全限定名是否能找到对应的类。

  • 符号引用的字段描述符或方法描述符是否能在类中找到对应字段和方法。

  • 符号引用的类、字段和方法的作用域(public、protected、private、default)是否可以被当前类访问。

    ……

符号引用验证也说明了这几个阶段不是线性的,而是会会穿插执行。

无法通过符号引用验证的话,会抛出相关的异常,比如 java.lang.IllegalAccessErrorjava.lang.NoSuchFieldErrorjava.lang.NoSuchMethodError 等等。

3. 准备

类变量 分配内存,设置初始值。

分配的初始值具体是多少?各个数据的默认零值,有 0、0L、null、false 等。

比如下面的类变量 a:

public class SimpleObject {
    public static String a = "hello world";
    public static String b = 2;
}

准备 阶段给 a 的初始值是 null,而不是 “hello world” 字符串,给 b 的初始值是 0 而不是 2。a 和 b 的真实赋值发生在 初始化 阶段,放在了类构造器 <clinit>() 中,使用 putstatic 指令实现。

上面是类变量的处理过程,常量并非如此。如果是字符串或者基础数据类型(int、long、float 等等)的常量类型,准备阶段会直接分配具体数据。

还是 SimpleObject 对象,我们使用 final 关键字将 a 和 b 修饰为类常量:

public class SimpleObject {
    public static final String a = "hello world";
    public static final String b = 2;
}

准备阶段,会直接从运行时常量池中取出 “hello world” 字符串给 a 赋值,从常量池中取出 2 对 b 赋值(这时候 2 被加入到常量池中)。

注意,准备阶段只是处理 类变量,而不是 实例变量。实例变量的在类实例化后一同和实例对象分配在堆中。

4. 解析

解析的过程,就是将符号引用转为直接引用的过程。

符号引用中主要有:

  • 类和接口的全限定名。
  • 字段名称和描述符。
  • 方法名称和描述符。

对应在 class 文件中的常量类型为 CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_info 。编译后在 class 文件的静态常量池中,类加载后进入运行时常量池。之所以需要符号引用,是因为 javac 将代码编译成 class 文件时,是不知道这些类、字段和方法在内存中的位置的,需要有一个符号来代替。

比如我们有一个类 ObjectA ,引用了 ObjectB 作为成员变量:

public class  ObjectA {

    private ObjectB b;

    public void setB(ObjectB b) {
        this.b = b;
    }

    public ObjectB getB() {
        return b;
    }
}

使用 javap 查看这个类的符号引用:

JVM-类加载机制-符号引用

直接引用主要有:

  • 目标指针,比如 Class 对象、类变量、类方法在方法区的指针。
  • 相对偏移量,比如实例变量在堆中的相对偏移量。
  • 能间接定位到目标的 句柄

可以直接引用对应的目标已经在内存中了。直接引用和虚拟机的内存布局相关,在不同虚拟机的直接引用是不一样的。

这些在未解析前都是字符串,存在方法区的运行时常量池里(HotSpot 1.8 方法区实现做了调整,符号引用被放到了由本地内存组成的元空间中),字节码解释器在执行字节码的时候是无法直接使用符号引用的,所以需要翻译成直接引用,可以是内存中的

这个解析过程不一定在 初始化 前执行,也有可能延迟到一个符号引用需要使用到的时候,在栈帧进行动态链接。

JVM-虚拟机栈-栈帧-动态链接

初始化前的解析的被称为 静态绑定 ,栈帧里的解析被称为 动态绑定

解析完成后,符号引用对应的直接引用会被记录在运行时常量池中,后续如果重复解析,直接返回对应的直接引用。

5. 初始化

经过了加载和连接(校验、准备和解析),类已经载入内存并且分配了初始内存,开始进行初始化。

加载过程我们可以自定义类加载器(比如 ClassLoader) 来接管加载,连接完全是虚拟机自动处理的,而初始化才开始正式执行字节码。

初始化就是执行类构造器 <clinit>() 的过程。<clinit>() 由类变量赋值加上 static{} 语句块的代码合并而成,这个并不是必须的,如果没有静态变量赋值过程的话,是不会生成 <clinit>() 的。

<clinit()> 调用一些指令来实现静态变量的赋值操作, 比如使用 putstatic

准备类 SimpleObject 如下:

public class SimpleObject {
    public static String  a = "hello world";
    public static int     b = 100;
    public static ObjectC c = new ObjectC();
    public static Class   d = ObjectD.class;
    public static int     e = ObjectE.e;
    public static int     f = ObjectF.getF();
    public static int     h = 10;
    public static String  i = ObjectI.I;
    public static int     k = ObjectJ.k;

    static {
        ObjectH.h = h;
    }
}

使用 javap 查看 SimpleObject 的类构造器 <clinit>

  static {};
    descriptor: ()V
    flags: ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: ldc           #2                  // String hello world
         2: putstatic     #3                  // Field a:Ljava/lang/String;
         5: bipush        100
         7: putstatic     #4                  // Field b:I
        10: new           #5                  // class oblee/demo/jvm/pre/ObjectC
        13: dup
        14: invokespecial #6                  // Method oblee/demo/jvm/pre/ObjectC."<init>":()V
        17: putstatic     #7                  // Field c:Loblee/demo/jvm/pre/ObjectC;
        20: ldc           #8                  // class oblee/demo/jvm/pre/ObjectD
        22: putstatic     #9                  // Field d:Ljava/lang/Class;
        25: getstatic     #10                 // Field oblee/demo/jvm/pre/ObjectE.e:I
        28: putstatic     #11                 // Field e:I
        31: invokestatic  #12                 // Method oblee/demo/jvm/pre/ObjectF.getF:()I
        34: putstatic     #13                 // Field f:I
        37: bipush        10
        39: putstatic     #14                 // Field h:I
        42: ldc           #16                 // String objectI
        44: putstatic     #17                 // Field i:Ljava/lang/String;
        47: getstatic     #18                 // Field oblee/demo/jvm/pre/ObjectJ.k:I
        50: putstatic     #19                 // Field k:I
        53: getstatic     #14                 // Field h:I
        56: putstatic     #20                 // Field oblee/demo/jvm/pre/ObjectH.h:I
        59: return

如果静态变量是 字符串类型,作为字面量加入了常量池,初始化时从常量池中取出赋值。比如上面的 “hello world” 直接从常量池取出赋值给 a。

如果静态变量是 基础数据类型,直接使用指令载入。比如上面的 bipush 指令。

如果静态变量是个 类实例的引用类型,但是类还未加载,会再次触发这个类的类加载流程。

初始化不是在类加载后就马上执行的,有一定的触发条件。JVM 规范没有规定什么时候开始进行类加载流程,但对类初始化有进行严格的规定,有四条字节码指令 newgetstaticputstaticinvokestatic 这 4 条指令,如果没有初始化会先触发初始化。

常见的场景有:

  • 使用 new 关键词实例化对象。上面对静态变量 c 赋值,需要对类 ObjectC 进行实例化,所以会触发 ObjectC 的初始化。
  • 读取类的静态变量。上面赋值静态变量 e, getstatic #10 ,触发 ObjectE 的初始化。
  • 设置类的静态变量。上面设置 ObjectH 的静态变量 h,putstatic #20 ,触发 ObjectH 的初始化。
  • 调用类的静态方法。上面调用 ObjectF 的静态方法 getF()invokestatic #12 ,触发 ObjectF 的初始化。

比如下面几种情况不会执行类初始化:

  • 通过子类引用父类的静态字段,只触发父类的初始化,不会触发子类的初始化。
  • 使用对象数组,不会触发该类初始化。
  • 常量被存入常量池中,没有直接引用常量的类,不会触发定义常量的类的初始化。
  • 通过类名获取 Class 对象,不会触发初始化。比如上面使用 ObjectD.class 并不会触发 ObjectD 类的初始化。
  • 使用 Class.forName() 加载类,参数 initialize 为 false 不会触发初始化。
  • 通过 ClassLoader 默认的 loadClass 方法加载类不会触发初始化。
发布了61 篇原创文章 · 获赞 43 · 访问量 6万+

猜你喜欢

转载自blog.csdn.net/firefile/article/details/102617459