JVM之类加载阶段详解
类加载阶段总览
注意:这些阶段的顺序虽然是确定的,但是这些阶段通常都是互相交叉混合进行的,会在一个阶段中调用,激活另外一个阶段执行
加载
加载阶段顾名思义,也就是Class文件所代表的的 类/接口 被加载到虚拟机中。
那么什么时候(类加载的时机), 谁来加载(哪个类加载器),将哪个Class文件(Class的名称是什么)到虚拟机中呢?
1.什么时候加载?
《JAVA虚拟机规范》中没有进行强制约束,由不同虚拟机来决定加载的时机,也就是不同虚拟机进行加载的时机是不同的。
2.谁来加载?
通过类加载器进行加载,一个类必须和类加载器一起确定唯一性。由于本文重点阐述的是类加载的阶段,故下篇文章对类加载器进行阐述。
3.哪个Class文件?
需要程序指定 类/接口的全限定名(包名+类/接口名)。
因此可以得出加载阶段做的事情:
获取二进制流
这条规则虚拟机规范中并没有指明说从哪里获取,如何获取,只是说通过全限定名获取二进制字节流就行。
因此我们可以从压缩包(JAR),网络(Web Applet),加密文件(加载时动态解密),运行时生成(动态代理)…这些路径中通过类的全限定名去获取二进制字节流进行加载。
将字节流转换为运行时数据结构
要想把字节流转换成方法区的运行时数据结构,自然一定是要经过一些验证,也就是验证阶段的文件格式验证;
文件格式验证阶段没有出现问题的话,接下来就会按照虚拟机中方法区的数据存储格式将数据存储到方法区之中(方法区的数据结构并没有明确规定,因此不同虚拟机实现的结构也是不一样的)。
堆中生成Class对象
也就是下图中的步骤:
特殊
上面所说的都是非数组类型的加载阶段,开发者可以根据自定义类加载器来获取二进制字节流,来做一些骚操作。
而对于数组类来说,数组不是通过类加载器进行创建的,而是虚拟机在内存中动态构造出来的。但是数组的元素类型却需要通过类加载器来进行加载。
数组类型的加载:
连接
验证
1.为什么需要验证阶段?
上面的加载阶段中说过,二进制字节流的来源可以有很多,当然也可以自己手写0和1,如果不对这些字节流进行验证的话,可能会因为加载了错误或者恶意的代码使整个系统崩溃。
所以字节码验证是必须的阶段,这个阶段决定了虚拟机的健壮性,使得虚拟机不那么轻易被攻击,因此在代码量和耗费的性能上来说,验证阶段的工作量在类加载过程中是占比非常大的。
2.验证哪些内容?
2.1,文件格式验证
该步骤在上面的加载过程中已经提到,当加载阶段将字节流的数据存储到方法区中的数据结构中时需要对Class的文件格式进行验证。
当文件格式验证通过后字节流的数据信息就已经被存储到方法区中的数据结构中了,因此之后的验证阶段都不是直接对二进制流进行操作了,而是对方法区中的数据结构进行验证。
该步骤是验证字节流是否符合Class文件格式的规范,保证字节流的数据能够正确解析并存储到方法区的数据结构中,而且当前的虚拟机版本能够对其进行处理
2.2,元数据验证
简单理解就是对类的元数据信息进行验证,比如对父类的信息检查,类字段方法定义,数据类型校验
2.3字节码验证
上一步对元数据进行验证后,接下来就是对方法体进行验证了。
其实这步应该叫code属性的校验(Code为类中的方法体属性)比较准确。
通过了Code属性的验证也不一定代表就是方法体中的代码就是安全的,不可能用程序来判定一段程序是否存在Bug(感兴趣的读者可以搜索“停机问题”),这是离散数学中的一个问题。
上面这些验证点是基于数据流和控制流来分析的,这种方式太过复杂并且该验证阶段执行时间过长,因此在JDK6以后在Code的属性表中新加了一项"StackMapTable"属性,那么这个属性有什么用呢?
这部分可以去我的另一篇博客中查看,本文也进行介绍下:
2.4、符号引用验证
该阶段发生在解析阶段(将符号引用转换为直接引用)前进行的验证工作
该阶段的目的就是保证解析阶段正常执行,如果这步验证出错也就是无法通过符号引用的验证,则虚拟机会抛出java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等异常。
准备
这个阶段做的事情就是为静态变量分配内存,然后赋值(普通静态变量赋默认值,加上final的静态变量直接赋值)。
举例
public static int value = 123;
public static final int value1 = 123;
经过准备阶段后,value为0;而value1为123。
图示:
解析
该阶段是将符号引用转换为直接引用的过程
名词解释
符号引用:
-
使用符号来描述引用的目标,符号可以是任何形式的字面量,只需要能够准群的定位到目标就行;
-
与虚拟机的内存布局无关,由于一份Class文件能够加载到不同的虚拟机中,但是虚拟机的实现不同其内存布局也不同,符号引用存在于Class文件中,而直接引用是一个内存地址。因此对于符号引用来说,只需保证能够确保加载的目标即可。
比如:一个字段,在常量池中是用的CONSTANT_Fieldref_info表示的,至于要在虚拟机中怎么分配内存,这是虚拟机的事情,但是对于不同虚拟机来说,这个CONSTANT_Fieldref_info属性都是一样的。
直接引用:
- 能够直接定位到目标的指针,或者间接定位的句柄,这个是和虚拟机内存布局相关的,不同的虚拟机内存空间不同,自然而然指针,偏移量也就不一样。
举个栗子
同一种水果,在不同的国家有不同的叫法。但是水果本身是不变的,因此水果本身可以类比为符号引用,不同的叫法可以类比与不同的虚拟机中这个引用的真实内存分布也不一样。
解析:静态链接
**编译期间即可确定**
A是父类,B是子类
public A a=new B();
public void invoke(A a){
}
public void invoke(B b){}
调用invoke方法时传入a变量会调用第一个invoke()方法,对于变量a来说静态类型就是A,实际类型是B。对于调用invoke的哪个方法版本(重载有两个版本)则会根据参数变量的静态类型确定,而其在编译期间就可以确定;到此前面是解析阶段的直接引用转换过程。
如果我通过类型强转的方式改变静态类型的话这个可以在编译器确定吗也就是说它属于静态链接吗?
类型强转:比如将a变量在调用invoke方法的时候将其静态类型转为B【B(a)】,类型强转在编译期间是可以知道的(有对应的强转字节码指令用来再次设置变量的静态类型)也就是可以获取到他的静态类型是哪个,自然也就知道该调用哪个方法版本了(第二个invoke方法)。这种其实并不是在解析阶段进行的转换,但是这个也是可以在编译的时候确定的。(这个也叫作静态分派 )
由此可以得出静态类型是可以变化的(强转),对于没有进行重载的方法来说,在解析阶段就可以直接转换;而对于重载的方法来说,如果没有找到对应的静态类型则会对静态类型进行转换(如果参数长度一样参数类型不一样,会有对应的向上转换过程(实现接口-继承的类-进行装箱-变成一个变长类型)即使进行了转换在编译过程中也是可以确定的)。而这两个都是对直接饮用进行转换但是并不冲突。解析阶段进行确定调用的方法版本,如果程序中没有对应静态类型的方法时还会进行一次自动的转换来确定最终调用哪个静态类型参数的方法版本。
还记得上面说到过的多个方法版本吗?没错静态分派中方法的重载因为其有多个方法版本所以也叫作多分派(后面讲)。
分派:动态链接
**运行时才可确定**
中文和外文上对这部分的描述不同的原因:静态连接和动态连接;
如果是根据参数的静态类型来作为判断依据那么静态分派和类加载解析阶段都属于静态链接,
但是如果根据是否是运行期间来确定最终调用的引用是谁(调用方法的哪个版本)的话,那么静态分派其实是运行时由于找不到对应目标参数的方法,会对静态类型进行转换再次查找转换后静态类型方法参数的方法。但是解析阶段是类加载的时候就可以确定的。(注意静态分派和解析阶段确定调用哪个方法)
静态链接:
编译时即可确认要转换成哪个直接引用。
首先明确一个点:静态类型和实际类型都是可以改变的。唯一不同的是静态类型的变化是通过强转实现的而java中又有对应的强转字节码来获取更改之后得变量的静态类型所以编译时是可以确定变量的静态类型的,但是实际类型需要根据运行时才能够进行确定(下面动态链接详细说明)。
静态链接发生的阶段其实也可以分为:静态分派和解析阶段
解析阶段就是将编译期可以确定不会发生变化的符号引用转换为直接引用。
有以下这些方法:
构造方法,私有方法,静态方法
这些叫做非虚方法也就是运行时不会发生变化,编译器即可确定
而静态分派则是更改变量的静态类型,但是也可以确定编译时期不会发生变化。
而且当找不到对应的静态类型的时候在还会默认对静态类型进行转换(实现接口-继承的类-进行装箱-变成一个变长类型)。
动态链接:
编译时不能确认转换成哪个引用要等到运行时才可以确认调用的是哪个方法。
从编译器的角度来讲:
首先变量必须都得有个类型(静态类型)用于之后的字段表中存储代表这是什么类型的变量。
那么从字段表中获取到的类型就是静态类型,这个是在编译的时候生成的属性表示可以确定的,但是静态类型是可以进行变化的,比如类型强转就是改变的静态类型,但是强转后的类型编译期也可以确定(有对应的强转字节码指令)。所以静态类型发不发生变化都能够在编译期确定【不仅限于强转类似的比如编译期如果找不到对应的静态类型则编译期会根据一些规则来改变静态类型后面说明】,简单说就是字段必须要声明一个类型,而编译器自然就可以知道这个类型是什么,而其变化也是可以知道的,所以这种叫做静态链接(编译期确定)
对于方法的参数来说,只能指定参数的静态类型是什么(需要匹配静态类型),参数的实际类型在运行过程中是会发生变化而且也不能确定最终的类型,但是对于静态类型来说不管他怎么变编译的时候都是能够确定他的类型是什么。所以对于重载的方法来说,最终调用哪个方法是编译的时候就 已经在方法调用的字节码后面写上了方法的最终调用版本。
但是对于调用方法的对象来说,真正调用哪个方法是根据这个对象的实际类型来决定的(比如子类重写父类方法,创建一个子类对象,不管静态类型是什么,最终调用的肯定是子类中的方法)。由于实际类型编译期无法确定,所以也叫动态链接(运行时才能确定)
但是每次都到运行时才进行查找效率太低,所以在解析这个阶段的时候还会生成一个虚方法表来优化查找效率,类/接口中都有一个虚方法表,那么是如何优化的呢?:虚方法表中如果子类重写了父类的方法,则其对应的直接饮用地址就是自己的,如果没有重写就指向父类中对应方法的直接饮用地址,为了更快的匹配子类中的虚方法表对应的方法如果没有重写其下标和父类虚方法表的下标一致。
最后放几张图来解释下(ps:图比写文字还累…)
解析阶段全面总结
何时进行
虚拟机可以选择在类加载时就进行解析,也可以在真正使用的时候在解析
但是对于以下需要操作符号引用的指令之前,必须对符号引用进行解析:
需要解析的符号引用:
解析哪些类型
一,类/接口解析
类的解析其实更像是对类的加载,之后进行访问权限的验证
1.1.1这个类泛指Class和接口
通过类的全限定名(符号引用)进行类加载,那么用哪个类加载器加载呢?一般是使用定义该类的类的类加载器,怎么理解(比如A中定义了一个B类,那么B类的类加载器是用的A类的类加载器)。
那么类的加载又会涉及到类加载的这些阶段,所以接着往下讲。
1.1.2当碰到是数组的时候
碰到数组对象时,首先加载数组的所属类型,如上面所说的类解析是一样的流程;当这个类型加载完后(类加载阶段结束),虚拟机会在生成一个类,这个类的目的是什么呢?(前面说到过所属类型去掉了所有的维度,那么这个虚拟机自动生成的类就是去表达出数组的维度和长度的)
1.2进行访问权限验证
如果解析该类的类没有对解析类的使用权限,那么也会解析失败。
二,字段解析
第一步:解析字段类型
在上一篇文章中提到过字段表这个属性表会持有字段所属类型的信息,也就是CONSTANT_Class_info,所以先对这个类性加载就是上面一中的解析;
第二步:查找字段
我们知道字段有这两个属性(简单名称,描述符),比如String a=""; 简单名称是a,描述符是String的权限定名,这两个信息是字段的基本信息,那么查找的时候也会根据这两个属性来查找。
第三步:权限验证
验证解析该字段的类/接口是否有对该字段的访问权限,如果没有也会解析失败。
初始化
那么初始化阶段也就是对静态类型上面说的赋默认值的静态变量进行赋值操作,同时该阶段也会执行静态语句块中的内容。将这两个步骤合到一块就是静态变量赋值操作和静态语句块执行操作,编译器整合这两个操作生成了一个方法叫做cinit。而执行和赋值的操作是根据用户写的java文件的顺序决定的。
类
在初始化阶段也需要确保父类完成类加载,因此cinit方法执行前父类的cinit方法肯定会执行完毕,和类的构造函数init方法还不太一样,cinit不需要显示调用父类的构造器。
注意:
1.cinit方法不一定会生成,如果没有静态代码块或者静态变量,那么编译器是不会生成这个方法的
2.JAVA虚拟机会保证父类的cinit方法先执行,不需要像init方法一样显示的调用父类构造器来保证父类init方法执行完成。
接口
接口中没有静态代码块,字段默认是static和final修饰的。
注意:
1.接口的cinit方法执行前不一定需要父接口的cinit方法也执行完。当使用到了父接口中的变量父接口才会初始化。
2.接口实现类初始化前不会执行接口的cinit方法。
3.cinit方法是加锁同步的,多线程初始化同一个类时会发生阻塞只有当cinit方法执行完才可以释放锁。
总结:
使用
这里类加载完成之后就可以进行使用了,上面说到的都是静态变量,代码块的初始化赋值执行操作,那么类的成员变量,类的构造方法呢?这些叫做init方法执行构造方法,完成类成员变量的初始化(也就是实例变量),当然这些都是在创建完对象后进行的操作,而且init执行前需要显示的去执行父类的init方法。
卸载
类型卸载的条件比较严苛:
1.该类的所有对象都已被清除
2.该类的java.lang.Class对象没有被任何对象或变量引用。只要一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class。这个对象在类被加载进方法区的时候创建,在方法区中该类被删除时清除。
3.加载该类的ClassLoader已经被回收