JAVA进阶之类加载阶段详解

1,335 阅读26分钟

2022 年什么会火?什么该学?本文正在参与“聊聊 2022 技术趋势”征文活动 」

技术进阶之JVM之类加载阶段详解

类加载阶段总览

image.png 注意:这些阶段的顺序虽然是确定的,但是这些阶段通常都是互相交叉混合进行的,会在一个阶段中调用,激活另外一个阶段执行


加载

加载阶段顾名思义,也就是Class文件所代表的的 类/接口 被加载到虚拟机中。

那么什么时候(类加载的时机), 谁来加载(哪个类加载器),将哪个Class文件(Class的名称是什么)到虚拟机中呢?


1.什么时候加载? 《JAVA虚拟机规范》中没有进行强制约束,由不同虚拟机来决定加载的时机,也就是不同虚拟机进行加载的时机是不同的。

2.谁来加载? 通过类加载器进行加载,一个类必须和类加载器一起确定唯一性。由于本文重点阐述的是类加载的阶段,故下篇文章对类加载器进行阐述。

3.哪个Class文件? 需要程序指定 类/接口的全限定名(包名+类/接口名)。


因此可以得出加载阶段做的事情:

1、通过一个类的全限定名来获取定义此类的二进制字节流 2、将这个字节流所代表的的静态存储结构转换为方法区的运行时数据结构 3、在堆内存中生成一个代表这个类的java.ang.Class对象,作为方法区这个类的访问入口


获取二进制流

这条规则虚拟机规范中并没有指明说从哪里获取,如何获取,只是说通过全限定名获取二进制字节流就行。

因此我们可以从压缩包(JAR),网络(Web Applet),加密文件(加载时动态解密),运行时生成(动态代理)....这些路径中通过类的全限定名去获取二进制字节流进行加载。

将字节流转换为运行时数据结构

要想把字节流转换成方法区的运行时数据结构,自然一定是要经过一些验证,也就是验证阶段的文件格式验证

文件格式验证阶段没有出现问题的话,接下来就会按照虚拟机中方法区的数据存储格式将数据存储到方法区之中(方法区的数据结构并没有明确规定,因此不同虚拟机实现的结构也是不一样的)。

正如开篇所说,这些阶段是互相交叉混合进行的,但是他们的顺序是确定的。这个很好理解,没有加载阶段获得的二进制字节流,那么验证阶段是不知道验证什么的。

堆中生成Class对象

也就是下图中的步骤:

image.png


特殊

上面所说的都是非数组类型的加载阶段,开发者可以根据自定义类加载器来获取二进制字节流,来做一些骚操作。

而对于数组类来说,数组不是通过类加载器进行创建的,而是虚拟机在内存中动态构造出来的。但是数组的元素类型却需要通过类加载器来进行加载。

数组元素类型就是去掉所有维度的类型,比如一个int类型的一维数组和一个int类型的二维数组,他们两个的元素类型都是int。也可以简单理解为和维度无关。

数组类型的加载:

image.png


连接


验证

这一步骤是确保Class文件的字节流中包含的信息要符合虚拟机规范中的要求,保证这些信息被当做代码运行后不会危害虚拟机自身的安全。

1.为什么需要验证阶段? 上面的加载阶段中说过,二进制字节流的来源可以有很多,当然也可以自己手写0和1,如果不对这些字节流进行验证的话,可能会因为加载了错误或者恶意的代码使整个系统崩溃。

所以字节码验证是必须的阶段,这个阶段决定了虚拟机的健壮性,使得虚拟机不那么轻易被攻击,因此在代码量和耗费的性能上来说,验证阶段的工作量在类加载过程中是占比非常大的。

2.验证哪些内容?

验证过程的描述在《JAVA虚拟机规范(Java SE 7版)》中长达130页,有兴趣的读者可以自己搜索,本文仅对整个阶段中的重要验证部分讲解。

2.1,文件格式验证

该步骤在上面的加载过程中已经提到,当加载阶段将字节流的数据存储到方法区中的数据结构中时需要对Class的文件格式进行验证。

