您的位置:首页 > 其它

Jvm笔记总结(八):虚拟机类加载机制

2018-01-28 23:04 246 查看

Jvm笔记总结(八):虚拟机类加载机制

PS : 本文乃学习整理参考而来 ,目录参考 [ Jvm系列目录 ]

概述

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验转换解析初始化最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。与那些在编译时需要进行连接工作的语言不同,在Java语言里面,类的加载连接、和初始化都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载动态连接接这个特点实现的。

例如:一个面向接口的应用程序可以等到运行时再指定其实际的实现类;用户也可以通过自定义类加载器让一个本地应用程序可以在运行时网络或其他地方加载一个二进制流作为程序代码的一部分

类加载的时机:

类被从加载到虚拟机内存开始,到卸载出内存为止,他的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中验证、准备、解析3个部分称为连接(Linking)。如图



其中,加载、验证、准备、初始化卸载5个阶段开始顺序是确定的但并不是按顺序“完成”的,因为这些阶段通常是互相交叉的混合式进行的。而解析阶段则不一定有可能发生在初始化前 或者初始化后,这是为了支持Java语言的运行时绑定。何时进行“初始化”阶段,虚拟机则严格规定了以下5种情况必须对类进行初始化(加载、验证、准备必须在此之前完成)。

1.遇到newgetstaticputstaticinvokestatic这4条字节码指令时如果类没有进行初始化则需要触发其初始化

生成这4条指令最常见的Java代码场景是:使用new关键字实例化对象读取或设置一个静态(被final修饰除外)字段,以及调用一个类的静态方法

2.使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则先触发其初始化。

3.当初始化一个类的时候,如果发现其父类还没有进行初始化,则需要先触发其父类的初始化

4.虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的类),虚拟机会先初始化这个主类

5.当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

类加载过程:加载、验证、准
eee5
备、解析和初始化即类加载全过程


加载:“加载”只是“类加载(Class Loading)”过程的一个阶段在加载阶段,虚拟机需要完成以下3件事

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

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

3.内存中生成一个代表这个类的java.lang.Class对象作为方法区这个类的各种数据的访问入口

通过一个类的全限定名获取定义此类的二进制字节流”这条,并未指明二进制字节流获取的方式以及从哪里获取。所以,加载阶段中获取二进制字节流的动作开发人员可控性最强的,因为类加载阶段既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器去完成,也可以自定义类加载器去控制字节流获取方式重写类加载器的loadClass()方法)。如:

1.从zip包获取,如JARWAREAR

2.从网络当中获取。

3.运行时产生,这种场景使用最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是使用ProxyGenerator.generateProxyClass来为特定接口生成形如“*$Proxy”的代理二进制字节流。

4.由其他文件生成,典型场景是JSP应用,由JSP文件生成Class文件

5.从数据库读取。

极端情况下Class文件甚至可以通过16进制编辑器直接编写获得

加载阶段完成后虚拟机外部二进制字节流就按照虚拟机所需的格式存储在方法区之中,然后在内存中(方法区)实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中类型数据的外部接口

加载阶段连接阶段的部分内容(一部分字节码格式验证动作)是交叉进行的,加载阶段尚未完成连接阶段可能已经开始

验证:验证是连接阶段的第一步,是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。由于class文件的获取途径没有任何限制,如果虚拟机不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作,验证阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码的攻击,从执行性能上讲,验证阶段的工作量在虚拟机加载中又占了相当大一部分但虽然重要,但并不是必须的,在确认运行稳定后,可以通过-Xverify:none来关闭大部分验证提高虚拟机效率。验证阶段大致上会完成下面4个阶段的检查动作:文件格式验证、元数据验证、字节码验证、符号引用验证

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

1.是否以魔数0xCAFEBABE开头

2.主、次版本号是否在当前虚拟机处理范围之内。

3.常量池的常量是否有不被支持的常量类型(检查常量tag标志)。PS:Class常量池详解

4.指向常量的各种索引值中是否有指向不存在的常量不符合类型的常量

5.Class文件中各部分及文件本身是否有被删除附加的其他信息

实际第一阶段验证点远不止这些,这只是一小部分内容,该验证阶段主要保证输入的字节流能正确的解析并存储于方法区之内这阶段验证基于二进制字节流进行的,只有通过了这个阶段的验证后字节流才会进入内存的方法区中进行存储,所以后面3个验证阶段全部基于方法区的存储结构进行的,不会再直接操作字节流

2.元数据验证:第二阶段是对字节码的描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,主要验证点:

1.这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。

2.这个类的父类是否继承了不允许被继承的类(被final修饰的类);

3.如果这个类不是抽象类是否实现了其父类或接口中要求实现的所有方法

4.类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载。)

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

3.字节码验证第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,这个阶段将对类方法体进行校验分析,主要验证点

1.保证任意时刻操作数栈数据类型与指令代码都能配合工作,例如不会出现:在操作栈上放置了一个int类型的数据,使用时却按long类型来加载入本地变量表

2.保证跳转不会跳转到方法体外的字节码指令上。

3.保证方法体中类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型甚至把对象赋值给与他毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的

如果一个类方法体字节码没有通过字节码验证,那肯定是有问题的;但如果一个方法通过了字节码验证,也不能说明其一定就是安全的。其中有一个很著名的问题“Halting Problem”通过程序去校验程序逻辑是无法做到绝对准确的。由于数据流验证的高度复杂性,虚拟机设计团队为了避免过多的时间消耗在字节码验证阶段,JDK1.6之后的Javac编译器和Java虚拟机中进行了一项优化,给方法体Code属性的属性表中增加了一项名为“StackMapTable”的属性,这项属性描述了方法体中所有的基本块开始时本地变量表和操作栈应有的状态,在字节码验证期间,只需要检查StackMapTable属性中记录的是否合法即可。这样字节码验证的类型推导转变为类型检查从而节省一些时间

