编程语言
首页 > 编程语言> > 为什么Java编译器生成奇怪的本地变量和堆栈映射帧?如何使用它们可靠地确定变量类型?

为什么Java编译器生成奇怪的本地变量和堆栈映射帧?如何使用它们可靠地确定变量类型?

作者:互联网

我在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)和它们之间的“漏洞”.稍后使用ASTORE 1将List迭代器写入此“漏洞”,而不首先声明此变量的类型.只有在此操作后才会出现堆栈映射框,但我不清楚为什么只有2个变量放入其中,因为后来使用了2个以上的变量.稍后,使用ISTORE 3,int再次写入变量槽,没有任何声明.

此时看起来我需要完全忽略变量定义,并通过解释字节码来推断所有类型,运行JVM堆栈的模拟.

尝试了ASM EXPAND_FRAME选项,但它没用,仅将单帧节点的类型更改为F_NEW,其余部分仍然与以前完全相同.

任何人都可以解释为什么我会看到这样一个奇怪的代码,除了编写自己的JVM解释器之外还有其他选择吗?

结论,基于所有答案(如果我错了,请再次纠正我):

变量定义仅用于将源变量名称/类型匹配到在特定代码行访问的特定变量槽,显然是由JVM类验证程序和代码执行期间忽略的.可能缺席或与实际字节码不匹配.

变量槽被视为另一个堆栈,虽然通过32位字索引访问,并且只要您使用匹配类型的加载和存储指令,它总是可以用不同的临时值覆盖其内容.

堆栈帧节点包含从变量帧的开头分配到最后一个变量的变量列表,该变量将在后续代码中加载而不先存储.无论采用何种执行路径到达其标签,预期此分配映射都是相同的.它们还包含类似操作数堆栈的映射.它们的内容可以指定为相对于前一个堆栈帧节点的增量.

仅存在于线性代码序列中的变量将仅出现在堆栈帧节点中,如果存在在较高的插槽地址处分配的具有较长寿命的变量.

解决方法:

简短的回答是,如果你想知道每个代码位置的堆栈帧元素的类型,你确实需要编写某种解释器,尽管是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帧条目也没有用.它们的目的是帮助验证后续代码的正确性(可以通过不同的代码路径来实现),为此,它不需要声明所有现有元素.它只需声明合并点之前存在的元素,并在合并点之后实际使用.但这取决于编译器是否存在验证者实际不需要的条目(以及哪些条目).

标签:stackframe,java,jvm,bytecode
来源: https://codeday.me/bug/20190727/1550717.html