当文件格式验证通过后字节流的数据信息就已经被存储到方法区中的数据结构中了,因此之后的验证阶段都不是直接对二进制流进行操作了,而是对方法区中的数据结构进行验证。

目的:该步骤是验证字节流是否符合Class文件格式的规范,保证字节流的数据能够正确解析并存储到方法区的数据结构中,而且当前的虚拟机版本能够对其进行处理

验证点,eg: 1.magic是否正确 2.主版本号和次版本号是否在当前虚拟机版本能够处理的范围 3.常量池中的常量是否有不被支持的常量类型(tag标志) 4.是否有指向常量池中不存在的索引


2.2,元数据验证

对字节码描述的信息进行语义分析,保证其描述的信息符合《JAVA语言规范》的要求

简单理解就是对类的元数据信息进行验证,比如对父类的信息检查,类字段方法定义,数据类型校验

验证点,eg: 1.这个类是否有父类 2.这个类的父类是不是继承了不允许被继承的类(final修饰的类无法被继承) 3.如果这个类不是抽象类的话,是否已经实现了父类或者接口中的所有方法 4.类中的字段,方法是否与父类产生矛盾(修改了父类的final字段,...)


2.3字节码验证

上一步对元数据进行验证后,接下来就是对方法体进行验证了。

其实这步应该叫code属性的校验(Code为类中的方法体属性)比较准确。

整个验证过程最复杂的一步,通过数据流和控制流来分析程序的语义是合法,符合逻辑的。保证方法运行时不会做出错误或者危害虚拟机的行为。

验证点,eg: 1.保证操作数栈的数据类型的指令代码序列中操作的数据类型一致,不会出现操作数栈是int,指令代码取出的时候按照long来取 2.跳转指令不会跳转到方法体以外的字节码指令上 3.保证类型转换是正确的

通过了Code属性的验证也不一定代表就是方法体中的代码就是安全的,不可能用程序来判定一段程序是否存在Bug(感兴趣的读者可以搜索“停机问题”),这是离散数学中的一个问题。

上面这些验证点是基于数据流和控制流来分析的,这种方式太过复杂并且该验证阶段执行时间过长,因此在JDK6以后在Code的属性表中新加了一项"StackMapTable"属性,那么这个属性有什么用呢?

这部分可以去我的另一篇博客中查看,本文也进行介绍下:

"StackMapTable"属性描述了方法体中每一个按照控制流拆分的代码块开始时局部变量表和操作数栈应该有的状态,简单说就是执行到某个字节码指令时(拆分后的代码块)提供了执行到某个字节码指令时局部变量表和操作数栈需要验证的数据类型。

以前是经过类型推导出是什么数据类型,在进行判断类型是否一致;现在只需要验证StackMapTable属性中存储的记录是合法的,然后进行类型检查是否一致就行,不需要进行复杂的推导过程


2.4、符号引用验证

该阶段发生在解析阶段(将符号引用转换为直接引用)前进行的验证工作

验证点,eg: 1.符号引用中通过字符串描述的全限定名是否能找到对应的类。 2.在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。 3.符号引用中的类、字段、方法的可访问性(private、protected、public、package)是否可被当前类访问。

该阶段的目的就是保证解析阶段正常执行,如果这步验证出错也就是无法通过符号引用的验证,则虚拟机会抛出java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等异常。

image.png


准备

这个阶段做的事情就是为静态变量分配内存,然后赋值(普通静态变量赋默认值,加上final的静态变量直接赋值)。

注意,这个阶段只是对“静态变量”(不需要创建出对象就能访问)进行的操作,对于实例变量(创建出对象才能访问)来说则会延迟到对象实例化的时候在进行分配内存。

对于普通静态变量来说,具体的赋值是在初始化阶段中进行的;而对于final修饰的静态变量来说,在该阶段就已经赋值了。这个得益于ConstantValue属性,读者可以查看上一篇属性表结构中的该属性进行了解。

举例

public static int value = 123;

