写在前面:类加载机制倒是听得不少了,但是又知不知道它到底有什么用呢?为什么要学它呢?因为面试(
真实.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做的校验多了去了。我只是将最容易理解的方式写给你们,让你们对整个过程有个最直观的理解, 尽量不要死记硬背
准备阶段是正式为类中定义的变量(即静态变量、被 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<>()
方法。
完结撒花!!!看到这里的你已经掌握了类加载机制的绝大多数内容了,主要需要掌握类加载机制的七个阶段,类加载过程中每个阶段所做的事情, 什么情况下会触发类的初始化 , 解析阶段的直接引用和符号引用 在面试过程中如果能解释清楚是非常加分的,它代表你对虚拟机栈的结构非常清晰,也清楚类加载的每一阶段主要做了什么,首先很感谢你愿意花时间来阅读我的文章,如果这篇文章对你有一点点小的帮助,**你的点赞是对我最大的鼓励和支持!**由于作者能力有限,如文章有严重错误,请务必评论指出,乐意与大家交流和学习!
blog.csdn.net/qq_34402394…