JVM虚拟机类加载过程

转自 类加载过程
可以结合 葛一鸣老师的JVM视屏,下面的内容基本来源于视屏讲义。

1、加载

“加载”阶段是“类加载”过程的一个阶段,二者不能混淆。在加载阶段,虚拟机需要完成以下三件事:

(1)通过一个类的全限定名来获取定义此类的二进制字节字节流。

(2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

(3)在java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口。

第一个通过类全限定名来获取二进制字节流,并没有指明二进制字节流要从一个Class文件中获取,准确说是根本没有指明要从哪里获取及怎样获取。所以虚拟机设计在加载阶段比较开放,例如:从ZIP包中读取,这很常见,最终成为JAR、EAR、WAR格式的基础。从网络中获取,这种场景最典型应用就是Applet。运行时计算生成,使用最多就是动态代理技术。在java.lang.reflect.Proxy总,就使用了ProxyGenerator.generateProxyClass来为特定接口生成*$Proxy的代理类的二进制字节流。由其他文件生成,典型场景:JSP应用。

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需要的格式存储在方法区中,方法区中的数据存储格式由虚拟机自行定义,虚拟机并未规定此区域的具体数据结构,然后在java堆中实例化一个java.lang.Class类的对象,这个对象作为程序访问方法区中的这些类型数据的外部接口。加载阶段与连接阶段部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些加载阶段之中进行的动作,仍然属于连接阶段内容,这两个阶段仍然保持着固定的先后顺序。

2、验证

验证是连接阶段的第一步,这一阶段是为了确保Class文件的字节流中包含的仔细符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

java是本身相对安全的语言(相对c/c++),使用纯粹的java代码无法坐到:访问数据边界以外的数据、将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类。如果这样做了,编译器拒绝编译。但是,Class文件并不一定要求用java源码编译而来,可以使用任何途径,包括用十六进制编译器直接编写来产生class文件,在字节码的语言层面,上述java代码无法做到的事情是都可以实现的,至少语义上是可以表达出来的。虚拟机如果不检查输入的字节流。对其完全信任,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。

不同虚拟机对类验证的实现可能会有所不同,但大致都会完成下面四个阶段检验过程:文件格式验证、元数据验证、字节码验证和符号引用验证。

(1)文件格式验证

第一个阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段验证可能包括几个点:

是否以魔数0xCAFEBABE开始。主、次版本号是否在当前虚拟机处理范围之内。常量池的常量是否有不被支持的常量类型(检查常量tag标志)。指向常量池的各种索引值中是否有之下不存在的常量或不符合类型的常量。CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据。Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。等等,

该验证阶段主要目的是保证输入的字节流能正确的解析并存储于方法去之内,格式上符合描述一个java类型信息的要求,这阶段验证是基于字节流进行的,经过这个阶段验证后,字节流才会进入内存的方法区进行存储,所以后面的三个验证阶段全部是基于方法区基于存储结构进行的。

(2)元数据验证

第二个阶段是最字节码描述的信息进行语义分析,以保证其描述的信息符合java语言规范的要求,这个阶段包括的验证点:这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。这个类的父类是否继承了不允许被继承的类(被final修饰的类)。如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。类中的字段、方法是否与父类产生了矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载)等等。

第二个阶段主要目的是对类的元数据信息进行语义校验,保证不存在不符合java语言规范的元数据信息。

(3)字节码验证

第三阶段是验证过程中最复杂的一个阶段,主要工作是进行数据流和控制流分析。在第二阶段对元数据信息中的数据类型做完校验后,这个阶段将对类的方法进行校验分析,这阶段任务是保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。例如:保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现在操作栈中放置了一个int类型的数据,使用时却按long类型来加载本地变量表中。保证跳转指令不会跳转到方法体以外的字节码指令上。保证方法体中的类型转换是有效的,例如把一个子类对象复制给父类数据类型,这是安全的,但把父类对象复制给子类数据类型,甚至把对象赋值给与它毫无继承关系的,则是危险和不合法的。

如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的,但如果方法体通过了字节码验证,,也不能保证这一点,通过程序去校验程序逻辑是无法做到的绝对准确的。

(4)符号引用验证

最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三个阶段-解析阶段中发生,符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配的校验,通常校验内容:符号引用中通过字符串描述的全限定名是否能找到对应的类。在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。符号引用中的类、字段和方法的访问性(private、protected、public、default)是否可被当前类访问。

符号引用验证的目的是确保解析动作能正常执行,如果无法通过符号引用验证,将会抛出java.lang.IncompatibleClassChangeError异常的子类,如java.lang.IllegalAccessErro、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

验证阶段对虚拟机的类加载机制来说,是一个非常重要但不一定必要的阶段,如果所运行的全部代码(包括自己写的、第三方包中的代码)都已经被反复使用和验证过,在实施阶段就可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

3、准备

准备阶段是正式为类变量分配内存并设置类变量初始化的阶段。这些内存都将在方法区中进行分配。这里有两个易混淆的概念:首先是这个时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中。其次这里说的初始值“通常情况”下是数据类型的零值,假设一个变量的定义为:

public static int value = 123;

那么变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器< clinit>()方法中,所以把value赋值为123的动作将在初始化阶段才会执行。

上面说到“通常情况下”初始值是零值,那相对会有一些“特殊情况”,如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstanValue属性所指定的值,假设上面类变量value的定义变为:

public static final int value = 123;编译时javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

4、解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用再Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现,那解析阶段的直接引用于符号引用有什么关联呢?

符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。符号引用于虚拟机实现的内存布局无关,引用的目标不一定已经加载到内存中。

直接引用(direct References):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的。同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用目标必定已经在内存中存在。

解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info以及CONSTANT_InterfaceMethodref_info四种常量类型。

(1)类或接口的解析:

假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用。那虚拟机完成整个解析的过程包括3个步骤:

a、如果c不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于无数据验证、字节码验证的需要,又将可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口,一旦这个加载过程出现了任何异常,解析过程就宣告失败。

b、如果c是一个数组类型,并且数组的元素类型为对象,也就是N的描述符将会是类似“[Ljava.lang.Integer”的形式,那将会按照第1点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着又虚拟机生成一个代表此数组维度和元素的数组对象。

c、如果上面步骤没有出现任何异常,那么C在虚拟机中实际神已经成为一个有效的类或接口了,但在解析完成之前还要进行符号验证,确认C是否具备对D的访问权限,如果发现不具备访问权限,将抛出java.lang.IllegelAccessError异常。

5、初始化

类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到初始化阶段。才真正开始执行类中定义的java程序代码(或者说是字节码)。

在准备阶段,变量已经赋值过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达,初始化阶段是执行类构造器< clinit>()方法的过程。

< clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。

< clinit>()方法与类的构造函数(或者说实例构造器< init>()方法)不同,它不需要显示的调用父类构造器,虚拟机会保证子类的< clinit>()方法执行之前,父类的< clinit>方法已经执行完毕,因此在虚拟机中第一个被执行的< cinit>()方法的类肯定是java.lang.Object.

由于父类< clinit>()方法先执行,也就意味着父类中定义静态语句块要优先于子类的变量赋值操作。

猜你喜欢

转载自blog.csdn.net/qq_25673113/article/details/78927046