public static final int value1 = 123;

经过准备阶段后,value为0;而value1为123。

图示:

image.png

静态变量本身是应该存放在方法区中的,在JDK7以前是用的永久代实现的方法区;但是在JDK7以后,方法区是在直接内存上的,而静态变量则会随着Class对象在堆内存中进行分配。 因此类变量在方法区在JDK7以前是可以这样说的,但是JDK7以后就不能这样说了。

image.png


解析

该阶段是将符号引用转换为直接引用的过程

注意能够在类加载阶段也就是解析阶段中可以直接转换为直接引用的这个步骤也叫作静态链接,因为某些方法,字段是可以在编译器就可以确定的(比如:重载方法,这个是可以在编译期确定调用的是哪个方法,因为根据参数的长度和参数的类型即可确定;这里说的参数类型指的是方法的静态类型不是实际类型)。但是解析阶段也只能转换运行的时候确定不会发生变动的那些符号引用,大部分都是动态链接,比如多态,重写这些,编译时不知道要调用哪个方法或者使用哪个字段,需要运行时才能确定。

名词解释

符号引用

  1. 使用符号来描述引用的目标,符号可以是任何形式的字面量,只需要能够准群的定位到目标就行

  2. 与虚拟机的内存布局无关,由于一份Class文件能够加载到不同的虚拟机中,但是虚拟机的实现不同其内存布局也不同,符号引用存在于Class文件中,而直接引用是一个内存地址。因此对于符号引用来说,只需保证能够确保加载的目标即可。

比如:一个字段,在常量池中是用的CONSTANT_Fieldref_info表示的,至于要在虚拟机中怎么分配内存,这是虚拟机的事情,但是对于不同虚拟机来说,这个CONSTANT_Fieldref_info属性都是一样的。

符号引用就是一个字段/类/方法的属性表,是存在于Class文件中的,对于不同虚拟机来说符号引用是一样的,确定不变的。

直接引用:

  1. 能够直接定位到目标的指针,或者间接定位的句柄,这个是和虚拟机内存布局相关的,不同的虚拟机内存空间不同,自然而然指针,偏移量也就不一样。

直接引用就是将Class文件中的符号引用(也就是字段/类/方法的属性表)转换为真实的内存地址(访问读取修改就是基于真实的内存地址来操作的,为了之后的操作)。由于是内存地址,不同虚拟机的内存布局实现可能不同,对于不同的虚拟机来说直接引用是不一样的,不确定的。

举个栗子 同一种水果,在不同的国家有不同的叫法。但是水果本身是不变的,因此水果本身可以类比为符号引用,不同的叫法可以类比与不同的虚拟机中这个引用的真实内存分布也不一样。

解析:静态链接

**编译期间即可确定**

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中又有对应的强转字节码来获取更改之后得变量的静态类型所以编译时是可以确定变量的静态类型的,但是实际类型需要根据运行时才能够进行确定(下面动态链接详细说明)。

静态链接发生的阶段其实也可以分为:静态分派和解析阶段 解析阶段就是将编译期可以确定不会发生变化的符号引用转换为直接引用。 有以下这些方法: 构造方法,私有方法,静态方法

这些叫做非虚方法也就是运行时不会发生变化,编译器即可确定

而静态分派则是更改变量的静态类型,但是也可以确定编译时期不会发生变化。

(静态分派和动态分派的区别在于:静态分派是根据静态类型来确定调用的方法是哪个,而动态分配时根据变量的实际类型。由此可以得出静态分派在编译时就可以确定,而动态分派需要运行时) 典型的栗子就是方法的参数是根据参数的静态类型来决定调用哪个方法,重载就是利用的这点实现的,而调用多态则是根据变量的实际类型来确定的, 比如方法的重写。

而且当找不到对应的静态类型的时候在还会默认对静态类型进行转换(实现接口-继承的类-进行装箱-变成一个变长类型)。

动态链接: 编译时不能确认转换成哪个引用要等到运行时才可以确认调用的是哪个方法。

