虚拟机类加载机制

1,020 阅读7分钟

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。 ——《深入理解Java虚拟机》

类加载的时机

类的生命周期:

加载、连接(验证、准备、解析)、初始化、使用、卸载。 类的生命周期

这五个阶段的顺序是确定的,类加载必须按照这种顺序“开始”(这些阶段通常是交叉混合进行的,所以执行顺序与完成顺序可能并不是按照开始顺序)。

第一阶段“加载”啥时候开始?

《Java虚拟机规范》中并没有进行强制约束,但是对于初始化阶段,则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之 前开始):

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
    1. 使用new关键字实例化对象的时候。
    2. 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
    3. 调用一个类型的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需 要先触发其初始化。
  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先 初始化这个主类。
  5. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解 析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句 柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  6. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

接口初始化与类初始化不同,只要是体现在上述第3点,当一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

类加载的过程

加载

在加载阶段,Java虚拟机需要完成以下三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入 口。

总结一下,就是读取Class文件,将类结构存到方法区,再在堆内创建一个Class对象。

连接

验证

验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚 拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

  1. 文件格式验证
  2. 元数据验证
  3. 字节码验证
  4. 符号引用验证

准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。

🌰1
//对于static变量来说,在准备阶段结束之后,value的值是0,而非123,等到初始化时才会设置成123。
public static int value = 123;

🌰2
//而对于常量来说,在准备阶段就会被设置成123。
public final static int value = 123;

解析

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

符号引用就是字符串,这个字符串包含足够的信息,以供实际使用时可以找到相应的位置。你比如说某个方法的符号引用,如:“java/io/PrintStream.println:(Ljava/lang/String;)V”。里面有类的信息,方法名,方法参数等信息。

符号引用主要包括下面几类常量:

  • 被模块导出或者开放的包(Package)
  • 类和接口的全限定名(Fully Qualified Name)
  • 字段的名称和描述符(Descriptor)
  • 方法的名称和描述符
  • 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
  • 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)
  • ... ...

在编译期间,Class文件中不会保存各个方法、字段最终在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存地址的。当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。

关于符号引用可以看看R大的回答。JVM里的符号引用如何存储?

还有《Java虚拟机规范》第四章class文件格式的第四节常量池 有介绍符号引用的格式。

初始化

初始化阶段就是执行类构造器clinit()方法的过程。

何为clinit()

clinit()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的。就是static变量和static方法合并后的结果。

如下图,static的变量赋值和方法会被合并到static{}(javap反编译的结果,static{}就是clinit())里。

clinit()方法
clinit()方法

类加载器

  1. BootstrapClassLoader在HotSpot中是用C++实现的,是虚拟机的一部分,负责加载JDK/jre/lib下的
  2. ExtClassLoader负责加载JDK/jre/lib/ext, java.ext.dirs系统变量指定的路径中的所有类库
  3. AppClassLoader负责加载用户类路劲下所指定的类

类加载机制

  • 全盘负责,当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入
  • 父类委托,先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类
  • 缓存机制,缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效

双亲委派模型

  1. 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。

  2. 当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。

  3. 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;

  4. ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException

使用双亲委派模型的好处

防止重复加载,防止核心库被修改。

使同名类(🌰java.lang.Object)在程序的各种类加载器环境中都能够保证是同一个类。

参考

《深入理解Java虚拟机》《Java虚拟机规范

图片来源《深入理解Java虚拟机》

本文使用 mdnice 排版