Java类加载机制知识点复习
前言
1. 基本概念
在上文中的JVM概述中,在数据运行区时之前,存在一个ClassLoader,即类加载器。
类加载器的功能是将Class文件中的信息加载内存中,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。
2. 概要流程
类加载流程主要分为如下几个阶段 :加载,验证,准备,解析,初始化,使用和卸载。
上述几个流程之中,除了解析流程之外的其他阶段的顺序(开始顺序)都是固定的。解析阶段会有可能会在初始化之后开始,支持Java的运行时绑定。
验证、准备和解析三个阶段统称为连接。
一、加载
加载主要分为如下几步:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
获取字节流并没有限制只能从.class文件中获取,在运行时计算生成如反射中的动态生成代理类也包括在其中。
-
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
-
在内存中生成一个代码此类的Class对象,作为方法区这个类的各种数据的访问入口。
数组类的加载和普通类加载有所区别。数组类本身不通过类加载器加载,而是由虚拟机直接完成。但是数组类中具体的元素类型仍然是靠类加载器完成加载的,如String[] 数组其元素类型是String,是靠类加载器加载的。
二、 验证
验证是连接阶段的第一步。
这一阶段的主要目的是确保Class文件流中的信息符合虚拟机的规范。 验证阶段主要分为四个步骤:文件格式验证,元数据验证,字节码验证和符号引用验证。
1. 文件格式验证
文件格式验证主要是验证字节流是否符合Class文件格式的规范,是否能够被虚拟机处理。存在有如下内容:
- 是否以魔数(0xCAFEBABE)开头。
每个Class文件的头4个字节被成为魔数,是一个16进制的固定值,其作用是确保这个CLass文件能够被虚拟机接受。
- 主、次版本号是否在当前虚拟机的处理范围中。
紧接着魔数后面的第5,6字节代表次版本号,第7,8字节代表主版本号。
当然,验证不止如上两项内容,这一阶段主要是针对二进制字节流进行的,验证完成之后,字节流会进入内存中的方法区进行存储,后面的验证阶段就不再直接操作二进制字节流了。
2. 元数据验证
元数据验证主要是对字节码描述信息进行语义分析,保证其描述的信息符合Java语言规范,包括但不限于如下验证点:
- 继承是否正确(final类不能被继承)
- 是否正确继承(方法的实现,类的字段是否正确或者与父类产生了矛盾)
3. 字节码验证
字节码验证主要是对类的方法体进行验证,检查程序语义是否正确。包括但不限于:
- 数据类型是否正确
4. 符号引用验证
符号引用验证主要是对类自身之外的信息进行校验,主要是对常量池中的符号引用进行验证。
符号引用存在于常量池,是一组描述所引用目标的符号,能够准确无歧义的定位到目标内容。
直接引用是可以简单理解为指向目标的偏移量,指针等内容,同样
三、准备
准备阶段是静态类变量分配内存并设置初始值的阶段。
需要注意的是这里的初始值指的是当前数据类型的默认值,而不是代码中的赋值,代码赋值需要在初始化阶段才能发生。
举例来说,有如下代码:
public static int value = 1 ;
在准备阶段之后,value值为0,而不是1。赋值为1的动作发生在初始化阶段。
如果同时被static 和final修饰在,则准备阶段后为指定值。
public final static int value = 1 ;
准备阶段之后,value值为1.
常见类型的初始默认值如下:
数据类型 | 默认值 |
---|---|
int | 0 |
long | 0L |
float | 0.0f |
double | 0.0d |
char | 'u0000' |
byte | byte(0) |
short | (short)0 |
四、解析
解析阶段是将常量池中的符号引用转换为直接引用的过程。
从验证过程中我们提及到过符号引用和直接引用,解析的动作主要是针对类或接口、字段、类方法、方法属性、方法句柄等符号引用进行处理。
类解析
如果当前的类不是数组类型,则虚拟机会把类直接传给类记载器。数组类本身不通过类加载器加载,而是由虚拟机直接完成。
如果是数组类型并且元素类型是对象(如String[]),则会先使用类加载器加载元素类型(如String),然后再由虚拟机创建出此数组维度和数组对象。
判断调用类是否有权限访问被加载类,如果不允许,则有IllegalAccessError异常。
字段解析
- 首先解析字段所属的类或接口的符号引用。
- 如果类中有字段的符号引用(字段的名称和描述符)和目标字段相匹配,则返回这个字段的直接引用。
- 如果没有,则自下而上查找其实现的接口和父接口,若匹配到,则返回这个字段的直接引用。
- 如果还没有,就自下而上查找其继承的父类,若匹配到,则返回这个字段的直接引用。否则,查找失败,抛出NoSuchFieldError异常。
- 最后如果查找成功的话,会判断字段访问权限,如果该字段不允许访问,则抛出 IllegalAccessError异常。
类方法解析
类方法解析第一步同字段解析一样,也需要先解析方法所属的类或接口的符号引用。类方法和接口方法符号引用的常量类型是分开的。如果,在类方法中解析出来的是一个接口,则会抛出 IncompatibleClassChangeError 异常。如果在类中有方法的符号引用(方法的名称和描述符)和目标方法相匹配,则返回这个方法的直接引用,查找结束。否则,在类的父类中递归查找,若找到则返回,查找结束。否则,查找它实现的接口和父接口,如果找到,说明此类是一个抽象类,抛出 AbstractMethodError异常。若都找不到,就抛出NoSuchMethodError 异常。最后,如果查找成功,会判断此方法是否有访问权限,若没有,则抛出 IllegalAccessError异常。
image.png接口方法解析
首先解析方法所属的类或接口的符号引用,和类方法解析同理,如果发现解析出来是一个类方法,则会抛出 IncompatibleClassChangeError 异常。如果所属接口中匹配到目标方法,则返回此方法的直接引用。否则,在父接口中查找,若找到,则返回。否则,查找失败,抛出 NoSuchMethodError 异常。由于接口的方法都是public的,所以不存在访问权限的问题。
image.png五、初始化
在准备阶段,已经为类变量分配的内存,并赋值了默认值。而在初始化阶段,则可以执行JAVA代码来根据需要来赋值了。
可以说,初始化阶段是执行类构造器 < clinit > 方法的过程。
首先说下类构造器 < clinit > 方法和实例构造器 < init > 方法有什么区别。
- < clinit > 方法是在类加载的初始化阶段执行,是对静态变量、静态代码块进行的初始化。
- < init > 方法是new一个对象,即调用类的 constructor方法时才会执行,是对非静态变量进行的初始化。
类构造器方法
类构造器方法有如下特点:
- 保证父类的 < clinit > 方法执行完毕,再执行子类的 < clinit > 方法。
- 由于父类的 < clinit > 方法先执行,所以父类的静态代码块也优于子类执行。
- 如果类中没有静态代码块,也没有为变量赋值,则可以不生成 < clinit > 方法。
- 执行接口的 < clinit > 方法时,不需要先执行父接口的 < clinit > 方法。只有父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也不执行接口的 < clinit > 方法。
- 虚拟机会保证在多线程环境下 < clinit > 方法能被正确的加锁、同步。如果有多个线程同时请求加载一个类,那么只会有一个线程去执行这个类的 < clinit > 方法,其他线程都会阻塞,直到方法执行完毕。同时,其他线程也不会再去执行 < clinit > 方法了。这就保证了同一个类加载器下,一个类只会初始化一次。(这也是为什么说饿汉式单例模式是线程安全的,因为类只会加载一次。)
类初始化时机
类的初始化时机:只有对类主动使用的时候才会触发初始化,主动使用的场景如下:
- 使用new关键词创建对象时,访问某个类的静态变量或给静态变量赋值时,调用类的静态方法时。
- 反射调用时,会触发类的初始化(如Class.forName())
- 初始化一个类的时候,如其父类未初始化,则会先触发父类的初始化。
- 虚拟机启动时,会先初始化主类(即包含main方法的类)。
另外,也有些场景并不会触发类的初始化:
- 通过子类调用父类的静态变量,只会触发父类的初始化,而不会触发子类的初始化(因为,对于静态变量,只有直接定义这个变量的类才会初始化)。
- 通过数组来创建对象不会触发此类的初始化。(如定义一个自定义的Person[] 数组,不会触发Person类的初始化)
- 通过调用静态常量(即static final修饰的变量),并不会触发此类的初始化。因为,在编译阶段,就已经把final修饰的变量放到常量池中了,本质上并没有直接引用到定义常量的类,因此不会触发类的初始化。