动态链接是和变量的实际类型相关的,因为变量的实际类型不能够在编译时确定只能等到运行时才可以,比如某个条件成立就将这个变量转换为A,不成立转为B(前提这个条件的成立与否必须是运行时才可以确定的,不能够在编译时就能够确认比如if(true) ),这个条件的成立必须是运行时才可进行确定的不能够在编译的时候就知道其实际类型是什么。所以叫做动态链接运行时才可以确定。

从编译器的角度来讲: 首先变量必须都得有个类型(静态类型)用于之后的字段表中存储代表这是什么类型的变量。

那么从字段表中获取到的类型就是静态类型,这个是在编译的时候生成的属性表示可以确定的,但是静态类型是可以进行变化的,比如类型强转就是改变的静态类型,但是强转后的类型编译期也可以确定(有对应的强转字节码指令)。所以静态类型发不发生变化都能够在编译期确定【不仅限于强转类似的比如编译期如果找不到对应的静态类型则编译期会根据一些规则来改变静态类型后面说明】,简单说就是字段必须要声明一个类型,而编译器自然就可以知道这个类型是什么,而其变化也是可以知道的,所以这种叫做静态链接(编译期确定)

对于方法的参数来说,只能指定参数的静态类型是什么(需要匹配静态类型),参数的实际类型在运行过程中是会发生变化而且也不能确定最终的类型,但是对于静态类型来说不管他怎么变编译的时候都是能够确定他的类型是什么。所以对于重载的方法来说,最终调用哪个方法是编译的时候就 已经在方法调用的字节码后面写上了方法的最终调用版本。

但是对于调用方法的对象来说,真正调用哪个方法是根据这个对象的实际类型来决定的(比如子类重写父类方法,创建一个子类对象,不管静态类型是什么,最终调用的肯定是子类中的方法)。由于实际类型编译期无法确定,所以也叫动态链接(运行时才能确定) 但是每次都到运行时才进行查找效率太低,所以在解析这个阶段的时候还会生成一个虚方法表来优化查找效率,类/接口中都有一个虚方法表,那么是如何优化的呢?:虚方法表中如果子类重写了父类的方法,则其对应的直接饮用地址就是自己的,如果没有重写就指向父类中对应方法的直接饮用地址,为了更快的匹配子类中的虚方法表对应的方法如果没有重写其下标和父类虚方法表的下标一致。

最后放几张图来解释下(ps:图比写文字还累....)

由于图片太大,我把它放到了ProcessOn里面,读者可以点击下面的链接查看

解析阶段全面总结


何时进行

虚拟机可以选择在类加载时就进行解析,也可以在真正使用的时候在解析

但是对于以下需要操作符号引用的指令之前,必须对符号引用进行解析:

ane-warray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invoke-special、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield和putstatic

需要解析的符号引用:

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行。 分别对应于常量池的CONSTANT_Class_info、CON-STANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dyna-mic_info和CONSTANT_InvokeDynamic_info 8种常量类型


解析哪些类型

一,类/接口解析 类的解析其实更像是对类的加载,之后进行访问权限的验证

1.1.1这个类泛指Class和接口 通过类的全限定名(符号引用)进行类加载,那么用哪个类加载器加载呢?一般是使用定义该类的类的类加载器,怎么理解(比如A中定义了一个B类,那么B类的类加载器是用的A类的类加载器)。

注意:是一般,类加载是根据双亲委派模型机制进行加载的,某些类加载器是专门加载某些路径下面的类。而且这种关系可以被打破,比如SPI中,加载ServiceLoader的类加载器是系统类加载器,而其却需要加载开发者自定义的类(ServiceLoader中的load方法接受的是开发者的类),这个时候会找不到这个类【因为系统类加载器的加载类路径中没有这个类】,所以这个时候就是父类类加载器需要加载子类的类加载器,通过引入ContextClassLoader线程类加载器来实现,默认情况下是会用应用类加载器来加载。 还有一点需要注意:上面所说的父类子类类加载器并不是java上继承的关系,而是其内部的一个字段parent。

