1 前言
在Java的世界里,每一个类或者接口,在经历编译器后,都会生成一个个.class文件。类加载机制指的是将这些.class文件中的二进制数据读入到内存中,并对数据进行校验,解析和初始化。最终,每一个类都会在方法区保存一份它的元数据,在堆中创建一个与之对应的Class对象。类的生命周期,经历7个阶段,分别是加载、验证、准备、解析、初始化、使用、卸载。
除了使用和卸载两个过程,前面的5个阶段 加载、验证、准备、解析、初始化 的执行过程,就是类的加载过程。
2 类加载的时机
很多数人在问 “类什么时候加载” 和 “类什么时候初始化” ,从语境上来说,都是在问同一个问题,就是这个.class文件什么时候被读取到虚拟机的内存中,并且达到可用的状态。
但严格意义上来说,加载和初始化,是类生命周期的两个阶段。对于什么时候加载,Java虚拟机规范中并没有约束,各个虚拟机都可以按自身需要来自由实现。但绝大多数情况下,都遵循“什么时候初始化”来进行加载。
什么时候初始化?Java虚拟机规范有明确规定,当符合以下条件时(包括但不限于),虚拟机内存中没有找到对应类型信息,则必须对类进行“初始化”操作:
1.使用new实例化对象时、读取或者设置一个类的静态字段或方法时
2.反射调用时,例如 Class.forName("com.xxx.TestDemo")
3.初始化一个类的子类,会首先初始化子类的父类
4.Java虚拟机启动时标明的启动类
5.JDK8 之后,接口中存在default方法,这个接口的实现类初始化时,接口会其之前进行初始化
初始化阶段开始之前,自然还是要先经历加载、验证、准备 、解析的。
3 类加载的过程
从前言中,我们知道,类的加载过程分 5 个阶段,其中验证、准备、解析 可以归纳为 “连接” 阶段。
需要注意的是,这5个阶段,并不是严格意义上的按顺序完成,在类加载的过程中,这些阶段会互相混合,交叉运行,最终完成类的加载和初始化。
例如在加载阶段,需要使用验证的能力去校验字节码正确性。在解析阶段,也要使用验证的能力去校验符号引用的正确性。或者加载阶段生成Class对象的时候,需要解析阶段符号引用转直接引用的能力等等......
接下来,我们详细分解一下,这5个阶段,都做了什么事情。
3.1 加载阶段
加载是类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:
1.通过一个类的全限定名去找到其对应的.class文件
2.将这个.class文件内的二进制数据读取出来,转化成方法区的运行时数据结构
3.在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口
Java虚拟机并没有规定类的字节流必从.class文件中加载,在加载阶段,程序员可以通过自定义的类加载器,自行定义读取的地方,例如通过网络、数据库等。
3.2 验证阶段
Class文件中的内容是字节码,这些内容可以由任何途径产出,验证阶段的目的是保证文件内容里的字节流符合Java虚拟机规范,且这些内容信息运行后不会危害虚拟机自身的安全。
验证阶段会完成以下校验:
1.文件格式验证:验证字节流是否符合Class文件格式的规范。例如:是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型等等
2.元数据验证:对字节码描述的元数据信息进行语义分析,要符合Java语言规范。例如:是否继承了不允许被继承的类(例如final修饰过的)、类中的字段、方法是否和父类产生矛盾
3.字节码验证:对类的方法体进行校验分析,确保这些方法在运行时是合法的、符合逻辑的。
4.符号引用验证:发生在解析阶段,符号引用转为直接引用的时候,例如:确保符号引用的全限定名能找到对应的类、符号引用中的类、字段、方法允许被当前类所访问。
验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
验证阶段不是必须的,虽然这个阶段非常重要。Java虚拟机允许程序员主动取消这个阶段,用来缩短类加载的时间,可以根据自身需求,使用 -Xverify:none参数来关闭大部分的类验证措施。
3.3 准备阶段
这个阶段,类的静态字段信息(即使用 static 修饰过的变量)会得到内存分配,并且设置为初始值。
对于该阶段有以下几个知识点需要注意:
1、内存分配仅包括 static 修饰过的变量,而不包括实例变量,实例变量得等到对象实例化时分配内存。
2、初始值指的是变量数据类型的默认值,而不是被在Java代码中被显式地赋予的值。但是,当字段信息被 final 修饰成常量(ConstantValue)时,这个初始值就是Java代码中显式地赋予的值。
示例:public static int value = 3
类变量 value 在准备阶段设置的初始值是 0,不是 3。把value赋值为3的 putstatic 指令是在程序编译后,存放于类构造器 <clinit>() 方法中的,所以把 value 赋值为 3 的动作将在初始化阶段才会执行。
当使用 final 修饰后:public static final int value = 3
类变量 value 在准备阶段设置的初始值是 3,不是 0。
3、在JDK8取消永久代后,方法区变成了一个逻辑上的区域,这些类变量的内存实际上是分配在Java堆中的。
3.4 解析阶段
这个阶段,虚拟机会把这个Class文件中,常量池内的符号引用转换为直接引用。主要解析的是 类或接口、字段、类方法、接口方法、方法类型、方法句柄等符号引用。我们可以把解析阶段中,符号引用转换为直接引用的过程,理解为当前加载的这个类,和它所引用的类,正式进行“连接“的过程。对于这段描述,很多人会问出下面几个问题。
什么是符号引用?
Java代码在编译期间,是不知道最终引用的类型,具体指向内存中哪个位置的,这时候会用一个符号引用,来表示具体引用的目标是"谁"。Java虚拟机规范中明确定义了符号引用的形式,符合这个规范的前提下,符号引用可以是任意值,只要能通过这个值能定位到目标。
什么是直接引用?
直接引用就是可以直接或间接指向目标内存位置的指针或句柄。
引用的类型,还未加载初始化怎么办?
当出现这种情况,会触发这个引用对应类型的加载和初始化。
3.5 初始化阶段
这步类加载的最后一个步骤了,初始化的过程,就是执行类构造器 <clinit>()方法的过程。
当初始化完成之后,类中static修饰的变量会赋予程序员实际定义的“值”,同时类中如果存在static代码块,也会执行这个静态代码块里面的代码。
<clinit>() 方法的作用是什么?
还记得么?在准备阶段,已经对类中static修饰的变量赋予了初始值。<clinit>() 方法的作用,就是给这些变量赋予程序员实际定义的“值”。同时类中如果存在static代码块,也会执行这个静态代码块里面的代码。
<clinit>() 方法是什么?
<clinit>() 方法 和 <init> 方法是不同的,它们一个是“类构造器”,一个是实例构造器。Java虚拟机会保证子类<clinit>() 方法在执行前,父类的 <clinit>() 已经执行完毕。而 <init> 方法则需要显性的调用父类的构造器。
<clinit>() 方法由编译器自动生成,但不是必须生成的,只有这个类存在static修饰的变量,或者类中存在静态代码块但时候,才会自动生成<clinit>()方法。
加载过程总结
当一个符合Java虚拟机规范的字节流文件,经历 加载、验证、准备、解析、初始化这些阶段相互协作执行完成之后,加载阶段读取到的Class字节流信息,会按虚拟机规定的格式,在方法区保存一份,然后Java 堆中,会创建一个 java.lang.Class 类的对象,这个对象描述了这个类所有信息,也提供了这个类在方法区的访问入口。
方法区中,使用同一加载器的情况下,每个类只会有一份Class字节流信息;
Java堆中,使用同一加载器的情况下,每个类只会有一份 java.lang.Class 类的对象。
4 类加载器
还记得在前面加载阶段,通过类的全限定名,获取该类字节流数据的这个动作么,类加载器就是用来实现这个动作的。当年为了满足浏览器上 Java Applet 的需求,Java的开发团队设计了类加载器,它独立于Java虚拟机外部,允许程序员按自身需要自行实现类加载器。这是一项非常优秀的创新,它让同一个类可以实现访问隔离、OSGi、程序热部署等等。发展至今,类加载器已经是Java技术体系的一块重要基石。
三层类加载器介绍
启动类加载器(Bootstrap Class Loader):负责加载<JAVA_HOME>\lib 目录,或者被 -Xbootclasspath 参数制定的路径,例如 jre/lib/rt.jar 里所有的class文件。由C++实现,不是ClassLoader子类。
拓展类加载器(Extension Class Loader):负责加载Java平台中扩展功能的一些jar包,包括<JAVA_HOME>\lib\ext 目录中 或 java.ext.dirs 指定目录下的jar包。由Java代码实现。
应用程序类加载器(Application Class Loader):我们自己开发的应用程序,就是由它进行加载的,负责加载ClassPath路径下所有jar包。
双亲委派模型
高端的食材往往只需要最简单的烹饪方式,而保证Java程序稳定运行的双亲委派模式,其实也非常简单:
双亲委派模式其实一句话就可以说清楚:任何一个类加载器在接到一个类的加载请求时,都会先让其父类进行加载,只有父类无法加载(或者没有父类)的情况下,才尝试自己加载。
ClassLoader 类中有示例,如下:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
{
// 首先要保证线程安全
synchronized (getClassLoadingLock(name)) {
// 先判断这个类是否被加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 有父类,优先交给父类尝试加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父类加载失败,这里捕获异常,但不需要做任何处理
}
if (c == null) {
// 没有父类,或者父类无法加载,尝试自己加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
双亲委派模型好处
在解答这个问题前,需要先了解一个知识点:不同的类加载器,加载同一个类,结果是虚拟机里会存在两份这个类的信息,所以当判断这两个类是否“相等”时,必定是不相等的。
使用双亲委派模式,可以保证,每一个类只会有一个类加载器。例如Java最基础的Object类,它存放在 rt.jar 之中,这是 Bootstrap 的职责范围,当向上委派到 Bootstrap 时就会被加载。但如果没有使用双亲委派模式,可以任由自定义加载器进行加载的话,Java这些核心类的API就会被随意篡改。
好了,Java类加载机制掌握这些就足够了,再也不用怕面试官问到这类问题了,可以和他掰扯一下了,你们学会了吗?