class文件格式中文文档
class文件格式中文文档
本博客仅对原版文档部分内容作翻译,作为笔者的阅读笔记。
4.10 Verification of class Files
4.10.2 Verification by Type Inference
不包含 StackMapTable 属性的类文件(该属性版本号必然为 49.0 或更低)必须通过类型推断进行验证。
4.10.2.1 类型推断验证过程
链接期间,验证器通过对类文件中每个方法执行数据流分析,检查其代码属性的代码数组。验证器确保在程序的任意给定点,无论通过何种代码路径到达该点,以下所有条件均成立:
操作数栈始终保持相同大小且包含相同类型的值。
除非已知局部变量包含正确类型的值,否则不得访问该变量。
方法调用必须使用正确参数。
字段赋值仅使用正确类型的值。
所有操作码在操作数堆栈和局部变量数组中均拥有类型匹配的参数。
出于效率考虑,某些本可由验证器执行的检测会被延迟至方法代码首次实际调用时进行。此举使验证器避免在非必要时加载类文件。
例如:若某方法调用返回类A实例的子方法,且该实例仅赋值给同类型字段时,验证器无需检查类A是否存在。但若赋值对象为类型B的字段,则必须加载A和B的定义以确保A是B的子类。
4.10.2.2 字节码验证器(The Bytecode Verifier)
每个方法的代码都会被独立验证。首先,构成代码的字节会被拆分为一系列指令,并将每条指令起始位置在代码数组中的索引存入一个数组中。然后,验证器第二次遍历代码并解析指令。在此过程中,会构建一个数据结构,用于保存方法中每条 Java 虚拟机指令的信息。指令的操作数(operands)会被解析,并检查每条指令以确保它是一个有效的实例。
字节码验证器会执行以下检查:
- 分支指令(branch)必须位于该方法代码数组的边界之内。
- 所有控制流指令的目标必须是某条指令的起始位置。在
wide指令的情况下,wide的操作码被视为指令的起始位置,而被wide修饰的操作码不被视为一条新指令的起始位置。跳转到指令中间是被禁止的。 - 任何指令都不能访问或修改一个索引大于或等于该方法所声明的局部变量数量的局部变量。
- 所有对常量池的引用都必须指向类型正确的常量池项。(例如,
getfield指令必须引用一个字段。) - 代码不能在某条指令的中间结束。
- 执行不能“跌落”(fall off)到代码末尾之外。
- 对于每个异常处理器(exception handler),其所保护代码的起始和结束位置必须位于某条指令的起始处;对于结束位置,则必须是代码末尾之后的紧接位置。起始位置必须在结束位置之前。异常处理器的代码必须从一条有效指令开始,并且不能从一个被
wide指令修饰的操作码开始。
对于方法中的每一条指令,验证器都会记录在执行该指令之前局部变量数组和操作数栈(operand stack)的内容。操作数栈需要知道栈高度以及栈中每个值的类型。对于每个局部变量,需要知道该局部变量的类型或其是否包含一个不可用(unusable)或未知(unknown,可能未初始化)的值。验证器在确定操作数栈和局部变量类型时,不需要区分不同的整型类型(例如 byte、short、char)。
接下来,初始化一个数据流分析器(data-flow analyzer)。对于方法中的第一条指令,局部变量数组表示方法描述符所指示的参数初始值。操作数栈最初为空。所有局部变量最初都包含一个合法值。对于尚未被检查的其他指令,目前还没有关于操作数栈或局部变量的信息。
最后,运行数据流分析器。对于每条指令,都维护一个“changed”(已改变)标志,用于指示是否需要重新检查该指令。最开始时,第一条指令的“changed”标志被置位。数据流分析器执行如下循环:
1. 选择指令
选择一条其“changed”标志被置位的 Java 虚拟机指令。如果不存在这样的指令,则方法已经成功通过验证。否则,清除所选指令的“changed”标志。
2. 模拟指令执行效果
通过以下方式模拟该指令对操作数栈和局部变量数组的影响:
- 如果指令从操作数栈中取值,确保栈中有足够数量的值,并且栈顶的值类型是合适的;否则,验证失败。
- 如果指令使用了某个局部变量,确保该局部变量包含一个类型正确的值;否则,验证失败。
- 如果指令向操作数栈中压入值,确保操作数栈中有足够空间容纳这些新值,并将相应的类型加入到模拟的操作数栈顶部。
- 如果指令修改了某个局部变量,记录该局部变量现在包含的新类型。
3. 确定后继指令
确定在当前指令之后可能执行的指令。后继指令可以是以下之一:
- 下一条指令(如果当前指令不是无条件控制转移指令,例如
goto、return或athrow)。如果存在从方法最后一条指令“跌落”执行的可能性,则验证失败。 - 条件或无条件分支,或
switch指令的目标指令。 - 该指令对应的任何异常处理器。
4. 合并执行状态
将当前指令执行结束时的操作数栈和局部变量数组状态合并到每个后继指令中,规则如下:
- 如果这是该后继指令第一次被访问,则将步骤 2 中计算出的操作数栈和局部变量值记录为该后继指令执行前的状态,并将该后继指令的“changed”标志置位。
- 如果该后继指令之前已经被访问过,则将步骤 2 中计算出的操作数栈和局部变量值合并到已有的值中。如果合并导致任何值发生变化,则将该后继指令的“changed”标志置位。
控制转移到异常处理器的特殊情况
在控制转移到异常处理器时:
- 记录一个单一对象,其类型由异常处理器指定,作为执行后继指令之前的操作数栈状态。操作数栈必须有足够空间容纳该单个值,就好像有一条指令将其压栈一样。
- 记录在步骤 2 执行之前的局部变量值,作为执行后继指令之前的局部变量数组状态。步骤 2 中计算出的局部变量值在此情况下是无关的。
5. 重复执行
返回步骤 1,继续循环。
操作数栈合并规则
在合并两个操作数栈时,每个栈中的值数量必须完全相同。然后,对两个栈中对应位置的值进行比较,并按以下规则计算合并后的值:
如果某个值是基本类型(primitive type),则对应的值必须是相同的基本类型;合并后的值即为该基本类型。
如果某个值是非数组引用类型,则对应的值必须是对第一个公共父类型(first common supertype)实例的引用(可以是数组或非数组)。合并后的值即为该引用类型。(由于
Object是所有类、接口和数组类型的父类,因此该引用类型总是存在。)例如,
Object和String可以合并,结果是Object。类似地,Object和String[]也可以合并,结果仍然是Object。甚至Object和int[]也可以合并,结果依然是Object。如果对应的值都是数组引用类型,则需要检查它们的维度:
- 如果两个数组具有相同的维度,则合并结果是一个数组引用,其元素类型是两个数组元素类型的第一个公共父类型。
- 如果其中一个或两个数组的元素类型是基本类型,则在计算公共父类型时使用
Object作为元素类型。
- 如果其中一个或两个数组的元素类型是基本类型,则在计算公共父类型时使用
- 如果两个数组的维度不同,则合并结果是一个对
Object、Cloneable或Serializable中某一个的引用,具体取决于它们的公共父类型。
例如,
Object[]和String[]可以合并,结果是Object[]。Object[]和String[][]可以合并,结果是Object。int[]和String[]可以合并,结果是Object。Object[][]和String[][]可以合并,结果是Object[][]。Object[][]和String[]可以合并,结果是Object。由于数组可以有不同的维度,
Object[]和String[][]不能合并为Object[];Object[][]和String[]也不能合并为Object[][]。在这两种情况下,结果都是Object。Cloneable[]和String[]可以合并,结果是Cloneable[]。
最后,Cloneable[][]和String[]可以合并,结果是Object。- 如果两个数组具有相同的维度,则合并结果是一个数组引用,其元素类型是两个数组元素类型的第一个公共父类型。
如果操作数栈无法合并,则该方法的验证失败。
局部变量数组的合并规则
在合并两个局部变量数组状态时,对应位置的局部变量会被逐一比较。合并后的局部变量值按照上述规则计算,但有一个例外:
- 对应的局部变量允许是不同的基本类型。在这种情况下,验证器会记录该局部变量包含一个不可用值(unusable value)。
如果数据流分析器在未报告任何验证失败的情况下结束,则该方法被类文件验证器(class file verifier)成功验证。
某些指令和数据类型会使数据流分析更加复杂。下面将更详细地讨论其中的一些。
4.10.2.3 long 与 double 类型的值(Values of Types long and double)
在验证过程中,long 和 double 类型的值会被特殊对待。
当一个 long 或 double 类型的值被存入索引为 n 的局部变量时,索引 n + 1 会被特殊标记,表示该位置已被索引 n 的值占用,不能再作为局部变量索引使用。原先位于索引 n + 1 的任何值都会变为不可用(unusable)。
当一个值被存入索引为 n 的局部变量时,验证器会检查索引 n - 1,以确定其是否为一个 long 或 double 类型值的索引。如果是,则索引 n - 1 处的局部变量会被标记为包含一个不可用值。由于索引 n 处的局部变量已经被覆盖,索引 n - 1 不可能再表示一个 long 或 double 类型的值。
在操作数栈(operand stack)上处理 long 和 double 类型的值则相对简单:验证器将它们视为栈上的单一值。例如,dadd 操作码(对两个 double 值求和)的验证代码只需检查栈顶的两个元素是否都是 double 类型即可。然而,在计算操作数栈深度时,long 和 double 类型的值占用两个栈单元(length two)。
对操作数栈进行操作的无类型指令(untyped instructions)必须将 long 和 double 类型视为原子(不可分割)值。例如,如果栈顶的值是一个 double,而验证器遇到诸如 pop 或 dup 这样的指令,则验证失败。此时必须使用 pop2 或 dup2 指令。
4.10.2.4 实例初始化方法与新创建的对象 (Instance Initialization Methods and Newly Created Objects)
创建一个新的类实例是一个多步骤过程。如下语句:
...
new myClass(i, j, k);可以由以下指令序列实现:
...
new #1 // 为 myClass 分配未初始化的空间
dup // 复制操作数栈顶的对象
iload_1 // 压入 i
iload_2 // 压入 j
iload_3 // 压入 k
invokespecial #5 // 调用 myClass.<init>该指令序列在操作数栈顶留下一个新创建并已初始化的对象。
(更多将 Java 代码编译为 Java 虚拟机指令集的示例,参见 §3 Compiling for the Java Virtual Machine。)
实例初始化方法(§2.9.1)将未初始化的对象作为参数传递给局部变量 0。在该方法中,myClass 或其直接父类的实例初始化方法会在该对象上被调用。该方法对该对象唯一允许执行的操作,是对 myClass 中声明的字段进行赋值。
在对实例方法进行数据流分析时,验证器会将局部变量 0 初始化为当前类的一个对象;而对于实例初始化方法,局部变量 0 则被初始化为一个表示“未初始化对象”的特殊类型。当在该对象上调用了合适的实例初始化方法(来自当前类或其直接父类)之后,验证器模型中的操作数栈和局部变量数组中,所有该特殊类型的出现都会被替换为当前类的类型。
验证器会拒绝以下代码:
- 在对象完成初始化之前就使用该对象;
- 对同一个对象执行多次初始化。
此外,验证器还会确保:在实例初始化方法中调用的方法,其每一个正常返回路径,都必须调用了该方法所在类或其直接父类的实例初始化方法。
类似地,当执行 Java 虚拟机指令 new 时,会在验证器的操作数栈模型中创建并压入一个特殊类型。该特殊类型记录了:
- 创建该类实例的指令位置;
- 被创建但尚未初始化的类实例的类型。
当在该未初始化类实例上调用其所属类中声明的实例初始化方法时,所有该特殊类型的出现都会被替换为该类的目标类型。随着数据流分析的推进,这种类型变化可能会传播到后续指令。
必须将指令编号作为该特殊类型的一部分进行存储,因为在同一时刻,操作数栈中可能存在多个尚未初始化的同类实例。例如,下面的 Java 虚拟机指令序列:
new InputStream(new Foo(), new InputStream("foo"))在同一时刻,操作数栈中可能存在两个尚未初始化的 InputStream 实例。当在某个类实例上调用实例初始化方法时,只有那些在操作数栈或局部变量数组中、与该类实例表示同一对象的特殊类型才会被替换。
4.10.2.5 异常与 finally(Exceptions and finally)
为了实现 try-finally 结构,针对 类文件版本号为 50.0 或更低 的 Java 编译器,可以结合异常处理机制以及两条特殊指令来实现:jsr(jump to subroutine,跳转到子例程)和 ret(return from subroutine,从子例程返回)。
finally 子句会被编译为该方法中的一个子例程(subroutine),其形式类似于异常处理器中的代码。当执行调用该子例程的 jsr 指令时,jsr 会将其返回地址(即 jsr 指令之后的那条指令的地址)作为一个 returnAddress 类型的值压入操作数栈。子例程中的代码会将该返回地址存入一个局部变量。在子例程的末尾,ret 指令会从该局部变量中取出返回地址,并将控制权转移到该地址所指向的指令。
控制流可以通过多种方式转移到 finally 子句(即调用 finally 子例程):
- 如果
try子句正常执行完成,则在继续求值下一个表达式之前,通过一条jsr指令调用finally子例程。 - 如果在
try子句中执行了break或continue,并将控制权转移到try子句之外,则会在跳转之前先执行一条jsr,调用finally子句的代码。 - 如果
try子句中执行了return,编译后的代码会执行以下步骤:- 将返回值(如果有)保存到一个局部变量中。
- 执行一条
jsr指令,跳转到finally子句的代码。 - 从
finally子句返回后,返回保存在局部变量中的值。
编译器还会设置一个特殊的异常处理器,用于捕获在 try 子句中抛出的任何异常。如果在 try 子句中抛出了异常,该异常处理器会执行以下操作:
- 将异常对象保存到一个局部变量中。
- 执行一条
jsr指令,跳转到finally子句。 - 从
finally子句返回后,重新抛出该异常。
关于
try-finally结构实现的更多信息,参见 §3.13。
finally 子句对验证器带来的问题
finally 子句的代码会给验证器带来一个特殊的问题。通常,如果某条指令可以通过多条路径到达,并且某个局部变量在这些路径上包含不兼容的值,那么该局部变量就会变为不可用(unusable)。然而,finally 子句可能会从多个不同的位置被调用,从而导致多种不同的执行环境:
- 从异常处理器调用
finally时,某个局部变量中可能保存的是异常对象。 - 为实现
return而调用finally时,某个局部变量中可能保存的是返回值。 - 从
try子句底部正常执行路径调用finally时,同一个局部变量中可能包含一个不确定的值(indeterminate value)。
finally 子句本身的代码可能能够通过验证,但在完成对 ret 指令所有后继路径的更新之后,验证器会发现:
原本异常处理器期望保存异常的局部变量,或者返回代码期望保存返回值的局部变量,现在却包含了一个不确定的值。
含 finally 子句代码的验证策略
对包含 finally 子句的代码进行验证是非常复杂的,其基本思想如下:
每条指令都会维护一个 到达该指令所需的
jsr目标列表。- 对于大多数代码,该列表为空。
- 对于
finally子句中的指令,该列表长度为 1。 - 对于多重嵌套的
finally代码(极少见),该列表长度可能大于 1。
对于每条指令以及到达该指令所需的每一条
jsr,都会维护一个向量,用于记录在执行该jsr指令期间被访问或修改的局部变量。当执行实现“从子例程返回”的
ret指令时,必须只有一个可能的子例程来源。来自两个不同子例程的执行路径不能合并到同一条ret指令上。在对
ret指令执行数据流分析时,会使用一种特殊的处理流程。由于验证器知道该指令必须返回到哪个子例程,它可以找到所有调用该子例程的jsr指令,并将ret指令执行时的操作数栈和局部变量数组状态,合并到这些jsr指令之后的指令中。
局部变量状态的合并规则(finally / ret 场景)
在合并局部变量的状态时,采用如下规则:
- 对于向量中标记为 在子例程中被访问或修改过 的每一个局部变量,使用该局部变量在执行
ret指令时的类型。 - 对于其他局部变量,使用该局部变量在执行
jsr指令之前的类型。