那么类的加载又会涉及到类加载的这些阶段,所以接着往下讲。

1.1.2当碰到是数组的时候 碰到数组对象时,首先加载数组的所属类型,如上面所说的类解析是一样的流程;当这个类型加载完后(类加载阶段结束),虚拟机会在生成一个类,这个类的目的是什么呢?(前面说到过所属类型去掉了所有的维度,那么这个虚拟机自动生成的类就是去表达出数组的维度和长度的)

注意这个所属类型是不区分维度的也就是去掉所有维度的类型,比如一个二维数组的A类和一个三维数组的A类,其所属类型都是A类,解析的也就是A类,进行加载的也是A类。

1.2进行访问权限验证 如果解析该类的类没有对解析类的使用权限,那么也会解析失败。

二,字段解析 第一步:解析字段类型 在上一篇文章中提到过字段表这个属性表会持有字段所属类型的信息,也就是CONSTANT_Class_info,所以先对这个类性加载就是上面一中的解析; 第二步:查找字段 我们知道字段有这两个属性(简单名称,描述符),比如String a=""; 简单名称是a,描述符是String的权限定名,这两个信息是字段的基本信息,那么查找的时候也会根据这两个属性来查找。

如果在解析这个字段的类(接口)中能找到这个字段,那么就直接返回了查找成功代表;如果没有找到,先根据这个类/接口 实现/继承 的接口从下往上找这个字段,找到就会返回;如果还是没有找到,而且不是接口的话(接口只能继承,上面已经查找过;但是类可以实现接口还可以继承父类),在按照从下往上找的顺序从父类中找。如果还没有找到抛出一个NoSuchFieldError的异常。

第三步:权限验证 验证解析该字段的类/接口是否有对该字段的访问权限,如果没有也会解析失败。

初始化

前面讲到过在类的准备阶段时会对静态final的常量进行初始化并赋值,而对只有static修饰没有被final修饰的变量则是赋默认值。

那么初始化阶段也就是对静态类型上面说的赋默认值的静态变量进行赋值操作,同时该阶段也会执行静态语句块中的内容。将这两个步骤合到一块就是静态变量赋值操作和静态语句块执行操作,编译器整合这两个操作生成了一个方法叫做cinit。而执行和赋值的操作是根据用户写的java文件的顺序决定的。

在初始化阶段也需要确保父类完成类加载,因此cinit方法执行前父类的cinit方法肯定会执行完毕,和类的构造函数init方法还不太一样,cinit不需要显示调用父类的构造器。

注意: 1.cinit方法不一定会生成,如果没有静态代码块或者静态变量,那么编译器是不会生成这个方法的 2.JAVA虚拟机会保证父类的cinit方法先执行,不需要像init方法一样显示的调用父类构造器来保证父类init方法执行完成。

接口

接口中没有静态代码块,字段默认是static和final修饰的。

注意: 1.接口的cinit方法执行前不一定需要父接口的cinit方法也执行完。当使用到了父接口中的变量父接口才会初始化。 2.接口实现类初始化前不会执行接口的cinit方法。 3.cinit方法是加锁同步的,多线程初始化同一个类时会发生阻塞只有当cinit方法执行完才可以释放锁。 总结:

初始化也就是执行编译器自动添加的cinit方法,按java文件中的顺序为静态变量赋值和静态语句块执行的操作。

使用

这里类加载完成之后就可以进行使用了,上面说到的都是静态变量,代码块的初始化赋值执行操作,那么类的成员变量,类的构造方法呢?这些叫做init方法执行构造方法,完成类成员变量的初始化(也就是实例变量),当然这些都是在创建完对象后进行的操作,而且init执行前需要显示的去执行父类的init方法。

卸载

类型卸载的条件比较严苛:

1.该类的所有对象都已被清除 2.该类的java.lang.Class对象没有被任何对象或变量引用。只要一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class。这个对象在类被加载进方法区的时候创建,在方法区中该类被删除时清除。 3.加载该类的ClassLoader已经被回收