我正在借助ASM框架创建Java字节仪器工具工具,并且需要确定并可能更改方法的局部变量的类型。很快,我遇到了一个简单的情况,其中变量和堆栈地图节点看起来有些怪异,并且没有给我足够的有关所使用变量的信息:
public static void test() {
List l = new ArrayList();
for (Object i : l) {
int a = (int)i;
}
}
给出以下字节码(来自IDEA):
public static test()V
L0
LINENUMBER 42 L0
NEW java/util/ArrayList
DUP
INVOKESPECIAL java/util/ArrayList.<init> ()V
ASTORE 0
L1
LINENUMBER 43 L1
ALOAD 0
INVOKEINTERFACE java/util/List.iterator ()Ljava/util/Iterator;
ASTORE 1
L2
FRAME APPEND [java/util/List java/util/Iterator]
ALOAD 1
INVOKEINTERFACE java/util/Iterator.hasNext ()Z
IFEQ L3
ALOAD 1
INVOKEINTERFACE java/util/Iterator.next ()Ljava/lang/Object;
ASTORE 2
L4
LINENUMBER 44 L4
ALOAD 2
CHECKCAST java/lang/Integer
INVOKEVIRTUAL java/lang/Integer.intValue ()I
ISTORE 3
L5
LINENUMBER 45 L5
GOTO L2
L3
LINENUMBER 46 L3
FRAME CHOP 1
RETURN
L6
LOCALVARIABLE i Ljava/lang/Object; L4 L5 2
LOCALVARIABLE l Ljava/util/List; L1 L6 0
MAXSTACK = 2
MAXLOCALS = 4
正如人们所能看到的,所有4个明确和隐式定义的vars取1个插槽,保留4个插槽,但只有2个定义为奇怪的顺序(地址为0之前的地址2),并且它们之间有一个"孔"。列表迭代器后来用Astore 1写入此"孔",而无需首先声明此变量的类型。只有在出现此操作堆栈映射框架之后,我还不清楚为什么只放入2个变量,因为使用了2个变量。后来,使用ISTORE 3,INT再次写入一个可变插槽,没有任何声明。
在这一点上,我似乎需要完全忽略变量定义,并通过解释字节码,运行JVM堆栈的模拟来推断所有类型。
尝试过ASM Expand_frame选项,但它是没有用的,只有将单帧节点的类型更改为f_new,其余的仍然像以前一样被视为。
任何人都可以解释为什么我会看到如此奇怪的代码,如果除了写自己的JVM Inteptreter之外还有其他选择?
结论,根据所有答案(如果我错了,请再次纠正我):
变量定义仅适用于将源变量名称/类型与特定代码行访问的特定变量插槽匹配,显然是JVM类验证器和代码执行期间忽略的。可以不存在或不匹配实际的字节码。
可变插槽被视为另一个堆栈,尽管通过32位单词索引访问,并且只要您使用匹配的加载类型和存储指令,就可以始终用不同的临时性覆盖其内容。
>堆栈框架节点包含从变量帧开头到最后一个变量分配的变量列表,该变量将在后续代码中加载而无需先存储。无论采取哪种执行路径以达到标签,该分配图都将相同。它们还包含用于操作数堆栈的类似地图。它们的内容可以指定为相对于前面的堆栈框架节点的增量。
仅在代码线性序列中存在的变量,只有在较高插槽地址分配寿命较长的变量时,才会出现在堆栈帧节点中。
LocalVariableTable
用于匹配源代码中的变量与方法字节码中的变量插槽。此可选属性主要用于调试者(打印变量的正确名称)。
您已经回答了自己,为了推断本地变量类型或表达式类型,您必须通过bytecode进行迭代:从方法开始或从最近的堆栈映射中。StackMapTable
属性仅包含合并点的堆栈地图。
简短的答案是,如果您想知道每个代码位置的堆栈框架元素的类型,则确实需要编写某种解释器完成了,但是还不足以恢复本地变量的源级别类型,并且根本没有一般解决方案。
如其他答案中所述,诸如LocalVariableTable
之类的属性确实旨在帮助恢复局部变量的正式声明,例如调试时,但仅涵盖源代码中存在的变量(嗯,实际上是编译器的决定),并且不是强制性的。也不能保证正确,例如字节码转换工具可能在不更新这些调试属性的情况下更改了代码,但是JVM不在乎您不调试时。
在其他答案中也说,StackMapTable
属性仅旨在帮助字节码验证,而不是提供正式声明。它将在分支合并点上告诉堆栈框架状态,必要的。
因此,对于没有分支的线性代码序列,本地变量和操作数堆栈条目的类型仅由推理确定,但是这些推断的类型不能保证完全匹配正式声明的类型。
为了说明问题,以下无分支代码序列产生相同的字节:
CharSequence cs;
cs = "hello";
cs = CharBuffer.allocate(20);
{
String s = "hello";
}
{
CharBuffer cb = CharBuffer.allocate(20);
}
这是编译器的决定,将本地变量的插槽重复使用,以用于删除范围的变量,但所有相关编译器都可以。
进行验证,只有正确性很重要,因此,当将X
类型的值存储到本地变量插槽中时,然后读取它并访问成员Y.someMember
,则必须将X
分配给Y
,而不管本地变量是否已声明为类型实际上是Z
,是X
的超级类型,但Y
的子类型。
在没有调试属性的情况下,您可能会很想分析后续用途以猜测实际类型(我想这是大多数分解器所做的),例如以下代码
CharSequence cs;
cs = "hello";
cs.charAt(0);
cs = CharBuffer.allocate(20);
cs.charAt(0);
包含两个invokeinterface CharSequence.charAt
指令,表明该变量的实际类型可能是CharSequence
而不是String
或CharBuffer
,但是字节码仍然与例如,例如
{
String s = "hello";
((CharSequence)s).charAt(0);
}
{
CharBuffer cb = CharBuffer.allocate(20);
((CharSequence)cb).charAt(0);
}
由于这些类型的铸造仅影响后续方法调用,但不会自行生成字节码指令,因为这些指令正在扩大。
因此,不可能在线性序列中从字节码和堆栈示例框架条目中精确地恢复从字节码中声明的源级变量类型。他们的目的是帮助验证后续代码的正确性(可以通过不同的代码路径达到),为此,它不需要声明所有现有元素。它只需要声明合并点之前存在的元素,并在合并点之后实际使用。但这取决于编译器是否存在验证者实际上不需要的条目。
要详细说明Apangin的答案:您必须考虑要查看的属性的目的。
LocalVariableTable
是为调试目的而添加的可选元数据。这就是允许调试器向程序员(包括其名称和源级别类型)显示局部变量的值的原因。但是,推论的是,编译器仅发布源级别变量的调试信息。插槽1是针对您循环隐含生成的迭代器,因此没有明智的调试信息可以发出。至于插槽3,即适用于您的a
变量。我不确定为什么不添加它,但是可能是因为变量创建后立即结束。因此,变量a
的字节码范围为空。
至于StackMapTable
,堆栈地图旨在加快字节码验证。这样的第一个推论是它仅保存字节码级别的信息 - 即没有仿制药或类似的东西。第二个推论是它仅保留帮助验证者所需的信息。
在引入堆栈地图之前,验证者可能会通过代码进行多次通过。每次代码中都有一个向后分支,它必须返回并更新类型,这可能会改变进一步的推断类型等,因此验证者必须迭代直到收敛为止。
堆栈映射旨在允许验证者在从上到下的单个通过中验证方法字节码。因此,它需要在有跳跃目标的任何地方明确指定的类型。当字节码到达该位置时,它只能检查当前推断的类型,而不是堆栈框架中的类型,而不必一直回溯并重做事物。但是,由于验证者的推理算法为此效果很好。
您遇到的最后一个问题是为什么在堆栈框架中仅列出了两个值。原因是为了减少空间,堆栈地图是Delta编码的。有多种不同的帧类型,在常见情况下,您只能列出上一个帧的差异,而不是散发出每次列出所有变量和堆栈操作数的全帧。
您发布的字节码中列出了两个堆栈地图框架。第一个是append
帧,这意味着操作数堆栈是空的,并且其与先前的帧具有相同的本地框架,除了1-3个其他本地变量。在这种情况下,还有另外两个当地人,类型为List
和Iterator
。第二帧是chop
帧,这意味着操作数堆栈是空的,并且它具有与上一帧相同的当地人,除了丢失了最后的1-3个当地人。在这种情况下,由于迭代器不再处于范围,因此将一个本地切碎。