从零开始的JVM学习--类加载机制

296 阅读16分钟

简单介绍

什么是类加载机制?

在上一篇从零开始的JVM学习--类文件结构中我们解读了Class文件,而Class文件需要加载到JVM中才能使用,本章我们就主要介绍Class文件如何加载到虚拟机中。

什么是类加载机制?

Class文件需要加载到JVM中之后才能运行和使用。而「类加载机制」就是虚拟机加载Class文件的方式。

JVM的启动是通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成的,这个类由JVM的具体实现指定的。

需要知道的一点是:

类是在运行期间第一次使用时动态加载的,而不是一次性加载所有类。如果一次性加载,那么会占用很多的内存。

类的生命周期

什么是类的生命周期?

image.png

「类的生命周期」是类从被「加载」到虚拟机开始到「卸载」出内存为止的几个阶段,包括以下7个阶段:

  • 加载(Loading)
  • 验证(Verification)
  • 准备(Preparation)
  • 解析(Resolution)
  • 初始化(Initialization)
  • 使用(Using)
  • 卸载(Unloading)

「验证」、「准备」、「解析」可以归纳为「连接」(Linking)

「加载」、「连接」、「初始化」可以归纳为「类加载过程」

接着我们将围绕「类的生命周期」来正式介绍「类加载机制」

类加载机制

类加载过程

加载

什么是加载?

「加载」是「类加载过程」的第一步。(注意不要混淆两者的概念)

「加载」完成了下面3件事情:

  • 通过类全限定名(包名+类名)获取定义此「类的二进制字节流」(加载二进制文件到内存)
  • 将该字节流表示的静态存储结构转换为方法区的运行时存储结构。 (映射成JVM可以识别的格式)
  • 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口(在内存中生成Class文件)

一句话就是:获取字节流,将静态存储结构映射到方法区,在内存中生成Class文件。

如何获取Class文件(类的二进制字节流)?

对于Class文件,JVM没有指定要从哪里获取,怎样获取。除了直接从编译好的.class文件中获取,还有以下几种方式:

  • ZIP 包读取,成为 JAREARWAR 格式的基础。
  • 从网络中获取,最典型的应用是 Applet
  • 运行时计算生成(例如动态代理技术,在 java.lang.reflect.Proxy 使用 ProxyGenerator.generateProxyClass 的代理类的二进制字节流)
  • 由其他文件生成(例如由 JSP 文件生成对应的 Class 类)

"非数组类"与"数组类"加载比较

  • 非数组类

    「加载」阶段可以使用系统提供的「引导类加载器」,也可以由用户自定义的类加载完成。

    开发人员可以通过自定义自己的类加载器控制字节流的获取方式(如重写一个类加载器的loadClass()方法)。

  • 数组类

    「数组类」本身不通过类加载器创建,它是由JVM直接创建的,再由类加载器创建数组中的元素。

「加载」阶段与「连接」阶段的部分内容交叉进行,「加载」阶段尚未完成,「连接」阶段可能已经开始了。

但这两个阶段的开始时间仍然保持着固定的先后顺序。

「连接」是将上面创建好的Class类合并到JVM中,并使之能够执行的过程。

验证

什么是验证?

「验证」阶段确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全

验证过程

  • 文件格式验证

    验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理,验证点如下:

    • 是否以魔数 0XCAFEBABE 开头。
    • 主次版本号是否在当前虚拟机处理范围内。
    • 常量池是否有不被支持的常量类型。
    • 指向常量的索引值是否指向了不存在的常量。
    • CONSTANT_Utf8_info 型的常量是否有不符合 UTF8 编码的数据。
    • ......
  • 元数据验证

    对字节码描述信息进行语义分析,确保其符合 Java 语法规范。(比如该类是否有父类、该类是否继承了不允许继承的类(被final修饰的类)...)

  • 字节码验证

    本阶段是验证过程中最复杂的一个阶段,是对方法体进行「语义分析」,保证方法在运行时不会出现危害虚拟机的事件。

  • 符号引用验证

    本阶段发生在解析阶段,确保解析正常执行。

准备

什么是准备?

「准备」阶段为类变量(static修饰的变量)分配内存并设置默认的初始值(比如int类型的初始值是0)

  • 设置的初始值“通常情况下”是数据类型的默认零值

    如果我们定义了public static int value=123,那么value变量在「准备」阶段的初始值是0而不是123(初始化阶段才会赋值)。

    但是如果我们是public static final int value=123,那么「准备」阶段value的值就被赋值成了123。

    这是因为final修饰的变量在编译的时候就已经分配内存了

从概念上来说这些变量使用的内存在方法区分配。但是在JDK1.7及以后,HotSpot已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量会随着Class对象一起放在Java堆中。

虽然说是类变量的内存分配,但也是有例外的:

  • 实例变量的内存会“准备”吗?

    实例变量不会在这阶段分配内存,它会在对象实例化时随着对象一起被分配在堆中。

    实例化不是类加载的一个过程,类加载发生在所有实例化操作之前。

    类加载只进行一次,实例化可以进行多次。

解析

什么是解析?

「解析」阶段是虚拟机将常量池内的「符号引用」替换为「直接引用」的过程。

如果「符号引用」指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的连接以及初始化。)

「解析」动作主要针对类、接口、字段、类方法、接口方法、方法类型等。对应常量池中的 CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_info等。

「解析」在某些情况下可以在「初始化」阶段之后再开始,这是为了支持 Java 的动态绑定。

  • 什么是符号引用?

    「符号引用」就是一组符号来描述所引用的目标。「符号引用」的字面量形式明确定义在的Class文件格式中。

    简单来说「符号引用」就是字符串,这个字符串包含足够的信息,以供实际使用时可以找到相应的位置。(看完下面的分析相信你能很清楚这句话的含义)

    在我的博客从零开始的JVM学习--类文件结构中,我们分析了常量池中存储的就是「字面量」和「符号引用」。

    那么「符号引用」在Class文件中到底是怎么样的存在呢?

    以下部分学习自知乎RednaxelaFX的回答

    首先编写一个HelloWorld程序:

     public class HelloWorld {
         public static void main(String[] args) {
             System.out.println("Hello World!");
         }
     }
    

    接着编译并用javap命令显示结果,可以明显的抽取出常量池内容:

     Constant pool:
        #1 = Methodref          #6.#15         // java/lang/Object."<init>":()V
        #2 = Fieldref           #16.#17        // java/lang/System.out:Ljava/io/PrintStream;
        #3 = String             #18            // Hello World!
        #4 = Methodref          #19.#20        // java/io/PrintStream.println:(Ljava/lang/String;)V
        #5 = Class              #21            // com/dyh/classfile/HelloWorld
        #6 = Class              #22            // java/lang/Object
        #7 = Utf8               <init>
        #8 = Utf8               ()V
        #9 = Utf8               Code
       #10 = Utf8               LineNumberTable
       #11 = Utf8               main
       #12 = Utf8               ([Ljava/lang/String;)V
       #13 = Utf8               SourceFile
       #14 = Utf8               HelloWorld.java
       #15 = NameAndType        #7:#8          // "<init>":()V
       #16 = Class              #23            // java/lang/System
       #17 = NameAndType        #24:#25        // out:Ljava/io/PrintStream;
       #18 = Utf8               Hello World!
       #19 = Class              #26            // java/io/PrintStream
       #20 = NameAndType        #27:#28        // println:(Ljava/lang/String;)V
       #21 = Utf8               com/dyh/classfile/HelloWorld
       #22 = Utf8               java/lang/Object
       #23 = Utf8               java/lang/System
       #24 = Utf8               out
       #25 = Utf8               Ljava/io/PrintStream;
       #26 = Utf8               java/io/PrintStream
       #27 = Utf8               println
       #28 = Utf8               (Ljava/lang/String;)V
    

    还需要关注的就是Code区:

     {
       public com.dyh.classfile.HelloWorld();
         descriptor: ()V
         flags: ACC_PUBLIC
         Code:
           stack=1, locals=1, args_size=1
              0: aload_0
              1: invokespecial #1                  // Method java/lang/Object."<init>":()V
              4: return
           LineNumberTable:
             line 3: 0
     
       public static void main(java.lang.String[]);
         descriptor: ([Ljava/lang/String;)V
         flags: ACC_PUBLIC, ACC_STATIC
         Code:
           stack=2, locals=1, args_size=1
              0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
              3: ldc           #3                  // String Hello World!
              5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
              8: return
           LineNumberTable:
             line 5: 0
             line 6: 8
     }
    

    观察到main方法中的一条字节码指令:

              5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
    

    在Class文件的实际编码为:

    image-20221015201103864

    0xB6invokevirtual的操作码(opcode),后面的0x0004是该指令的操作数(oprand),用于指定要调用的目标方法。

    这个参数是Class文件常量池的下标。我们找到下标为4的常量项:

        #4 = Methodref          #19.#20        // java/io/PrintStream.println:(Ljava/lang/String;)V
    

    在Class文件的实际编码为:

    image.png

    0x0ACONSTANT_Methodref_infotag,后面的0x00130x0014是该常量池项的两个部分:class_indexname_and_type_index。这两部分分别都是常量池下标,引用着另外两个常量池项。

    顺着这条线索能找出这么一条序列(深度优先):

        #4 = Methodref          #19.#20        // java/io/PrintStream.println:(Ljava/lang/String;)V
        #19 = Class              #26            // java/io/PrintStream
        #26 = Utf8               java/io/PrintStream
        #20 = NameAndType        #27:#28        // println:(Ljava/lang/String;)V
        #27 = Utf8               println
        #28 = Utf8               (Ljava/lang/String;)V
    

    Class文件中的invokevirtual指令的操作数经过几层间接之后,最后都是由字符串来表示的。

    这里的字符串拼接出来就是“java/io/PrintStream.println:(Ljava/lang/String;)V”,这个字符串实际就是一个方法的「符号引用」。里面有类的信息,方法名,方法参数等信息。

    这就是Class文件里的**「符号引用」**的实态:带有类型(tag) / 结构(符号间引用层次)的字符串。

  • 什么是直接引用?

    「直接引用」就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

    直接引用如何由符号引用转换?(解析的实际过程)

    假定我们第一次执行到main方法中的invokevirtual指令。

    此时JVM会发现该指令尚未被「解析」,所以先去「解析」。

    通过其操作数所记录的常量池下标0x0004,找到常量池项#4,发现该常量池项也尚未被「解析」(resolve),于是进一步去「解析」一下。

    通过Methodref所记录的class_index找到类名(java/io/PrintStream),进一步找到被调用方法的类的ClassClass结构体;

    然后通过name_and_type_index找到方法名(println)和方法描述符((Ljava/lang/String;)V),到ClassClass结构体上记录的方法列表里找到匹配的那个methodblock;最终把找到的methodblock的指针写回到常量池项#4里。

    这里的methodblock就是一个「直接引用」,可以直接找到Java方法元数据。

    原本常量池项#4在类加载后的运行时常量池里的内容跟Class文件里的一致,是:

     [00 13] [00 14]
    

    在解析以后假设找到的methodblock0x45762300,那么常量池#4的内容会变成:

     [00 23 76 45]
    

    (「解析」后字符序使用x86原生使用的低位在前字节序(little-endian),为了后续使用方便)

    这样,以后再查询到常量池项#4时,里面就不再是一个「符号引用」,而是一个能直接找到Java方法元数据的methodblock了(直接引用)。

    「解析」完成后我们回到invokevirtual指令的解析

    在解析以前指令的内容是:

     [B6] [00 04]
    

    在解析后,这块代码被改写成:

     [D6] [06] [01]
    

    其中opcode部分从invokevirtual改写为invokevirtual_quick(操作码为D6),以表示该指令已经「解析」完毕。

    原本存储操作数的2字节空间现在分别存了2个1字节信息,第一个是虚方法表的下标(vtable index) ,第二个是方法的参数个数。这两项信息都由前面「解析」常量池项#4得到的methodblock读取而来。

    在解析/改写后的invokevirtual_quick指令里,虚方法表下标(vtable index) 也是一个**「直接引用」**的表现。

初始化

什么是初始化?

「初始化」阶段是执行初始化方法 <clinit> ()方法的过程,是「类加载」的最后一步。

这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。

  • 什么是<clinit> ()方法?

    <clinit> ()方法是编译之后自动生成的。

    <clinit> () 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static {} 块)中的语句合并产生的(编译器收集的顺序是由语句在源文件中出现的顺序所决定的)

    <clinit>() 方法不是必需的,如果一个类没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成 <clinit>() 方法。

    • <clinit> ()方法的调用

      • 线程安全性

        对于<clinit> () 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。保证一个类的 <clinit>() 方法在多线程环境中被正确加锁、同步。

        如果多个线程同时去「初始化」一个类,那么只会有一个线程去执行这个类的 <clinit>() 方法。

        因为 <clinit> () 方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个线程阻塞,并且这种阻塞很难被发现。

      • 父类初始化和子类初始化的执行顺序

        <clinit>() 方法不需要显式调用父类构造器,虚拟机会保证在子类的 <clinit>() 方法执行之前,父类的 <clinit>() 方法已经执行完毕。

        由于父类的 <clinit>() 方法先执行,意味着父类中定义的静态语句块要优先于子类的变量赋值操作。如下方代码所示:

         static class Parent {
             public static int A = 1;
             static {
                 A = 2;
             }
         }
         ​
         static class Sub extends Parent {
             public static int B = A;
         }
         ​
         public static void main(String[] args) {
             System.out.println(Sub.B); // 输出 2
         }
        
    • 接口初始化?

      接口中不能使用静态代码块,但接口也需要通过 <clinit>() 方法为接口中定义的静态成员变量显式「初始化」。

      但接口与类不同,接口的 <clinit>() 方法不需要先执行父类的 <clinit>() 方法,只有当父接口中定义的变量使用时,父接口才会「初始化」。

什么时候对类进行初始化?

  • 主动引用

    对于「初始化」阶段,虚拟机严格规范了有且只有 5 种情况下,必须对类进行「初始化」(加载、验证、准备都会随之发生):

    • 当遇到newgetstaticputstaticinvokestatic 这 4 条直接码指令时(这4条指令对应4个操作:new一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时)

      • JVM 执行 new 指令时会初始化类。即当程序创建一个类的实例对象。
      • JVM 执行 getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。
      • JVM 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值。
      • JVM 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法。
    • 使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forname("..."), newInstance() 等等。如果类没「初始化」,需要触发其「初始化」。
    • 「初始化」一个类,如果其父类还未「初始化」,则先触发该父类的「初始化」。
    • 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先「初始化」这个类。
    • MethodHandleVarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用 findStaticVarHandle 来初始化要调用的类。
  • 被动引用

    以上5种场景种的行为称为对一个类进行「主动引用」。

    除了「主动引用」之外,所有引用类的方式不会触发「初始化」,称为「被动引用」。

    所以这里「被动引用」的介绍只是引用情况的补充。「被动引用」的常见例子包括:

    • 通过子类引用父类的静态字段,不会导致子类初始化。

       System.out.println(SuperClass.value);  // value 字段在 SuperClass 中定义
      
    • 通过数组定义来引用类,不会触发此类的初始化。

      该过程会对数组类进行初始化,数组类是一个由虚拟机自动生成的、直接继承自 Object 的子类,其中包含了数组的属性和方法。

       SuperClass[] sca = new SuperClass[10];
      
    • 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。

       System.out.println(ConstClass.HELLOWORLD);
      

到此位置类加载就完成了,「使用」阶段就是执行我们编写的Java程序的过程,最后也就剩下「卸载」过程

卸载

什么是卸载?

「卸载」类即该类的 Class 对象被 GC

卸载类需要满足3个要求?

  • 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
  • 该类没有在其他任何地方被引用
  • 该类的类加载器的实例已被 GC

所以,JVM生命周期内,由JVM自带的类加载器加载的类是不会被「卸载」的。但是由我们自定义的类加载器的类是可能被「卸载」的。

  • 为什么呢?

    JDK 自带的 BootstrapClassLoader, ExtClassLoader, AppClassLoader 负责加载 JDK 提供的类,所以它们(类加载器的实例)肯定不会被回收

    我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被「卸载」掉的。

小结

本篇我们介绍了类的生命周期,对多个阶段进行了划分,详细介绍了类加载过程。类加载阶段结束后,JVM就可以正常的执行我们的Java程序了。下一章我们将介绍类加载器——加载类过程的执行者。

本章参考: