【深入理解 Java 虚拟机笔记】虚拟机类加载机制

6.虚拟机类加载机制

虚拟机的类加载机制,即虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成虚拟机可以直接使用的 Java 类型。

在 Java 语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成,这虽然增加一些性能开销,但是会为 Java 应用程序提供高度的灵活性。

思维导图

在这里插入图片描述

类加载的时机

类的生命周期:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中验证、准备和解析统称为连接(Linking),如图:
在这里插入图片描述

加载、验证、准备、初始化和卸载这五个阶段的顺序是固定的,但解析阶段则不一定。虚拟机规范没有强制约束类加载的时机,但严格规定了有且只有5种情况必须立即对类进行初始化:

  1. 遇到 newgetstaticputstaticinvokestatic指令,常见的 Java 场景:遇到 new、读取一个类的静态字段、设置一个类的静态字段、调用一个类的静态方法
  2. 对类进行反射调用时,如果类没有进行过初始化,先触发其初始化
  3. 初始化一个类时,发现其父类还没有进行初始化,先触发父类的初始化
  4. 虚拟机启动时,用户指定的主类(包含 main() 方法的类)会先初始化
  5. 当使用 JDK1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化

这五种场景的行为称为对一个类进行主动引用,除此之外,所有引用类的方式都不会触发初始化,称为被动引用。

被动引用例子一

public class SuperClass {
   static {
      System.out.println("SuperClass Init!");
   }

   public static int value = 123;
}

public class SubClass extends SuperClass {
	static {
		System.out.println("SubClass Init!");
	}
}

/**
 * @author chen
 * 通过子类引用父类的静态字段,不会导致子类初始化
 */
public class NotInitialization {
	public static void main(String[] args) {
        
		System.out.println(SubClass.value);
	}
}

其输出结果:

SuperClass Init!
123

对于静态字段,只有直接定义这个字段的类才会被初始化,通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。

被动引用例子二

/**
 * @author chen
 * 通过数组定义来引用类,不会触发此类的初始化
 */
public class NotInitialization {
	public static void main(String[] args) {
		SuperClass[] superClasses = new SuperClass[10];
	}
}

被动引用例子三

public class ConstClass {
   static {
      System.out.println("ConstClass Init!");
   }

   public static final String HELLOWORLD = "hello world";
}

/**
 * @author chen
 * 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发其初始化
 */
public class NotInitialization {
	public static void main(String[] args) {
		System.out.println(ConstClass.HELLOWORLD);
	}
}

虽然在 Java 源码中引用了 ConstClass 类中的常量,但其实在编译阶段通过常量传播优化,已经将此常量存储到 NotInitialization 类的常量池中,NotInitialization 对常量 ConstClass.HELLOWORLD 的引用实际都转化为 NotInitialization 类对自身常量的引用。

接口和类的加载过程稍有不同,接口不能使用 static{} 语句块,但编译器仍会为接口生成 <clinit>() 类构造器,用于初始化接口所定义的成员变量。接口和类区别在于第三种场景,类初始化时,必须要求父类全部初始化,但接口初始化并不要求父接口初始化,只有真正使用父接口时候(如引用接口中定义的常量)才会初始化。

类加载的过程

加载

在加载阶段,虚拟机需要完成:

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

一个非数组类的加载阶段,可以通过自定义的类加载器去控制字节流的获取方式。

而对于数组类,情况有所不同,数组类本身不通过类加载器创建,而是由 JVM 直接创建,但数组类的元素类型(Element Type,指的是数组去掉所有维度的类型)最终还是要通过类加载器去创建,数组类创建过程遵循以下规则:

  • 数组的组件类型(Component Type,指数组去掉一个维度的类型)是引用类型,则递归采用加载过程去加载这个组件类型,数组类将在加载该组件类型的类加载器的类名称空间上被标识
  • 组件类型不是引用类型(如 int[] 数组),JVM 会将数组类标记为与引导类加载器关联
  • 数组的可见性与它的组件类型一致,如果组件类型不是引用类型,那默认可见性为 public

验证

连接阶段的第一步,目的为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

文件格式验证

第一阶段验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。

主要验证:

  • 是否以魔数 0xCAFEBABE 开头
  • 版本号是否在当前虚拟机处理范围之内
  • 常量池的常量是否不被支持的常量类型(常量 tag 标志)
  • 指向常量的索引值中是否有指向不存在的常量或不符合类型的常量
  • CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 编码的数据
  • Class 文件中各个部分及文件本身是否有被删除或附加的其他信息

只有通过了该阶段的验证,才能进入内存的方法区,所以后面的验证阶段都是基于方法区的存储结构进行的。

元数据验证

第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息都符合 Java 语言规范的要求。其主要目的是对类的元数据信息进行校验,保证不存在不符合 Java 语言规范的元数据信息。

其验证点:

  • 这个类是否有父类(除了 java.lang.Object 之外,所有类都应当有父类)。
  • 这个类的父类是否继承了不允许继承的类(被 final 修饰的类)。
字节码验证

最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。这个阶段将对类的方法体进行校验分析,保证被校验的类方法不会做出危害虚拟机安全的事件,例如:

  • 保证任意时刻操作数栈的数据类型与指令代码都能配合工作。
  • 保证跳转指令不会跳转到方法体以外的字节码指令上。
  • 保证方法体中的类型转换是有效的。

通过了字节码验证,也不能说明其一定就安全。在 JDK 1.6 之后进行了优化,给方法体的 Code 属性的属性表中增加了一项 StackMapTable 的属性,它描述了方法体的所有基本块(Basic Block,按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态,在字节码验证期间,就不需要推导这些状态的合法性,只需要检查 StackMapTable 属性中的记录是否合法即可。StackMapTable 属性也可能存在错误或被篡改的可能。

符号引用验证

该阶段验证发生在虚拟机将符号引用转换为直接引用的时候,这个动作将在连接的第三阶段——解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,通常校验:

  • 符号引用中通过字符串描述的全限定名是否能够找到对应类
  • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
  • 符号引用中的类、字段、方法的访问性是否可被当前类访问

其目的确保解析动作能正常执行,如果无法通过符号引用验证,抛出 java.lang.IncompatibleClassChangeError 异常的子类。

验证阶段是非常重要、但并不一定是必须的阶段。

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都是在方法区进行分配。

  • 进行内存分配只包括类变量(被 staic 修饰的变量),不包括实例变量
  • 所设置的初始值是数据类型的零值,如 public static int value = 123; 其中变量 value 在准备阶段后的初始值为 0,赋值 123 的动作将在初始化阶段才会执行,通过类构造器 <clinit> 方法设置。
  • 但如果类字段的字段属性表中存在 ConstantValue 属性,那么准备阶段变量 value 就会被初始化为 ConstantValue 属性所指定的值,如 public static final int value = 123; ,则编译时 Javac 就会为 value 生成 ConstantValue 属性,在准备阶段就会将 value 赋值为 123。

解析

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

符号引用和直接引用的关系:

  • 符号引用(Symbolic Reference):符号引用是一组符号来描述所引用的目标对象,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标对象并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因此符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。
  • 直接引用(Direct References):直接引用可以是直接指向目标对象的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机内存布局实现相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,那引用的目标必定已经在内存中存在。

虚拟机规范并没有规定解析阶段发生的具体时间,只要求了在执行 anewarry、checkcast 等16个用于操作符号引用的字节码指令之前,先对它们使用的符号引用进行解析,所以虚拟机实现会根据需要来判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行,主要介绍前面 4 种引用的解析过程,对于后面 3 种,与动态语言支持相关。

  1. 类或接口的解析,对应常量池的 CONSTANT_Class_info
  2. 字段的解析,对应常量池的 CONSTANT_Fieldref_info
  3. 类方法的解析,对应常量池的 CONSTANT_Methodref_info
  4. 接口方法的解析,对应常量池的 CONSTANT_InterfaceMethodref_info

初始化

类的初始化阶段是类加载过程的最后一步,该阶段才真正开始执行类中定义的 Java 程序代码(或者说是字节码)。

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

  • <clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块( static{} 块)的语句合并而成的。收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块只能访问到定义在静态语句块之的变量,而定义在它之的变量,在前面的静态语句块中可以赋值,但不能访问。如

    public class Test{
        static{
            i = 0;//给变量赋值可以编译通过
            System.out.println(i);//编译器会提示“非法向前引用”
        }
        static int i = 1;
    }
    
  • <clinit>() 方法与实例构造器 <init>() 方法不同,不需要显式调用父类构造器,虚拟机保证子类的 <clinit>() 方法在执行前,父类的 <clinit>() 方法已经执行完毕,所以第一个被执行的 <clinit>() 方法的类肯定是 java.lang.Object。

  • 父类的 <clinit>() 方法先执行,所以父类中的静态语句块要先于子类的变量赋值操作,如

    static class Parent{
        public static int A = 1;
        static{
            A = 2;
        }
    }
    
    static class Son extends Parent{
        public static int B = A;
    }
    
    public static void main(String[] args){
        //打印 2
        System.out.print(Son.B);
    }
    
  • <clinit>() 方法并不是必须的,如果类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为该类生成 <clinit>() 方法。

  • 接口中不能使用静态语句块,但可以有变量初始化的赋值操作,因此接口与类都一样会生成 <clinit>() 方法。但与类不同,执行接口的 <clinit>() 方法并不需要先执行父接口的 <clinit>() 方法,只有当父接口定义的变量被使用的时候,父接口才会初始化。并且,接口的实现类在初始化时也一样不会执行父接口的 <clinit>() 方法。

  • 虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行 <clinit>() 方法,其他线程阻塞等待,并且 <clinit>() 方法只会被执行一次

类加载器

虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”动作放到 JVM 外部去实现,让应用程序自己决定如何获取所需要的类,这个动作的代码模块称为“类加载器”。

类与类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确定在 JVM 的唯一性,每一个类加载器,都拥有一个独立的类名称空间。也就是说,即使两个类来源于同一个 Class 文件,被同一个 JVM 加载,但类加载器不同,那么两个类就必定不相等。(相等,包括代表类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance()方法的返回结果,也包括使用 instanceof 关键字做对象所属关系)。

双亲委派模型

从 JVM 的角度来说,类加载器只有两种:

  1. 启动类加载器(Bootstrap ClassLoader),使用 C++ 实现,是虚拟机的一部分
  2. 所有其他的加载器,由 Java 实现,独立于虚拟机外部,继承抽象类 java.lang.ClassLoader

从 Java 开发人员来说,类加载器可以划分为以下 3 种系统提供的类加载器:

  1. 启动类加载器(Bootstrap ClassLoader),这个类加载器将放在 <JAVA_HOME>\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别(仅按照文件名识别)类库加载到虚拟机内存中。启动类加载器无法直接被 Java 程序使用,用户在编写自定义类加载器,如果需要把加载请求委派给引导类加载器,直接用 null 代替即可。
  2. 扩展类加载器(Extension ClassLoader),这个加载器由 sun.misc.Launcher$ExtClassLoader实现,负责加载<JAVA_HOME>\lib\ext目录中的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用它。
  3. 应用程序类加载器(Application ClassLoader),由于它是 ClassLoader 中的 getSystemClassLoder() 方法的返回值,所以一般也称为系统类加载器,负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,一般情况下默认的类加载器。

这些类加载器的关系:
在这里插入图片描述

这种层次关系称为类加载器的双亲委派模型(Parents Delegation Model)。要求除了顶层的启动类加载器,其余的类加载器都应当由自己的父类加载器,其父子关系一般不会以继承来实现,而是使用组合关系来复用父加载器代码。

工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,所以所有加载请求都会传送到启动类加载器,当父加载器反馈自己无法完成这个加载请求(其搜索范围没有这个类),子加载器才会尝试自己加载。

双亲委派模型来描述类加载的顺序,好处是类随着它的类加载器一起具备了一种带优先权的层次关系,例如类 java.lang.Object ,无论哪个类加载器要加载它,都会委派到启动类加载器去加载,所以 Object 类在各种类加载器环境都是同一个类。

破坏双亲委派模型

第一次被破坏在双亲委派模型出现之前(JDK 1.2之前),类加载器和抽象类 java.lang.ClassLoader 在 JDK 1.0 已经存在,用户去继承 java.lang.ClassLoader 唯一目的是为了重写 loadClass() 方法

为了向前兼容,java.lang.ClassLoader 添加了一个新的 protected 方法 findClass() ,此时已经不提倡覆写 loadClass() 方法,而应当将自己的类加载逻辑写到 findClass() 方法中loadClass() 方法的逻辑为如果父类加载失败,则调用自己的 findClass() 方法来完成加载,这样保证新写出来的类加载器是符合双亲委派规则。

第二次被破坏是模型本身的缺陷,双亲委派让越基础的类由越上层的加载器进行加载,但如果基础类要调回用户的代码,此时就会破坏双亲委派规则

典型的例子是:JNDI 服务,它的代码由启动类加载器去加载,但 JNDI 的目的就是对资源进行集中管理和查找,需要调用独立厂商实现并部署在应用程序的 ClassPath 下的 JNDI 接口提供者(SPI,Service Provider Interface)代码。

所以 Java 引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。可以通过 java.lang.Thread 类的 setContextLoaser() 方法进行设置,如果创建线程时未设置,会从父线程中继承一个,如果在应用程序的全局范围都没有设置,那么这个类加载器默认就是应用程序加载器。通过线程上下文类加载器,JNDI 服务器就能去加载所需要的 SPI 代码。Java 所有涉及 SPI 的加载动作基本都采用这种方法,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等。

第三次被破坏是由于用户对程序动态性的追求导致的。动态性指的是代码热替换、模块热部署等。OSGI 已经成为 Java 模块化标志,而 OSGI 实现模块化热部署的关键是自定义的类加载器机制的实现。它的部分类查找变为平级的类加载器中进行的。

小结

介绍类加载过程的加载、验证、准备、解析和初始化五个阶段中虚拟机的行为,以及类加载器的工作原理和对虚拟机的意义。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值