Java类加载的过程加载、验证、准备、解析、初始化
这⾥写⽬录标题
7.3 类加载的过程
接下来我们会详细了解Java虚拟机中类加载的全过程,即加载、验证、准备、解析和初始化这五个阶段所执⾏的具体动作。
7.3.1 加载
“加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的⼀个阶段,希望读者没有混淆 这两个看起来很相似的名词。在加载阶段,Java虚拟机需要完成以下三件事情:
1)通过⼀个类的全限定名来获取定义此类的⼆进制字节流。
2)将这个字节流所代表的静态存储结构转化为⽅法区的运⾏时数据结构。
3)在内存中⽣成⼀个代表这个类的java.lang.Class对象,作为⽅法区这个类的各种数据的访问⼊⼝。
《Java虚拟机规范》对这三点要求其实并不是特别具体,留给虚拟机实现与Java应⽤的灵活度都是 相当
⼤的。例如“通过⼀个类的全限定名来获取定义此类的⼆进制字节流”这条规则,它并没有指明⼆ 进制字节流必须得从某个Class⽂件中获取,确切地说是根本没有指明要从哪⾥获取、如何获取。仅仅 这⼀点空隙,Java虚拟机的使⽤者们就可以在加载阶段搭构建出⼀个相当开放⼴阔的舞台,Java发展历程中,充满创造⼒的开发⼈员则在这个舞台上玩出了各种花样,许多举⾜轻重的Java技术都建⽴在这 ⼀基础之上,例如:
·从ZIP压缩包中读取,这很常见,最终成为⽇后JAR、EAR、WAR格式的基础。
·从⽹络中获取,这种场景最典型的应⽤就是Web Applet。
·运⾏时计算⽣成,这种场景使⽤得最多的就是动态代理技术,在flect.Proxy中,就是⽤ 了
java源代码加密
·由其他⽂件⽣成,典型场景是JSP应⽤,由JSP⽂件⽣成对应的Class⽂件。
·从数据库中读取,这种场景相对少见些,例如有些中间件服务器(如SAP Netweaver)可以选择 把程序安装到数据库中来完成程序代码在集间的分发。
·可以从加密⽂件中获取,这是典型的防Class⽂件被反编译的保护措施,通过加载时解密Class⽂ 件来保障程序运⾏逻辑不被窥探。
·……
相对于类加载过程的其他阶段,⾮数组类型的加载阶段(准确地说,是加载阶段中获取类的⼆进 制字节流的动作)是开发⼈员可控性最强的阶段。加载阶段既可以使⽤Java虚拟机⾥内置的引导类加载器来完成,也可以由⽤户⾃定义的类加载器去完成,开发⼈员通过定义⾃⼰的类加载器去控制字节流的获取⽅式(重写⼀个类加载器的findClass()或loadClass()⽅法),实现根据⾃⼰的想法来赋予应⽤ 程序获取运⾏代码的动态性。
对于数组类⽽⾔,情况就有所不同,数组类本⾝不通过类加载器创建,它是由Java虚拟机直接在 内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(ElementType,指的是数组去掉所有维度的类型)最终还是要靠类加载器来完成加载,⼀个数组类(下⾯简称 为C)创建过程遵循以下规则:
·如果数组的组件类型(Component Type,指的是数组去掉⼀个维度的类型,注意和前⾯的元素类型区分开来)是引⽤类型,那就递归采⽤本节中定义的加载过程去加载这个组件类型,数组C将被标 识在加载该组件类型的类加载器的类名称空间上(这点很重要,在7.4节会介绍,⼀个类型必须与类加载器⼀起确定唯⼀性)。
·如果数组的组件类型不是引⽤类型(例如int[]数组的组件类型为int),Java虚拟机将会把数组C 标记
为与引导类加载器关联。
·数组类的可访问性与它的组件类型的可访问性⼀致,如果组件类型不是引⽤类型,它的数组类的 可访问性将默认为public,可被所有的类和接⼝访问到。
加载阶段结束后,Java虚拟机外部的⼆进制字节流就按照虚拟机所设定的格式存储在⽅法区之中 了,⽅法区中的数据存储格式完全由虚拟机实现⾃⾏定义,《Java虚拟机规范》未规定此区域的具体 数据结构。类型数据妥善安置在⽅法区之后,会在Java堆内存中实例化⼀个java.lang.Class类的对象, 这个对象将作为程序访问⽅法区中的类型数据的外部接⼝。
加载阶段与连接阶段的部分动作(如⼀部分字节码⽂件格式验证动作)是交叉进⾏的,加载阶段 尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进⾏的动作,仍然属于连接阶段的⼀部 分,这两个阶段的开始时间仍然保持着固定的先后顺序。
7.3.2 验证
验证是连接阶段的第⼀步,这⼀阶段的⽬的是确保Class⽂件的字节流中包含的信息符合《Java虚 拟机规范》的全部约束要求,保证这些信息被当作代码运⾏后不会危害虚拟机⾃⾝的安全。
Java语⾔本⾝是相对安全的编程语⾔(起码对于C/C++来说是相对安全的),使⽤纯粹的Java代码 ⽆
法做到诸如访问数组边界以外的数据、将⼀个对象转型为它并未实现的类型、跳转到不存在的代码 ⾏之类的事情,如果尝试这样去做了,编译器会毫不留情地抛出异常、拒绝编译。但前⾯也曾说过, Class⽂件并不⼀定只能由Java源码编译⽽来,它可以使⽤包括靠键盘0和1直接在⼆进制编辑器中敲出Class⽂件在内的任何途径产⽣。上述Java代码⽆法做到的事情在字节码层⾯上都是可以实现的,⾄少 语义上是可以表达出来的。Java虚拟机如果不检查输⼊的字节流,对其完全信任的话,很可能会因为 载⼊了有错误或有恶意企图的字节码流⽽导致整个系统受攻击甚⾄崩溃,所以验证字节码是Java虚拟 机保护⾃⾝的⼀项必要措施。
验证阶段是⾮常重要的,这个阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码的攻 击,从代码量和耗费的执⾏性能的⾓度上讲,验证阶段的⼯作量在虚拟机的类加载过程中占了相当⼤的⽐重。但是《Java虚拟机规范》的早期版本(第1、2版)对这个阶段的检验指导是相当模糊和笼统 的,规范中仅列举了⼀些对Class⽂件格式的静态和结构化的约束,要求虚拟机验证到输⼊的字节流如 不符合Class⽂件格式的约束,就应当抛出⼀个java.lang.VerifyError异常或其⼦类异常,但具体应当检查 哪些内容、如何检查、何时进⾏检查等,都没有⾜够具体的要求和明确的说明。直到2011年《Java虚 拟机规范(Java SE 7版)》出版,规范中⼤幅增加了验证过程的描述(篇幅从不到10页增加到130 页),这时验证阶段的约束和验证规则才变得具体起来。受篇幅所限,本书中⽆法逐条规则去讲解, 但从整体上看,验证阶段⼤致上会完成下⾯四个阶段的检验动作:⽂件格式验证、元数据验证、字节码验证和符号引⽤验证。
1.⽂件格式验证
第⼀阶段要验证字节流是否符合Class⽂件格式的规范,并且能被当前版本的虚拟机处理。这⼀阶 段可能包括下⾯这些验证点:
·是否以魔数0xCAFEBABE开头。
·主、次版本号是否在当前Java虚拟机接受范围之内。
·常量池的常量中是否有不被⽀持的常量类型(检查常量tag标志)。
·指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
·CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据。
·Class⽂件中各个部分及⽂件本⾝是否有被删除的或附加的其他信息。
·……
实际上第⼀阶段的验证点还远不⽌这些,上⾯所列的只是从HotSpot虚拟机源码 [1] 中摘抄的⼀⼩ 部分内容,该验证阶段的主要⽬的是保证输⼊的字节流能正确地解析并存储于⽅法区之内,格式上符 合描
述⼀个Java类型信息的要求。这阶段的验证是基于⼆进制字节流进⾏的,只有通过了这个阶段的 验证之后,这段字节流才被允许进⼊Java虚拟机内存的⽅法区中进⾏存储,所以后⾯的三个验证阶段 全部是基于⽅法区的存储结构上进⾏的,不会再直接读取、操作字节流了。
2.元数据验证
第⼆阶段是对字节码描述的信息进⾏语义分析,以保证其描述的信息符合《Java语⾔规范》的要求,这个阶段可能包括的验证点如下:
·这个类是否有⽗类(除了java.lang.Object之外,所有的类都应当有⽗类)。
·这个类的⽗类是否继承了不允许被继承的类(被final修饰的类)。
·如果这个类不是抽象类,是否实现了其⽗类或接⼝之中要求实现的所有⽅法。
·类中的字段、⽅法是否与⽗类产⽣⽭盾(例如覆盖了⽗类的final字段,或者出现不符合规则的⽅ 法重载,例如⽅法参数都⼀致,但返回值类型却不同等)。
·……
第⼆阶段的主要⽬的是对类的元数据信息进⾏语义校验,保证不存在与《Java语⾔规范》定义相悖的元数据信息。
3.字节码验证
第三阶段是整个验证过程中最复杂的⼀个阶段,主要⽬的是通过数据流分析和控制流分析,确定 程序语义是合法的、符合逻辑的。在第⼆阶段对元数据信息中的数据类型校验完毕以后,这阶段就要 对类的⽅法体(Class⽂件中的Code属性)进⾏校验分析,保证被校验类的⽅法在运⾏时不会做出危害 虚拟机安全的⾏为,例如:
·保证任意时刻操作数栈的数据类型与指令代码序列都能配合⼯作,例如不会出现类似于“在操作 栈放置了⼀个int类型的数据,使⽤时却按long类型来加载⼊本地变量表中”这样的情况。
·保证任何跳转指令都不会跳转到⽅法体以外的字节码指令上。
·保证⽅法体中的类型转换总是有效的,例如可以把⼀个⼦类对象赋值给⽗类数据类型,这是安全 的,但是把⽗类对象赋值给⼦类数据类型,甚⾄把对象赋值给与它毫⽆继承关系、完全不相⼲的⼀个 数据类型,则是危险和不合法的。
·……
如果⼀个类型中有⽅法体的字节码没有通过字节码验证,那它肯定是有问题的;但如果⼀个⽅法 体通过了字节码验证,也仍然不能保证它⼀定就是安全的。即使字节码验证阶段中进⾏了再⼤量、再 严密的检查,也依然不能保证这⼀点。这⾥涉及了离散数学中⼀个很著名的问题——“停机问题”(Halting Problem)[2] ,即不能通过程序准确地检查出程序是否能在有限的时间之内结束运⾏。在 我们讨论字节码校验的上下⽂语境⾥,通俗⼀点的解释是通过程序去校验程序逻辑是⽆法做到绝对准 确的,不可能⽤程序来准确判定⼀段程序是否存在Bug。
由于数据流分析和控制流分析的⾼度复杂性,Java虚拟机的设计团队为了避免过多的执⾏时间消 耗在字节码验证阶段中,在JDK 6之后的Javac编译器和Java虚拟机⾥进⾏了⼀项联合优化,把尽可能 多的校验辅助措施挪到Javac编译器⾥进⾏。具体做法是给⽅法体Code 属性的属性表中新增加了⼀项名 为“StackMapTable”的新属性,这项属性描述了⽅法体所有的基本块(Basic Block,指按照控制流拆分 的代码块)开始时本地变量表和操作栈应有的状态,在字节码验证期间,Java虚拟机就不需要根据程 序推导这些状态的合法性,只需要检查StackMapTable属性中的记录是否合法即可。这样就将字节码验证的类型推导转变为类型检查,从⽽节省了⼤量校验时间。理论上StackMapTable属性也存在错误或被 篡改的可能,所以是否有可能在恶意篡改了Code属性的同时,也⽣成相应的StackMapTable属性来骗过 虚拟机的类型校验,则是虚拟机设计者们需要仔细思考的问题。
JDK 6的HotSpot虚拟机中提供了-XX:-UseSplitVerifier选项来关闭掉这项优化,或者使⽤参数- XX:
+FailOverToOldVerifier 要求在类型校验失败的时候退回到旧的类型推导⽅式进⾏校验。⽽到了 JDK 7之后,尽管虚拟机中仍然保留着类型推导验证器的代码,但是对于主版本号⼤于50(对应JDK 6)的Class⽂件,使⽤类型检查来完成数据流分析校验则是唯⼀的选择,不允许再退回到原来的类型 推导的校验⽅式。
4.符号引⽤验证
最后⼀个阶段的校验⾏为发⽣在虚拟机将符号引⽤转化为直接引⽤ [3] 的时候,这个转化动作将在 连接的第三阶段——解析阶段中发⽣。符号引⽤验证可以看作是对类⾃⾝以外(常量池中的各种符号 引⽤)的各类信息进⾏匹配性校验,通俗来说就是,该类是否缺少或者被禁⽌访问它依赖的某些外部 类、⽅法、字段等资源。本阶段通常需要校验下列内容:
·符号引⽤中通过字符串描述的全限定名是否能到对应的类。
·在指定类中是否存在符合⽅法的字段描述符及简单名称所描述的⽅法和字段。
·符号引⽤中的类、字段、⽅法的可访问性(private、protected、public、<package>)是否可被当 前类访问。
·……
符号引⽤验证的主要⽬的是确保解析⾏为能正常执⾏,如果⽆法通过符号引⽤验证,Java虚拟机 将会抛出⼀个
java.lang.IncompatibleClassChangeError的⼦类异常,典型的如: java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
·直接引⽤(Direct References):直接引⽤是可以直接指向⽬标的指针、相对偏移量或者是⼀个能 间接定位到⽬标的句柄。直接引⽤是和虚拟机实现的内存布局直接相关的,同⼀个符号引⽤在不同虚 拟机实例上翻译出来的直接引⽤⼀般不会相同。如果有了直接引⽤,那引⽤的⽬标必定已经在虚拟机 的内存中存在。
《Java虚拟机规范》之中并未规定解析阶段发⽣的具体时间,只要求了在执⾏ane-warray、 checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invoke-special、 invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield和putstatic这17个⽤于 操作符号引⽤的字节码指令之前,先对它们所使⽤的符号引⽤进⾏解析。所以虚拟机实现可以根据需 要来⾃⾏判断,到底是在类被加载器加载时就对常量池中的符号引⽤进⾏解析,还是等到⼀个符号引 ⽤将要被使⽤前才去解析它。
类似地,对⽅法或者字段的访问,也会在解析阶段中对它们的可访问性(public、protected、 private、)进⾏检查,⾄于其中的约束规则已经是Java语⾔的基本常识,笔者就不再赘述了。
对同⼀个符号引⽤进⾏多次解析请求是很常见的事情,除invokedynamic指令以外,虚拟机实现可 以对第⼀次解析的结果进⾏缓存,譬如在运⾏时直接引⽤常量池中的记录,并把常量标识为已解析状 态,从⽽避免解析动作重复进⾏。⽆论是否真正执⾏了多次解析动作,Java虚拟机都需要保证的是在
同⼀个实体中,如果⼀个符号引⽤之前已经被成功解析过,那么后续的引⽤解析请求就应当⼀直能够 成功;同样地,如果第⼀次解析失败了,其他指令对这个符号的解析请求也应该收到相同的异常,哪 怕这个请求的符号在后来已成功加载进Java虚拟机内存之中。
不过对于invokedynamic指令,上⾯的规则就不成⽴了。当碰到某个前⾯已经由invokedynamic指令 触发过解析的符号引⽤时,并不意味着这个解析结果对于其他invokedynamic指令也同样⽣效。因为 invokedynamic指令的⽬的本来就是⽤于动态语⾔⽀持 [1] ,它对应的引⽤称为“动态调⽤点限定符 (Dynamically-Computed Call Site Specifier)”,这⾥“动态”的含义是指必须等到程序实际运⾏到这条指令时,解析动作才能进⾏。相对地,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶段,还没有开始执⾏代码时就提前进⾏解析。
解析动作主要针对类或接⼝、字段、类⽅法、接⼝⽅法、⽅法类型、⽅法句柄和调⽤点限定符这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种常量类型 [2] 。下⾯笔者将讲解
前4种引⽤的解析过程,对于后4种,它们都和动态语⾔⽀持密切相关,由于Java语⾔本⾝是⼀门静态类型语⾔,在没有讲解清楚 invokedynamic指令的语意之前,我们很难将它们直观地和现在的Java语⾔语法对应上,因此笔者将延 后到第8章介绍动态语⾔调⽤时⼀起分析讲解。
1.类或接⼝的解析
假设当前代码所处的类为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异常。
针对上⾯第3点访问权限验证,在JDK 9引⼊了模块化以后,⼀个public类型也不再意味着程序任 何位置都有它的访问权限,我们还必须检查模块间的访问权限。
如果我们说⼀个D拥有C的访问权限,那就意味着以下3条规则中⾄少有其中⼀条成⽴:
·被访问类C是public的,并且与访问类D处于同⼀个模块。
·被访问类C是public的,不与访问类D处于同⼀个模块,但是被访问类C的模块允许被访问类D的 模块进⾏访问。
·被访问类C不是public的,但是它与访问类D处于同⼀个包中。
在后续涉及可访问性时,都必须考虑模块间访问权限隔离的约束,即以上列举的3条规则,这些内 容在后⾯就不再复述了。
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论