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

Why Java compiler generates weird local vars & stack map frames and how can I use them to reliably determine variable types?

我正在借助 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 个,顺序很奇怪(地址 2 在地址 0 之前)并且之间有一个 "hole"他们。列表迭代器稍后使用 ASTORE 1 写入此 "hole",而无需先声明此变量的类型。只有在这个操作堆栈映射框架出现之后,但我不清楚为什么只将 2 个变量放入其中,因为后来使用了 2 个以上。后来在ISTORE 3中,int又被写入了一个变量槽,没有任何声明。

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

尝试了ASM EXPAND_FRAME选项,但没有用,只是将单帧节点的类型更改为F_NEW,其余仍然与之前完全相同。

任何人都可以解释为什么我会看到如此奇怪的代码,以及除了编写自己的 JVM 解释器之外我是否还有其他选择?

结论,基于所有答案(如有错误请再次指正):

变量定义仅用于将源变量 names/types 匹配到在特定代码行访问的特定变量槽,显然被 JVM class 验证程序和代码执行期间忽略。可以不存在或与实际字节码不匹配。

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

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

仅存在于线性代码序列中的变量将仅出现在堆栈帧节点中,前提是在更高的槽地址上分配了具有更长生命周期的变量。

LocalVariableTable用于将源代码中的变量匹配到方法字节码中的变量槽。这个可选属性主要用于调试器(打印变量的正确名称)。

正如您自己回答的那样,为了推断局部变量类型或表达式类型,您必须遍历字节码:从方法开始或从最近的堆栈映射开始。 StackMapTable 属性仅包含合并点处的堆栈映射。

详细说明apangin的回答:你必须考虑你正在查看的属性的目的。

LocalVariableTable 是为调试目的添加的可选元数据。它允许调试器向程序员显示局部变量的值,包括它们的名称和源代码级别类型。但是,这样做的必然结果是编译器只为 源代码级 变量发出调试信息。插槽 1 用于由您的 for 循环隐式生成的迭代器,因此不会发出任何合理的调试信息。至于插槽 3,那是你的 a 变量。我不确定为什么不添加它,但可能是因为变量的范围在创建后立即结束。因此,变量 a 的字节码范围为空。

至于StackMapTable,堆栈映射旨在加速字节码验证。第一个推论是它只包含字节码级别的类型信息——即没有泛型或类似的东西。第二个推论是它只持有协助验证者所需的信息。

在引入堆栈映射之前,验证器可能会对代码进行多次传递。每次代码中出现向后分支时,它都必须返回并更新类型,这可能会更改进一步推断的类型等,因此验证器必须迭代直到收敛。

堆栈映射旨在让验证器从上到下一次性验证方法字节码。因此,只要有跳转目标,就需要明确指定类型。当字节码到达该位置时,它可以根据堆栈帧中的类型检查当前推断的类型,而不必一直回溯并重做事情。但是在代码的线性部分中间不需要堆栈帧,因为验证者的推理算法可以完美地工作。

您的最后一个问题是为什么堆栈帧中只列出了两个值。原因是为了减少 space,堆栈映射是增量编码的。有许多不同的帧类型,在常见情况下,您可以只列出与前一帧的差异,而不是每次都发出一个完整的帧,列出所有变量和堆栈操作数的类型。

您发布的字节码中列出了两个堆栈映射框架。第一个是 append 帧,这意味着操作数堆栈是空的,它具有与前一帧相同的局部变量,除了 1-3 个额外的局部变量。在本例中,有两个额外的局部变量,类型为 ListIterator。第二帧是一个 chop 帧,这意味着操作数栈是空的,它与前一帧具有相同的局部变量,只是缺少最后 1-3 个局部变量。在这种情况下,一个局部被切断,因为迭代器不再在范围内。

简短的回答是,如果您想知道每个代码位置的堆栈帧元素的类型,您确实需要编写某种解释器,尽管 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,无论局部变量的声明类型实际上是否为 ZX 的超类型但 Y.

的子类型

在没有调试属性的情况下,您可能会忍不住分析后续使用以猜测实际类型(我想,这是大多数反编译器所做的),例如以下代码

CharSequence cs;
cs = "hello";
cs.charAt(0);
cs = CharBuffer.allocate(20);
cs.charAt(0);

包含两个 invokeinterface CharSequence.charAt 指令,表明变量的实际类型可能是 CharSequence 而不是 StringCharBuffer,但字节码仍然相同,例如

{
    String s = "hello";
    ((CharSequence)s).charAt(0);
}
{
    CharBuffer cb = CharBuffer.allocate(20);
    ((CharSequence)cb).charAt(0);
}

因为这些类型转换只会影响后续的方法调用,但不会自行生成字节码指令,因为这些是扩大转换。

所以不可能从线性序列的字节码中精确地恢复声明的源级变量类型,stackmap 帧条目也没有帮助。它们的目的是帮助验证后续代码的正确性(可以通过不同的代码路径到达),为此,它不需要声明所有现有元素。它只需要声明合并点之前存在的元素和合并点之后实际使用的元素。但这取决于编译器是否存在(以及哪些)验证者实际上不需要的条目。