写在前面:类加载机制倒是听得不少了,但是又知不知道它到底有什么用呢?为什么要学它呢?因为面试(真实.jpg),其实也不仅仅是面试,掌握它可以掌握对类加载的时机,在真正需要使用到类时才加载到内存中,可以减轻服务器的压力,而且,许多框架底层源码都用到了反射这个东西,反射的原理就是基于类加载机制的,所以掌握了这门绝活,学反射就不会一头雾水了,看源码也知其所以然了。
开篇概述:Java文件在编译时转换为字节码文件,字节码文件就是对一个类的描述,Java虚拟机把Class文件加载到内存,并且经过验证、准备、解析和初始化,最终形成可以被JVM直接使用的Java类型,这就是类加载机制。下面就是对类加载机制各个过程的详细分析,每个阶段都会尽我所能把最详细清晰的图贴上去带你理解这个阶段到底是怎样的。
类的生命周期分为7个阶段,在图中我已经标注了每个阶段对类所做的主要事情,你请拿好,如果这张图能帮助到你,你的点赞是对我最大的鼓励和支持!(跑远了哈哈哈)其中验证、准备、解析三个部分统称为连接,下面我就会对每一个部分做出通俗易懂的解释,用最友好的图示来告诉你,这一个阶段JVM到底做了什么~
声明:加载是类加载的其中一个阶段,类加载包含了前五个阶段(加载、验证、准备、解析、初始化),要区分开加载和类加载的区别。
我们来看看什么时候会类加载呢?
第一个阶段是加载,在Java虚拟机规范中没有明确规定一个类在什么时候会被加载,但是它严格规定了只有以下6种情况必须对类进行初始化操作,在初始化操作之前必定会触发类的加载和连接。
(1)遇到
new
、getstatic
、putstatic
、invokestatic
这四条字节码指令时
使用
new
关键字实例化对象时;对应new
字节码指令读取或设置一个类的静态字段(被
final
修饰的、在编译期把结果放入常量池的静态变量除外)时;对应getstatic
和putstatic
字节码指令调用一个类的静态方法时;对应
invokestatic
字节码指令(2)使用
java.lang.reflect
包的方法第一次对类进行反射调用时会触发类的初始化(3)初始化类时,如果发现父类还没有初始化,则需要先触发父类的初始化
(4)虚拟机启动时,用户需要指定一个主函数类(
main()
方法所在的类),虚拟机会先启动这个类(5)使用JDK7新加入的动态语言支持时,如果一个
java.lang.invoke.MethodHanlde
实例最后的解析结果为REF_getstatic
、REF_putstatic
、REF_invokeStatic
、REF_newInvokeSpecial
四种类型的方法句柄时,都需要先初始化该句柄对应的类(6)接口中定义了JDK 8新加入的默认方法(
default
修饰符),实现类在初始化之前需要先初始化其接口
上面几种类型是不是看得懵逼,下面我就会对类的初始化进行举例,让你们通过更直观的场景可以理解上面几种情况。因为只要类被初始化,它就一定得先加载该类到内存中。
定义一个StaticClass
,如果类被初始化,那么会自动执行静态代码块,在控制台可以看到信息。
/** * @author Zeng * @date 2020/4/8 23:21 */ public class StaticClass { static { System.out.println("StaticClass initialized!"); } public static int A = 0; public static void staticFunction(){ System.out.println("staticFunction executed;"); } } 复制代码
我以第一种情况给你演示一下类是否真的被加载和初始化了
我们使用一个Test类调用静态变量A
和静态方法staticFunction()
,如下图所示
/** * @author Zeng * @date 2020/4/8 23:21 */ public class Test { public static void main(String[] args) { //new StaticClass obj = new StaticClass(); //getstatic int a = StaticClass.A; //putstatic StaticClass.A = 1; //invokestatic StaticClass.staticFunction(); } } 复制代码
控制台的结果如下,很明显StaticClass
是被初始化了
我们可以使用JVM启动参数-XX:+TraceClassLoading
进行查看StaticClass
类有没有被加载
可以看到JVM确确实实是加载了StaticClass
类
知道了触发类加载的6种做法以后,我们就深入类加载的过程,探秘每一个过程发生的事情
加载阶段,Java虚拟机需要完成三件事情
通过一个类的全限定名来获取定义此类的二进制字节流。
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
在Java堆内存中生成一个代表这个类的
java.lang.Class
的对象,作为方法区中这个类的各种数据的访问入口。
怎么理解上面的第2、3点呢?
静态存储结构指的是Class文件结构
,它是一组以8位字节为单位的二进制流,如下图所示就是一个SubClass
类的Class文件结构,此时是静态的,虚拟机会把这个文件的相关类信息加载到方法区
当中,并在Java堆
上创建java.lang.Class
的对象,该对象就是图中的SubClass
类,注意不是SubClass的对象实例,而是java.lang.Class
的对象实例
到这里我们会产生一个疑问,加载阶段不是应该在连接阶段之前执行吗?为什么还没进行验证、准备和解析就可以把类信息放入方法区?
注意:加载阶段和连接的部分动作(如一部分字节码的文件格式验证动作)是交叉进行的,也就是说加载阶段还没完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作(验证文件格式、字节码验证······)都属于连接操作。
连接阶段包括验证、准备和解析,下面我们每一个阶段来细看,我们不用记住每一个阶段内部具体校验的东西,从整体上概括该阶段干了一些什么。
验证阶段主要包括四个检验动作:
文件格式检验:验证上面的Class文件字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。例如文件前四个字节是否为
CA FE BA BE
代表这是一个Class文件。元数据验证:对字节码描述的信息进行语义分析,保证其描述的信息符合要求,例如数据类型是否正确,是否正确继承类·····
字节码验证:最复杂的一个阶段,通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的,例如是否在
return
后面还有语句,这些语句是不可达的······符号引用验证:对类自身以外的各类信息进行匹配性校验,通俗地说,该类是否缺少或禁止访问它依赖的某些外部类、方法、字段等资源
下面用上面的字节码文件作例子来给你说明这四个检验动作:
文件格式检验:假如在获取到Class文件
之后,我偷偷地将开头修改为CA FE DE AD
,虚拟机还能正常加载该文件吗?
我们此时使用java SubClass
命令尝试加载该文件,看看发生什么结果!
上图中的错误信息已经非常明显了,告诉我们魔数3405700782是一个非法值,我们再来看看这个值是什么~
这不就是我们刚刚修改的地方嘛,因此,文件格式验证会对文件的相关格式做检验,当然我的例子只是冰山一角,实际上JVM做的校验多了去了。我只是将最容易理解的方式写给你们,让你们对整个过程有个最直观的理解,尽量不要死记硬背
元数据验证、字节码验证和符号引用验证由于篇幅问题就不再做例子了,我们只要知道验证阶段是校验字节码文件里的一切东西都要符合《Java虚拟机规范》
准备阶段是正式为类中定义的变量(即静态变量、被static
修饰的变量)分配内存并设置类变量初始值的阶段,我们需要知道两个要点
- 类变量被分配在哪个存储区域
- 类变量的初始值是什么
首先我们先来说第1点,我们都知道方法区是存储类相关信息的区域,在JDK7及以前,类变量是存储在方法区当中的,而在JDK8及之后,类变量已经随着Class
对象一起存放在Java堆当中了,这时候类变量存放在方法区这句话已经只是停留在逻辑上的概念表述层面了。
第2点是类变量的初始值,假设有一个类变量public static int value = 666
,在准备阶段过后的初始值是0
而不是666
,在初始化阶段时才会被赋值为123
。
注意如果有一个静态类变量为public static final int value = 666
,那么它在准备阶段JVM就已经会给它赋值为666,不会赋零值。
将常量池内的符号引用替换为直接引用的过程。
符号引用:符号引用是以一组符号来描述所引用的目标,符号可以是任何的字面形式的字面量,只要不会出现冲突能够定位到就行。符号引用于JVM内存布局无关。
符号引用的作用是在编译的过程中,JVM并不知道引用的具体地址,所以用符号引用进行代替,而在解析阶段将会将这个符号引用转换为真正的内存地址。
直接引用:可以是指向目标的指针,偏移量或者能够直接定位的句柄。该引用是和内存中的布局有关的,并且一定加载进来的。有了直接引用,那么引用的目标必定已经在虚拟机内存中。
直接引用可以理解为:指向类对象、变量、方法的指针、指向实例的指针和一个间接定位到对象的对象句柄。
举个例子让你去理解它们两个的区别
public class Test{ public static void main(String[] args) { String s="adc"; System.out.println("s="+s); } } 复制代码
上面这段代码的变量s
在编译时会被解析成为符号引用,符号引用的标志是astore_<n>
,对应下图的astore_1
我们在方法里定义了一个局部变量s
,把它指向adc
存放的地址,但是在编译时s
并不知道adc
的地址,JVM将变量s
与astore_1
对应起来,astore_1
的含义是将操作数栈顶的adc
保存回索引为1
的局部变量表中,此时访问变量s
就会读取局部变量表索引值为1
中的数据。所以局部变量s
就是一个符号引用。
下面这段代码的字符串被解析为直接引用
public class Test{ public static void main(String[] args) { System.out.println("s="+"adc"); } } 复制代码
我们可以看到字节码指令ldc
直接将s=abc
这一字符串从常量池中推送到栈,然后下一条字节码指令invokevirtual
代表调用实例方法,并没有将字符串存入局部变量表中,所以这里的s=abc
就是一个直接引用。
总结一下:符号引用是指在编译时无法确定对象的内存地址,所以必须使用一个符号引用去对应局部变量表中的一个特定位置,然后在解析阶段将该变量的值或引用地址保存回局部变量表中,此后访问该变量值都会从局部变量表对应的位置查找该值;而直接引用是在编译时就可以确定。
类的初始化是类加载的最后一个阶段了,在准备阶段时,JVM已经为类变量赋了零值,在初始化阶段,会根据代码去真正地初始化类变量值和其它资源
我们先来看看
StaticClass
被初始化时是如何执行静态代码块的?
我们在IDEA中查看StaticClass
的字节码文件,看到熟悉的一个输出语句,那么我们可以推测静态代码块被翻译成下面这个<clinit>
函数(先别走T.T,这个函数挺重要的,我们要掌握的,坚持看下去,学会了很香的)
静态代码块其实就是一个类构造函数,当一个类被初始化时,就会被调用这个<clinit>
方法对类进行初始化操作,注意这个方法只会执行一次,因为JVM加载某个类到内存中后,直到卸载之前,这个类一直都在内存当中,所以这也解释了为什么静态代码块只会执行一次。
<clinit>
方法这个方法是由编译器自动收集类中所有类变量的赋值操作和静态代码块中的语句合并产生的,收集的顺序是由语句在源文件中出现的顺序决定的,静态代码块只能访问到定义在它之前的类变量,但是可以为定义在它之后的类变量赋值。
public class Test{ static { i = 0; //编译通过 System.out.print(i); //编译失败 } static int i = 1; } 复制代码
在初始化一个类时,必须先初始化其父类,因此第一个执行<clinit>
方法的一定是Object
类。
<clinit>
方法不是必须存在的,如果一个类中没有类变量的赋值操作,也没有静态代码块,那么这个类将没有<clinit>
方法。
如果多个线程同时希望初始化一个类,<clinit>
方法会在多线程环境下保证正确地加锁同步,只有其中一个线程去执行这个类的clinit<>()
方法。
完结撒花!!!看到这里的你已经掌握了类加载机制的绝大多数内容了,主要需要掌握类加载机制的七个阶段,类加载过程中每个阶段所做的事情,什么情况下会触发类的初始化,解析阶段的直接引用和符号引用在面试过程中如果能解释清楚是非常加分的,它代表你对虚拟机栈的结构非常清晰,也清楚类加载的每一阶段主要做了什么,首先很感谢你愿意花时间来阅读我的文章,如果这篇文章对你有一点点小的帮助,**你的点赞是对我最大的鼓励和支持!**由于作者能力有限,如文章有严重错误,请务必评论指出,乐意与大家交流和学习!
巨人的肩膀: