我在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个显式和隐式定义的变量都需要1个时隙,4个时隙被保留,但只有2个被定义,以奇怪的顺序(地址0之前的地址2)和&#34;空洞&#34;它们之间。列表迭代器稍后写入此&#34; hole&#34;使用ASTORE 1而不首先声明此变量的类型。只有在此操作后才会出现堆栈映射框,但我不清楚为什么只有2个变量放入其中,因为后来使用了2个以上的变量。之后,使用ISTORE 3,int再次写入变量槽,没有任何声明。
此时看起来我需要完全忽略变量定义,并通过解释字节码来推断所有类型,运行JVM堆栈的模拟。
尝试了ASM EXPAND_FRAME选项,但它没用,只将单帧节点的类型更改为F_NEW,其余部分仍然与以前完全一样。
有人可以解释为什么我会看到这样一个奇怪的代码,除了编写自己的JVM解释器之外还有其他选择吗?
结论,基于所有答案(如果我错了,请再次纠正我):
变量定义仅用于将源变量名称/类型与在特定代码行访问的特定变量槽匹配,显然被JVM类验证程序和代码执行期间忽略。可以缺席或与实际字节码不匹配。
变量槽被视为另一个堆栈,虽然通过32位字索引访问,但只要使用匹配类型的加载和存储指令,它总是可以用不同的临时值覆盖它的内容。
堆栈帧节点包含从变量帧的开头到最后一个变量的变量列表,该变量将在后续代码中加载而不先存储。无论采用何种执行路径到达其标签,预期此分配映射都是相同的。它们还包含类似操作数堆栈的映射。它们的内容可以指定为相对于前一个堆栈帧节点的增量。
只有存在于线性代码序列中的变量才会出现在堆栈帧节点中,如果在较高的插槽地址处分配了较长生命周期的变量。
答案 0 :(得分:3)
LocalVariableTable
用于将源代码中的变量与方法字节码中的变量槽匹配。此可选属性主要用于调试器(用于打印变量的正确名称)。
正如您已经自己回答的那样,为了推断局部变量类型或表达式类型,您必须遍历字节码:从方法开始或从最近的堆栈映射。 StackMapTable
属性仅包含合并点处的堆栈映射。
答案 1 :(得分:2)
详细说明apangin的答案:你必须考虑你正在看的属性的目的。
LocalVariableTable
是为调试目的而添加的可选元数据。它允许调试器向程序员显示局部变量的值,包括它们的名称和源级别类型。但是,这样做的必然结果是编译器只发出源级变量的调试信息。插槽1用于for循环隐式生成的迭代器,因此没有合理的调试信息要发出。对于插槽3,这适用于您的a
变量。我不确定为什么没有添加,但可能是因为变量的范围在创建后立即结束。因此,变量a
的字节码范围为空。
对于StackMapTable
,堆栈映射旨在加速字节码验证。这样做的第一个推论是它只保存字节码级别类型信息 - 即没有泛型或类似的东西。第二个推论是它只保存协助验证者所需的信息。
在引入堆栈映射之前,验证程序可能会通过代码进行多次传递。每次代码中都有一个向后分支时,它必须返回并更新类型,这可能会更改进一步的推断类型等等,因此验证程序必须迭代直到收敛。
堆栈映射旨在允许验证程序从上到下一次验证方法字节码。因此,它需要在有跳转目标的地方明确指定类型。当字节码到达该位置时,它可以根据堆栈帧中的类型检查当前推断的类型,而不必一直回溯并重做事物。但是在代码的线性部分中间不需要堆栈帧,因为验证器的推理算法对此完全正常。
您遇到的最后一个问题是为什么堆栈框架中只列出了两个值。原因是为了减少空间,堆栈映射是delta编码的。有许多不同的帧类型,在通常情况下,您可以列出与前一帧的差异,而不是发出一个完整的帧,每次都列出所有变量和堆栈操作数的类型。
您发布的字节码中列出了两个堆栈映射帧。第一个是append
帧,这意味着操作数堆栈是空的,它与前一帧具有相同的局部性,除了1-3个额外的局部变量。在这种情况下,还有两个其他本地人,类型为List
和Iterator
。第二帧是chop
帧,这意味着操作数堆栈是空的,并且它具有与前一帧相同的本地,除了缺少最后1-3个本地。在这种情况下,由于迭代器不再在范围内,因此切断了一个局部。
答案 2 :(得分:2)
简短的回答是,如果你想知道每个代码位置的堆栈框架元素的类型,你确实需要编写某种解释器,尽管most of this work has already been done,但它仍然不足以恢复局部变量的源级别类型,根本没有通用解决方案。
正如其他答案所述,像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);
}
因为这些类型转换仅影响后续方法调用,但不会自行生成字节码指令,因为这些是扩展转换。
因此,无法从线性序列中的字节码精确恢复声明的源级变量类型,并且stackmap帧条目也没有用。它们的目的是帮助验证后续代码的正确性(可以通过不同的代码路径来实现),为此,它不需要声明所有现有元素。它只需声明合并点之前存在的元素,并在合并点之后实际使用。但这取决于编译器是否存在验证者实际不需要的条目(以及哪些条目)。