为什么在使用 StackTraceElement 时 getLineNumber return -1

Why does getLineNumber return -1 when using StackTraceElement

我想在检测 java 字节码时获取当前代码行号。 Instrumentation是通过ASM实现的。在visitcode后面插入getLineNumber对应的字节码,return值为-1,但在其他位置插桩得到的return值正常

例如源码如下

public static int add(int a, int b){
        int sum = a + b;
        return sum;
    }

按照ASM的逻辑,获取行号信息的字节码应该放在add方法之后。 但是当我调用main方法中的函数时,得到的行号是-1

同时我也分析了插桩前后的汇编代码,如下

//this is before instrumentation
public static int add(int, int);
    Code:
       0: iload_0
       1: iload_1
       2: iadd
       3: istore_2
       4: iload_2
       5: ireturn
//this is after instrumentation
public static int add(int, int);
    Code:
       0: new           #33                 // class java/lang/StringBuilder
       3: dup
       4: invokespecial #34                 // Method java/lang/StringBuilder."<init>":()V
       7: ldc           #36                 // String _
       9: invokevirtual #40                 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      12: invokestatic  #46                 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
      15: invokevirtual #50                 // Method java/lang/Thread.getStackTrace:()[Ljava/lang/StackTraceElement;
      18: iconst_1
      19: aaload
      20: invokevirtual #56                 // Method java/lang/StackTraceElement.getLineNumber:()I
      23: invokevirtual #59                 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
      26: invokevirtual #63                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      29: invokestatic  #69                 // Method afljava/logger/Logger.writeToLogger:(Ljava/lang/String;)V
      32: iload_0
      33: iload_1
      34: iadd
      35: istore_2
      36: iload_2
      37: ireturn

如您所见,我不仅获得了行号,还获得了 class 名称和方法名称。其中正常获取class名称和方法名,获取行号为-1。

另外,只有在visitcode位置之后插入才会让行号为-1,在其他位置插入同样的字节码不会有这个问题。

这是我的检测代码的一部分

private void instrument(){
            mv.visitTypeInsn(Opcodes.NEW, "java/lang/StringBuilder");
            mv.visitInsn(Opcodes.DUP);

            mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false);
            mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/Thread", "currentThread", "()Ljava/lang/Thread;", false);
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Thread", "getName", "()Ljava/lang/String;", false);
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
            mv.visitLdcInsn("_" + classAndMethodName + "_");

            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
            mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/Thread", "currentThread", "()Ljava/lang/Thread;", false);
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Thread", "getStackTrace", "()[Ljava/lang/StackTraceElement;", false);
            mv.visitInsn(Opcodes.ICONST_1);
            mv.visitInsn(Opcodes.AALOAD);
            
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StackTraceElement", "getLineNumber", "()I", false);
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(I)Ljava/lang/StringBuilder;", false);
            mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);
            mv.visitMethodInsn(Opcodes.INVOKESTATIC, "afljava/logger/Logger", "writeToLogger", "(Ljava/lang/String;)V", false);
        }

        @Override
        public void visitCode() {
            super.visitCode();
            instrument();
        }

像 Holger 的代码一样,我使用 visitcode 插入代码。

行号由 LineNumberTable Attribute 给出,它将字节码位置映射到源代码行。当您使用 ASM 库转换代码时,它会注意调整代码位置以反映更改。

这意味着当您在任何原始代码之前注入代码时,与行号关联的第一个代码的位置也会进行调整,因此您的新代码不会被行号覆盖。

您可以在通过 visitLineNumber 报告第一行号后注入代码,而不是在 visitCode 上注入代码。在最好的情况下,这仍然在任何可执行代码之前(如果合成代码已经通过其他方式注入,它可能不会)。

这样,新代码就会与第一个记录的行号相关联。但是,您不需要处理堆栈跟踪来重构此信息,因为在代码注入的这一点上已经知道了。由于 class 和方法名称也是已知的,因此甚至不需要生成字符串连接代码。您可以预先 assemble 字符串。

package com.example;

import java.lang.invoke.MethodHandles;

import org.objectweb.asm.*;

public class AsmExample {
    static class Test {
        public static int add(int a, int b){
            int sum = a + b;
            return sum;
        }
    }

    public static void main(String[] args) throws Exception {
        ClassReader cr = new ClassReader(AsmExample.class.getName()+"$Test");
        ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS);
        cr.accept(new ClassVisitor(Opcodes.ASM9, cw) {
            String className;
            @Override
            public void visit(int ver,
                int acc, String name, String sig, String superName, String[] ifs) {

                super.visit(ver, acc, name, sig, superName, ifs);
                className = name.replace('/', '.');
            }
            @Override
            public MethodVisitor visitMethod(
                int acc, String name, String desc, String sig, String[] ex) {

                MethodVisitor mv = super.visitMethod(acc, name, desc, sig, ex);
                if(name.equals("add")) mv = new Injector(mv, className + '_' + name);
                return mv;
            }
        }, 0);

        MethodHandles.lookup().defineClass(cw.toByteArray());

        System.out.println("return value: " + Test.add(30, 12));
    }

    static class Injector extends MethodVisitor {
        private final String classAndMethodName;
        private boolean logStatementAdded;

        public Injector(MethodVisitor methodVisitor, String classAndMethod) {
            super(Opcodes.ASM9, methodVisitor);
            classAndMethodName = classAndMethod;
        }

        @Override
        public void visitLineNumber(int line, Label start) {
            super.visitLineNumber(line, start);
            if(!logStatementAdded) {
                logStatementAdded = true;
                visitFieldInsn(Opcodes.GETSTATIC,
                    "java/lang/System", "out", "Ljava/io/PrintStream;");
                visitLdcInsn(classAndMethodName + "_" + line);
                visitMethodInsn(Opcodes.INVOKEVIRTUAL,
                    "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
            }
        }
    }
}
com.example.AsmExample$Test_add_10
return value: 42

我使用了一个简单的打印语句而不是你的记录器,但是这个例子应该很容易适应。


作为替代方案,如果您想尽可能保持原始逻辑,您可以只更改第一个报告的行号关联的字节码位置,以覆盖您的注入代码:

static class Injector extends MethodVisitor {
    private final String classAndMethodName;
    Label newStart = new Label();

    public Injector(MethodVisitor methodVisitor, String classAndMethod) {
        super(Opcodes.ASM9, methodVisitor);
        classAndMethodName = classAndMethod;
    }

    @Override
    public void visitCode() {
        super.visitCode();
        visitLabel(newStart);
        instrument();
    }

    @Override
    public void visitLineNumber(int line, Label start) {
        if(newStart != null) {
            start = newStart;
            newStart = null;
        }
        super.visitLineNumber(line, start);
    }

    …

请记住,为代码位置报告的行号与所有后续指令相关联,直到报告下一个行号。虽然 ASM 将按照代码位置的顺序调用访问者方法,但我们在调用 class 编写器时不需要那么严格。

所以我们可以通过在 instrument(); 之前调用 visitLabel(newStart); 来将 Label 与方法的开头关联起来,而无需知道行号。到第一次调用 visitLineNumber 时,我们将表示方法原始开始的标签 start 替换为表示新开始的新标签。 ASM 不介意我们在 instrument(); 之前没有调用 visitLineNumber,因为只有与 Label 关联的代码位置才重要。