有什么方法可以从字节码重新生成堆栈图?

Any way to regenerate stackmap from byte code?

我有一个旧库(大约 2005 年),它执行字节码操作,但不涉及堆栈映射。因此,我的 jvm (java 8) 抱怨它们无效 classes。避免错误的唯一方法是 运行 带有 -noverify 的 jvm。但这对我来说不是一个长期的解决方案。

在已经生成 classes 之后,有什么办法可以重新生成堆栈映射吗?我看到 ClassWriter class 有一个选项可以重新生成堆栈映射,但我不确定如何读入一个字节 class 并重写一个新的。那可行吗?

至于如何阅读 class,你应该可以只使用 ClassReader.

至于关于自动将堆栈映射添加到旧 classes 的可行性的更普遍的问题,在大多数情况下,它是可能的。然而,在一些模糊的情况下这是不可能的,主要是因为推理验证器比堆栈图验证器更宽松。请注意,这些仅适用于将堆栈映射添加到从未有过的旧代码的情况。如果您正在修改现有的 Java 8 代码,您可以忽略所有这些。

首先是 jsrret 指令,它们只允许在 class 版本 <= 49 的文件中使用(对应于 Java 5)。如果您想使用它们移植代码,则必须重写代码以复制和内联所有子例程主体。

除此之外,还有更多的小问题。例如,推理验证器允许您自由混合布尔和字节数组(验证器认为它们是同一类型),但堆栈映射验证器将它们视为不同的类型。

另一个潜在的问题是,使用推理验证时,根本不会检查死代码,而堆栈图验证器仍然需要您为所有内容指定堆栈图。在这种情况下,修复很简单——删除所有死代码。

最后,有一个问题,堆栈图要求您在控制流中合并时预先指定类型的公共超classes,而对于推理验证,您不需要明确指定超类型。大多数时候,这无关紧要,因为您有一个已知的继承层次结构,但理论上可以从仅在运行时通过 ClassLoader 定义的 classes 继承。

当然,堆栈映射需要常量池中的相应条目,这意味着常量池中用于其他所有内容的 space 较少。如果您的 class 接近达到最大常量池大小,则可能无法添加堆栈映射。这种情况很少见,但自动生成的代码可能会发生。

P.S。也有朝另一个方向发展的可能性。如果您的代码不使用任何版本 51.052.0 特定功能(基本上只是 invokedynamic,又名 lambda),那么您可以将 class 文件版本设置为50.0,不再需要堆栈映射。当然,这是一种倒退的解决方案,并且随着未来 class 文件版本添加更多有吸引力的功能(例如 lambdas)而变得越来越困难。

当您检测没有堆栈映射的旧 classes 并保留它们的旧版本号时,不会有问题,因为它们将由 JVM 以与以前相同的方式处理,不需要堆栈映射。当然,这意味着你不能注入更新的字节码特性。

当您检测更新的 class 文件时,这些文件在转换前具有有效的堆栈图,您将不会 运行 陷入这些问题 。所以你可以使用 ASM 重新生成堆栈图:

byte[] bytecode = … // result of your instrumentation
ClassReader cr = new ClassReader(bytecode);
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
cr.accept(cw, ClassReader.SKIP_FRAMES);
bytecode = cw.toByteArray(); // with recalculated stack maps

访问者 API 的设计允许轻松链接 reader 与编写者,并且只添加代码来拦截您想要更改的工件。

请注意,由于我们知道我们将使用 ClassWriter.COMPUTE_FRAMES 从头开始​​重新生成堆栈映射帧,我们可以将 ClassReader.SKIP_FRAMES 传递给 reader 以告诉它不要处理我们无论如何都会忽略源帧。

当我们知道 class 结构不变时,还有另一种优化可能。我们可以将 ClassReader 传递给 ClassWriter 的构造函数,以从未更改的结构中获益,例如目标常量池将使用源常量池的副本进行初始化。但是,必须小心处理此选项。如果我们根本不拦截方法,它也会得到优化,即代码被完全复制,甚至没有重新计算堆栈帧。所以我们需要一个自定义方法访问者来假装代码可能会发生变化:

byte[] bytecode = … // result of your instrumentation
ClassReader cr = new ClassReader(bytecode);
// passing cr to ClassWriter to enable optimizations
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES);
cr.accept(new ClassVisitor(Opcodes.ASM5, cw) {
    @Override
    public MethodVisitor visitMethod(int access, String name, String desc,
                                     String signature, String[] exceptions) {
        MethodVisitor writer=super.visitMethod(access, name, desc, signature, exceptions);
        return new MethodVisitor(Opcodes.ASM5, writer) {
            // not changing anything, just preventing code specific optimizations
        };
    }
}, ClassReader.SKIP_FRAMES);
bytecode = cw.toByteArray(); // with recalculated stack maps

这样,可以将常量池等未更改的工件直接复制到目标字节码,同时仍然重新计算堆栈映射帧。

不过有一些注意事项。从头开始生成堆栈图意味着不利用任何关于原始代码结构或转换性质的知识。例如。编译器会知道局部变量声明的形式类型,而 ClassWriter 可能会看到不同的实际类型,它必须为其找到公共基类型。此搜索可能非常昂贵,导致 classes 的加载被延迟或什至在正常执行期间不被使用。结果类型甚至可能与原始代码中声明的通用类型不同。这将是一个正确的类型,但可能会再次更改结果代码中 classes 的使用。

如果您在不同的环境中执行检测,ASM 尝试加载 classes 以确定通用类型可能会失败。然后,您必须使用可以在该环境中执行操作的实现来覆盖 ClassWriter.getCommonSuperClass(…)。这也是添加优化的地方,如果您对代码有更多了解并且可以提供答案而无需通过类型层次结构进行昂贵的搜索。

一般来说,建议首先重构旧库以使用 ASM,而不是需要后续的调整步骤。如上所述,在启用优化的情况下使用 ClassReaderClassWriter 链执行代码转换时,ASM 将能够复制所有未更改的方法,包括它们的堆栈图,并且只重新计算实际更改的堆栈图方法。在上面的代码中,在后续步骤中进行重新计算,我们不得不禁用优化,因为我们不再知道实际更改了哪些方法。

下一个合乎逻辑的步骤是将堆栈图处理合并到检测中,因为关于实际转换的知识通常允许保留 99% 的现有帧并轻松调整其他帧,而不需要昂贵的重新计算从零开始。