ASM пропускает классы, если COMPUTE_FRAMES установлен в ClassWriter

Я работаю над агентом Java, который запускается вместе с плагином maven-surfire. Агент должен иметь возможность внедрять вызовы методов с помощью библиотеки ASM в загруженные методы в трех разных точках: 1) в начале каждого метода; 2) В конце каждого метода; 3) На определенных линиях (см. ниже). Для этого я реализовал метод premain, добавляющий новый преобразователь в инструментарий Java. Затем метод преобразования создает новый ClassWriter и ClassVisitor (из библиотеки ASM) для каждого класса, который он должен преобразовать.

@Override
public void visitLineNumber(int line, Label start) {
    if(methodLines.first().equals(line)) {
        mv.visitMethodInsn(INVOKESTATIC, "de/ugoe/cs/listener/CallHelper", "raiseDepth", "()V", false);
    }

    if(mutationLines != null && mutationLines.contains(line)) {
        mv.visitLdcInsn(fqn);
        mv.visitLdcInsn(new Integer(line));
        mv.visitMethodInsn(INVOKESTATIC, "de/ugoe/cs/listener/CallHelper", "hitMutation", "(Ljava/lang/String;I)V", false);
    }

    mv.visitLineNumber(line, start);

    if(methodLines.last().equals(line)) {
        mv.visitMethodInsn(INVOKESTATIC, "de/ugoe/cs/listener/CallHelper", "lowerDepth", "()V", false);
    }
}

К сожалению, у меня с этим проблемы. Если установлен флаг COMPUTE_FRAMES для ClassWriter, я не получаю никаких ошибок, но некоторые классы пропускаются и не преобразуются агентом. После некоторых исследований я выяснил, что причиной этого является (скорее всего) метод getCommonSuperClass ClassWriter, который предварительно загружает класс.

Если я не устанавливаю флаг COMPUTE_FRAMES, я получаю Expected stackmap frame at this location ошибок, которые мне не удалось устранить.

У кого-нибудь есть решение этой проблемы?


person eftex    schedule 23.07.2018    source источник
comment
Нет другого хорошего решения, кроме как переопределить метод версией без загрузки классов и позволить ASM вычислять кадры. Однако на практике это довольно медленно и не всегда может быть выполнено, поскольку файлы классов могут даже не существовать во время выполнения или быть недоступными. Более сложный подход заключается в переназначении фреймов карты стека, как это делает адаптер Byte Buddy's Advice. Однако это работает только при добавлении кода в начале или в конце метода.   -  person Rafael Winterhalter    schedule 23.07.2018
comment
Спасибо за ваш ответ! Я почему-то ожидал, что это может быть непросто решить...   -  person eftex    schedule 24.07.2018
comment
Можете ли вы дать мне подсказку или источник информации о том, как написать такую ​​версию без загрузки классов? Я пытался что-то найти, но безуспешно.   -  person eftex    schedule 24.07.2018
comment
Вы можете запросить у загрузчика классов файлы .class. Затем вы можете запросить эти файлы классов с помощью ASM.   -  person Rafael Winterhalter    schedule 24.07.2018
comment
Вставка простых вызовов invokestatic не меняет структуру кода, следовательно, это должно быть возможно при сохранении исходных карт стека. Проблемы могут возникнуть из-за попытки вставки строк исходного кода. Между строками исходного кода и местоположениями байт-кода нет сопоставления 1:1, кроме того, ASM сообщает о строках, используя Labels, что может взаимодействовать с тем, как ASM сообщает о кадрах карты стека (то есть также с использованием Labels.   -  person Holger    schedule 25.07.2018


Ответы (1)


Как объясняется в этом ответе, подход ASM к вычислению (наиболее конкретного) общего суперкласса не обязательно воспроизводит кадры карты стека оригинальный класс. Он не только должен получить доступ к классам (которые вы могли бы обойти), но может получить доступ к классам, на которые исходный код никогда не ссылался, либо потому, что исходный код использовал более абстрактный тип или тип интерфейса, либо потому, что исходные кадры на самом деле удалил впоследствии неиспользуемое значение вместо объявления объединенного типа.

Таким образом, предпочтительным подходом является вычисление кадров карты стека на основе исходных кадров в соответствии с внесенными вами изменениями кода. Для вашего предполагаемого варианта использования это довольно просто, так как вы не меняете структуру ветвления кода, а просто вводите код, который оставляет состояние стека точно таким, каким оно было до вставленного фрагмента кода.

Так что в принципе можно было бы просто использовать оригинальные кадры. Для этого не указывайте COMPUTE_FRAMES вместо ClassWriter и не указывайте SKIP_FRAMES перед ClassReader. Вам нужно настроить максимальный размер стека только в том случае, если исходный размер был меньше двух, чтобы обеспечить место для аргументов вашего метода.

Фактические проблемы с вашим агентом возникают из-за попытки использовать строки исходного кода для определения местоположений кода для вставки вызовов. Чтобы проиллюстрировать это, рассмотрим следующий пример:

public class Example {
    public static void main(String[] args) {
        for(int i = 0; i < 10; i++) {
            System.out.println(i);
        }
    }
}

Я использую следующий код, чтобы показать, какие вызовы ASM будут сделаны вашему посетителю:

public static void main(String[] args) throws IOException {
    ClassReader cr = new ClassReader("Example");
    cr.accept(new ClassVisitor(Opcodes.ASM5) {
        @Override
        public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
            System.out.println(name+desc);
            return new PrintingVisitor();
        }
    }, 0);
}
static class PrintingVisitor extends MethodVisitor {
    final Map<Label,Integer> labels = new HashMap<>();

    public PrintingVisitor() {
        super(Opcodes.ASM5);
    }
    private String name(Label label) {
        return "label_"+labels.merge(label, labels.size(), (a,b) -> a);
    }
    @Override public void visitCode() {
        System.out.println("visitCode()");
    }
    @Override public void visitFrame(int type, int nLocal, Object[] local, int nStack, Object[] stack) {
        System.out.println("visitFrame()");
    }
    @Override public void visitLabel(Label label) {
        System.out.println("."+name(label));
    }
    @Override public void visitLineNumber(int line, Label start) {
        System.out.println(".line "+line+", "+name(start));
    }
    @Override public void visitJumpInsn(int opcode, Label label) {
        System.out.println(get(opcode)+" "+name(label));
    }
    @Override public void visitInsn(int opcode) {
        System.out.println(get(opcode));
    }
    @Override
    public void visitIincInsn(int var, int increment) {
        System.out.println("iinc "+var+", "+increment);
    }
    @Override public void visitEnd() {
        System.out.println();
    }
}
static String get(int opcode) {
    // for simplification, just the ones we need
    switch(opcode) {
        case Opcodes.RETURN: return "return";
        case Opcodes.ICONST_0: return "iconst_0";
        case Opcodes.ILOAD: return "iload";
        case Opcodes.IF_ICMPGE: return "if_icmpge";
        case Opcodes.GOTO: return "goto";
        default: return "<"+opcode+">";
    }
}

Что производит (при компиляции с javac):

main([Ljava/lang/String;)V
visitCode()
.label_0
.line 3, label_0
iconst_0
.label_1
visitFrame()
if_icmpge label_2
.label_3
.line 4, label_3
.label_4
.line 3, label_4
iinc 1, 1
goto label_1
.label_2
.line 6, label_2
visitFrame()
return
.label_5

Что демонстрирует:

  • «Первая строка», то есть строка 3, сообщается дважды, так как в конце цикла генерируется код, связанный с расположением оператора цикла for.
  • «Последняя строка», то есть строка 6, сообщается перед visitFrame(), которая описывает состояние стека цели перехода конца цикла. label_2 используется как для сообщения строки исходного кода, так и в качестве цели инструкции if_icmpge. При делегировании вызова visitLabel ClassWriter вы определяете цель ветвления, а для цели ветвления требуется фрейм карты стека, поэтому между вызовами visitLabel и visitFrame не должно быть кода, но вызов visitLineNumber, который вы использовали для вставки кода, делается прямо между ними.

Решение:

  • Внедряйте код прямо в вызов visitCode() для начала метода. Это происходит до того, как произойдет что-либо еще, и не будет конфликтовать с любой последующей операцией:

    @Override public void visitCode() {
        super.visitCode();
        mv.visitMethodInsn(INVOKESTATIC, "de/ugoe/cs/listener/CallHelper", "raiseDepth", "()V", false);
    }
    
  • Для внедрения кода в конце метода просто используйте точные инструкции, которые могут завершить метод, т.е.

    @Override public void visitInsn(int opcode) {
        switch(opcode) {
            case RETURN: case ARETURN: case IRETURN: case LRETURN: case FRETURN: case DRETURN:
            case ATHROW:
                mv.visitMethodInsn(INVOKESTATIC, "de/ugoe/cs/listener/CallHelper", "lowerDepth", "()V", false);
        }
        super.visitInsn(opcode);
    }
    

    Обратите внимание, что этого недостаточно, чтобы получить finally семантику вызова метода в каждом случае. Например. когда вызываемый метод генерирует исключение или среда выполнения генерирует его, например, при разыменовании null или делении на ноль, метод может не вызываться, но в исходном коде возникла проблема.

Для внедрения кода в произвольные строки исходного кода нет прямого решения. Как показано, строки исходного кода не сопоставляются 1:1 с местоположениями байт-кода, и сообщаемые местоположения могут находиться в местах, где внедрение невозможно. Гораздо лучше выбрать дополнительные критерии, такие как легко идентифицируемая конструкция кода, например, вызов известного метода, чтобы вставить его до или после него.

person Holger    schedule 25.07.2018