4.符号引用验证:最后一个阶段的校验发生在虚拟机符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段–解析阶段中发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验主要验证点:

1.符号引用通过字符串的描述的全限定名是否能找到对应的类

2.在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段

3.符号引用中的类、字段、方法的访问性(private、protect、public、default)是否可被当前类访问

符号引用验证目的是确保解析动作能正常执行

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

准备:准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区进行分配这个阶段有两个容易混淆的点需要强调,首先这时候进行内存分配仅包括类变量,而不包括实例变量实例变量将会在对象实例化时随对象一起分配在Java堆中。其次,这里所说的初始值“通常情况”下是数据类型的零值

假设一个类变量定义为:

public static int value = 123;


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

而“特殊情况”,如:

public static final int value = 123;


如果被final修饰,那么在编译时类字段的字段属性表中会存在ConstantValue属性,那么在准备阶段变量value就会被初始化为ConstantValue属性所指定的值

解析:解析阶段是虚拟机将常量池内符号引用替换为直接引用的过程,符号引用在Class文件格式中有描述。在Class文件中他以CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_info等类型的常量出现。关于直接引用与符号引用又有什么关联,可参考符号引用和直接引用

符号引用(Symbolic References)符号引用以一组符号来描述引用的目标,符号可以是任何形式的字面量。各种虚拟机实现的内存布局各不相同,但是他能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。

直接引用(Diect References)直接引用可以是直接指向目标指针相对偏移量或是一个能间接定位到目标的句柄

直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标已经在内存中存在了。

虚拟机之中并未规定解析阶段发生的具体时间,只要求了在执行anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevitual、ldc、ldc_w、multianewarray、new、putfield和putstatic这16个字节码指令之前,先对他所使用的符号引用进行解析。所以虚拟机实现可以根据需要来判断到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前去解析他

除invokedynamic指令以外,虚拟机实现可以对第一次解析结果进行缓存(在运行时常量池中记录直接引用,并把常量标志为已解析状态)从而避免解析动作重复进行。而invokedynamic指令,上面规则不成立,当碰到某个前面已经由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对其他invokedynamic指令也同样生效。因为invokedynamic指令的目的本来就是用于动态语言支持,而Java属于静态语言,省略过多描述。

什么是动态类型语言,动态类型的关键特征是他的类型检查的主体过程是在运行期,而不是编译期。动态类型语言如:Clojure、Groovy、JavaScript、Jython、PHP、Python等。相对的,在编译期就进行类型检查过程的语言就是静态类型语言,如:C++和Java

解析动作主要针对接口字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行解析,分别对应常量池的:CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_infoCONSTANT_InterfaceMethodref_infoCONSTANT_MethodType_indoCONSTANT_MethodHandle_info、CONSTANT_InvokeDynamic_info7种常量类型。以下详解前4种引用的解析过程。后面三种,与JDK1.7新增的动态语言支持息息相关,由于Java语言是一门静态类型语言,省略记录。

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

1、如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关的加载动作,如果在解析中出现任何异常,都会导致解析失败。

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

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

2.字段(CONSTANT_Fieldref_info)解析:要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析(也就是触发类或接口解析),也就是字段所属类或接口的符号引用

如果解析成功完成,那将这个字段所属的类或接口用C表示,虚拟机规范按照如下步骤对C进行后续字段的搜索。

1.如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。

2,否则,如果C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和他的父接口,如果接口中包含了与目标相匹配的字段,则返回这个字段的直接引用,查找结束。

3.否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了与目标相匹配的字段,则返回这个字段的直接引用。

4.否则,查找失败。

如果查找成功返回了引用,将会对这个字段进行权限验证,如果发现不具备字段的访问权限,将抛出异常

3.类方法解析(CONSTANT_Methodref_info):类方法解析的第一个步骤与字段解析一样,先需要解析出类方法表class_index项中索引方法所属的类或接口的符号引用如果解析成功,我们依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续类方法搜索。

1.在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。

2.否则,在类C的父类中递归查找是否有与目标想匹配的方法,如果有则返回这个方法的直接引用,查找结束。

3.否则,在类C实现的接口列表及他们的父接口中递归查找是否有与之匹配的方法,如果存在,说明类C是一个抽象类,查找结束,抛出AbstractMethodError异常。

4.否则宣告查找失败,抛出java.lang.NoSuchMethodError.

最后如果查找成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备此方法的访问权限,将抛出异常

4.接口方法解析:基本与类方法相同。

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

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

下文对 <clinit>()做出解释:

1.<clinit>()方法是由编译器自动收集类中所有类变量赋值动作静态语句块(static{})中的语句合并产生,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块只能访问到定义在静态语句块之前的变量定义在他之后的变量,在静态语句块中可以赋值,但不能访问。言下之意,要在静态代码块中访问类变量,那必须声明在静态语句块之前,否则只能赋值,不能访问

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

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

4.<clinit>()方法对类或者接口来说不是必须的,如果一个类没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。

5.接口中不能使用静态语句块,但仍然有类变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但与类不同的是接口的<clinit>()方法不需要先执行父类的<clinit>()方法只有当父类接口中的变量使用时,父类接口才会初始化。

6.虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确的加锁、同步,如果多个线程用时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()完毕。

经过编译器编译后会生成<init>方法,有可能会生成<clinit>方法。这两个构造器的产生过程实际上是一个代码收敛的过程

编译器会把“{}”语句块实例变量初始化调用父类实例构造器等操作收敛到<init>中。

编译器会把“static{}”静态语句类变量初始化操作收敛到<clinit>中。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: