通过类加载器ClassLoader的介绍我们知道了class是由类加载器加载的,了解了类加载器的基本原理,现在记录一下类的加载流程,探索一个类是如何被加载进入JVM内存中的。
类加载机制
定义: 虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。
在Java语言中,类型的加载,连接和初始化过程都是在程序运行期间完成的。
Java天生具备动态扩展的特性依赖的就是运行期动态加载和动态链接这个特点。
类的生命周期
类的生命周期主要有7个阶段:加载,验证,准备,解析,初始化,使用,卸载。其顺序如下:
加载,验证,准备,初始化,卸载 这5个阶段的顺序是确定的,类加载过程必须按照这种顺序按部就班的开始。解析阶段的话要灵活一些:在某些时候可以在初始化之后开始,这是为了支持Java语言的运行时绑定(动态绑定或晚期绑定)。
对于初始化阶段,虚拟机规范严格规定了有且只有5种情况必须立即对类进行初始化(加载,验证,准备自然在前面完成):
遇到 new,getstatic,putstatic,invokestatic这4条字节码指令时,如果类没有进行过初始化,则先需要触发其初始化。生成这4条指令的常见场景:
- 使用new实例化对象时
- 读取或设置一个类的静态字段时(被final修饰,已在编译期把结果放入常量池的静态字段除外)
- 调用一个类的静态方法时
使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化
当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
当虚拟机启动的时候,用户需要指定一个要执行的主类(包含main()方法的类),虚拟机会先初始化这个主类
当使用JDK1.7及其之后的动态语言支持时,如果一个java.lang.invoke.MethodHandler实例最后的解析结果 REF_getStatic,REF_putStatic,REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先触发其初始化。
上面这5种会触发类进行初始化的场景称为对一个类的主动引用。有主动肯定会有被动,下面举几个例子说说被动引用:
被动引用一:
public class SuperClass {
static {
System.out.println("Superclass Init !");
}
public static int value = 999;
}
class SubClass extends SuperClass {
static {
System.out.println("SubClass Init !");
}
}
/**
* 被动使用类字段1
* 通过子类引用父类的字段,不会导致子类初始化
*/
class NotInitialization1 {
public static void main(String[] args) {
System.out.println(SubClass.value);
}
}
//结果:
Superclass Init !
999
对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
被动引用二:
public class SuperClass {
static {
System.out.println("Superclass Init !");
}
public static int value = 999;
}
class SubClass extends SuperClass {
static {
System.out.println("SubClass Init !");
}
}
/**
* 被动使用类字段2
* 通过数组定义来引用类,不会触发此类的初始化
*/
class NotInitialization2 {
public static void main(String[] args) {
SuperClass[] sca = new SuperClass[10];
System.out.println(sca);
}
}
//结果:
[Lcom.yuangh.classloader.process.SuperClass;@6d6f6e28
没有触发 com.yuangh.classloader.process.SuperClass 的初始化,而是触发了 Lcom.yuangh.classloader.process.SuperClass 的初始化,该类由虚拟机自动生成,直接继承于 java.lang.Object,创建动作由newarray触发。
被动引用三:
class ConstClass {
static {
System.out.println("ConstClass Init !");
}
public static final String H = "Hello World!";
}
/**
* 被动使用类字段3
* 常量在编译阶段会存入调用类的常量池,本质上并没有直接引用到定义常量的类,因此
* 不会触发定义常量的类的初始化
*/
class NotInitialization3 {
public static void main(String[] args) {
System.out.println(ConstClass.H);
}
}
//结果:
Hello World!
在编译阶段通过传播优化,已经将常量值存储到NotInitialization3类的常量池中,以后NotInitialization3对ConstClass.H的引用都被转化为了NotInitialization3对自身常量池的引用。
强调: 接口加载与类加载稍有不同,主要体现在以上所说5点中的第三点。当一个类在初始化时,要求其父类全部初始化完成。但是一个接口在初始化时,并不要求其父接口全部完成了初始化,只有在真正使用到父接口的时候才会初始化。
类加载过程
下面是ClassLoader加载一个class文件到JVM时所经过的步骤:
第一阶段:加载。找到 .class 文件并把这个文件包含的字节码加载到内存
第二阶段:连接。字节码验证,Class类数据结构分析,内存分配以及符号表的链接
第三阶段:初始化。类中静态属性的初始化和赋值,以及静态块的执行
一. 加载字节码到内存
加载是“类加载”过程的一个阶段,在加载阶段虚拟机需要完成以下三件事情:
通过一个类的全限定名来获取定义此类的二进制字节流
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据结构的访问入口。
数组类的创建相对于非数组类要更复杂一些。数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的。但数组类的元素类型最终还是要靠类加载器去创建,一个数组类创建过程遵循以下规则(简称C):
如果数组的元素类型是引用类型,那就递归采用类加载过程去加载这些元素,数组C将在加载该元素类型的类加载器的类名称空间上被标识(类与类加载器确定唯一性)
如果数组的元素类型不是引用类型(例如:int),JVM会把数组C标记为与引导类加载器关联
数组类的可见性与它的元素类型的可见性一致,如果元素类型不是引用类型,那数组类的可见性将默认为public
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区中,方法区中的数据存储格式由虚拟机实现自定义,虚拟机规范未规定此区域的具体数据结构。然后在内存中实例化一个 java.lang.Class 类的对象(注意:java.lang.Class 对象比较特殊,它虽然是对象,但是并没有存放在堆中,而是存放在方法区里),这个对象将作为程序访问方法区中的这些类型数据的外部接口
二. 验证
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段是非常重要的,这个阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码的攻击,从执行性能的角度上看,验证阶段的工作量在虚拟机的类加载子系统中又占了相当大的一部分。验证阶段大致会完成下面4个阶段的校验动作:文件格式验证,元数据验证,字节码验证,符号引用验证。
1. 文件格式验证
第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,这一阶段可能包括如下验证点(其中几点,有很多验证):
是否以魔数 0xCAFEBABE 开头
主,次版本号是否在当前虚拟机处理范围之内
常量池的常量中是否有不被支持的常量类型
指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
该验证阶段的主要目的是保证输入的字节流能正确的解析并存储于方法区之内,格式上符合描述一个Java类型信息的需求。这个阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储。这里可能会有疑问,前面的加载过程不是已经把字节流加载到方法区中了吗,为什么这里需要又说需要经过该阶段的验证才存入方法区中,其实,加载阶段与连接阶段的部分内容是交叉进行的,并不是严格的要求需要先加载完成后才能验证。
2. 元数据验证
第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,这一阶段可能包括如下验证点(其中几点,有很多验证):
该类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)
这个类的父类是否继承了不允许被继承的类(被final修饰的类)
如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
类中的字段,方法是否与父类产生矛盾
该阶段的主要目的是对类的元数据信息进行语义校验,保证不存在不符合Java语言规则的元数据信息。
3. 字节码验证
第三阶段的验证相对复杂,主要目的是通过数据流和控制流分析,确定语义是否合法,符合逻辑。该阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件:
保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现:在操作数栈放置了一个int类型数据,使用时却按long类型来加载如本地变量表中
保证跳转指令不会跳转到方法体外的字节码指令上
保证方法体中的类型转换是有效的
4. 符号引用验证
第四阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在解析阶段发生,目的是保证解析动作正常执行。符号引用可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,通常需要校验如下内容:
符号引用中通过字符串描述的全限定名是否能找到对应的类
在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
符号引用中的类,字段,方法的访问性(private, protected, public, default) 是否可被当前类访问
总结: 验证阶段是一个非常重要但不一定必须的阶段,有一些经过反复验证的第三方类库就不需要验证直接加载,可以使用参数 Xverify:none 关闭大部分验证措施以提高性能。
三. 准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
这个阶段进行内存分配的是类变量(static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
“初始值”通常是指数据类型的零值
例如:public static int value = 123; value 在准备阶段后的初始值是0而不是123,因为这时尚未执行任何Java方法。赋值为123操作将在初始化阶段执行。
在特殊情况下不是零值:如果类字段的字段属性表中存在 ConstantValue 属性,那么准备阶段变量 value 就会被初始化为 ConstantValue 属性指定的值。
例如:public static final int value = 123; 编译时javac将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 123。
基本数据类型的零值
数据类型 | 零值 | 数据类型 | 零值 |
---|---|---|---|
int | 0 | boolean | false |
long | 0L | float | 0.0f |
short | (short)0 | double | 0.0d |
char | ‘\u0000’ | reference | null |
byte | (byte)0 |
四. 解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时无歧义的定位到目标即可。和虚拟机实现的内存和布局无关。
直接引用:直接引用可以是直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存和布局相关的。
解析动作主要针对类或接口,字段,类方法,接口方法,方法类型,方法句柄和调用点限定符7类符号引用进行。
符号引用 | 常量类型 |
---|---|
类或接口 | CONSTANT_Class_info |
字段 | CONSTANT_Fieldref_info |
类方法 | CONSTANT_Methodref_info |
接口方法 | CONSTANT_InterfaceMethodref_info |
方法类型 | CONSTANT_MethodType_info |
方法句柄 | CONSTANT_MethodHandler_info |
调用点限定符 | CONSTANT_InvokeDynamic_info |
五. 初始化
初始化阶段是类加载过程的最后一步。到了初始化阶段,才真正开始执行类中定义的Java代码。在准备阶段,变量已经赋过一次系统要求的初始值,在初始化阶段,则根据程序员定义进行初始化:初始化阶段是执行类构造器()方法的过程:
() 方法是由编译器自动收集类中所有类变量的赋值动作和静态代码块中的语句合并产生的,编译器收集的顺序由语句在源程序中出现的顺序产生。
() 方法与类的实例构造器()方法不同,它不需要显示的调用父类构造器,虚拟机会保证在子类的 () 方法执行之前,父类的 () 方法已经执行完毕。第一个一定是java.lang.Object的 () 方法先被执行。
父类中定义的静态语句块优先于子类执行
() 方法对于类和接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成() 方法。
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成() 方法。但接口与类不同的是,执行接口的() 方法不需要先执行父接口中的() 方法。只有当父接口中定义的变量被使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的() 方法。
虚拟机会保证一个类的() 方法在多线程的环境中被正确的加锁,同步。如果多个线程去初始化一个类,那么只会有一个线程去执行这个类的() 方法,其他的线程都会阻塞等待,直到活动线程执行() 方法完毕。如果在一个类的() 方法中有耗时很长的操作,可能会造成多个进程阻塞。
注意:其他线程虽然被阻塞,但如果执行 () 方法的那条线程退出 () 方法后, 其他线程唤醒后不会再次进入 () 方法。同一个类加载器下,一个类型只会初始化一次。
常见异常分析与解决
1. ClassNotFoundException
这个异常时平时开发中出现频率很高的异常,发生的主要原因是:当JVM要加载指定文件的字节码到内存时,并没有找到这个文件对应的字节码。
显示加载一个类的方式:
通过类 Class 中的 forName() 方法
通过 ClassLoader 中的 loadClass() 方法
通过 ClassLoader 中的 findSystemClass() 方法
解决方法:检查在当前classpath目录下有没有指定的文件存在,当前classpath路径可以通过如下方式获取:
this.getClass().getClassLoader().getResource("").toString();
2. NoClassDefFoundError
出现这个异常的可能情况是使用new关键字,属性引用某个类,继承了某个接口或类,以及方法的参数引用了某个类,这时会触发JVM隐式加载这些类时发现这些类不存在异常。
解决方法:确保每个引用到的类都在classpath下面。
关于隐式加载和显示加载:
隐式加载:不通过ClassLoader来加载需要的类,而是通过JVM来自动加载需要的类到内存的方式。例如:当我们在类中继承或者引用某个类时,JVM在解析当前这个类时发现引用的类不在内存中,那么就会自动将这些类加载到内存中。
显示加载:通过ClassLoader来加载一个类的方式。例如:Class.forName()。
3. UnsatisfiedLinkError
通常是在解析native标识的方法时JVM找不到对应的本机库文件。例如:在JVM启动的时候,不小心删除了JVM中的某个lib。
解决方法:检查某个库文件是否被误删。
4. ClassCastException
这个异常也很常见,主要是出现在强制类型转换的时候。
JVM在做类型装换时会按照如下规则检查:
对于普通对象,对象必须是目标类的实例或目标类子类的实例。如果目标类是接口,那么会把它当作实现了该接口的一个子类
对于数组类型,目标类必须是数组类型或 java.lang.Object,java.lang.Cloneable,java.io.Serializable。
解决方法:
在容器类型中显示指定容器所包含的类型
先通过 instanceof 检查是不是目标类型,然后再进行强制类型装换
参考
《深入理解Java虚拟机》《深入分析Java Web技术内幕》