Виртуальная машина Java использует концепцию промежуточного байт-кода для обеспечения переносимости между операционными системами и аппаратными платформами. Использование байт-кода позволяет отделить компилятор от среды выполнения и выполнять компиляцию с разных языков программирования для исполнения на JVM. В этой статье мы разберемся с внутренней организацией class-файлов и байт-кода в них и научимся работать с фреймворком ASM для исследования и программной генерации байт-кода для динамического определения новых классов или их компиляции из других языков программирования.

Java Virtual Machine (далее JVM) реализует совмещенную регистровую и стековую модель и байт-код представляет возможности для реализации полноценного ООП (создание и исследование объектов, вызова методов), описания арифметико-логических операций и ветвления, операций со стеком или локальными переменными, преобразования типов. Для символьной записи используется мнемоника, которая также включает в себя тип операндов:

  • *inc, *dec, *add, *sub, *div, *mul, *rem - арифметические операции (первая буква определяет тип операндов, i - integer, l - long, s - short, b - byte, c - character, f - float, d - double, a - reference)

  • <a>2<b> - преобразование типа данных из a в b (например, i2f - integer to float)

  • new - создание объекта

  • athrow - выбрасывание исключения

  • getstatic - получение ссылки на статический метод или поле

  • putfield - изменение значения атрибута объекта

  • invokevirtual - выполнение метода объекта

  • invokedynamic - динамическое выполнение метода (для языков с динамической типизацией)

  • invokespecial - запуск методов собственного объекта

  • invokeinterface - запуск метода через интерфейс (на объекте класса реализации интерфейса)

  • return - выход из метода

  • if*, goto - переход условный или безусловный

  • *load, *store, *const, dup - операции с локальными переменными или стеком

  • и многие другие

Инструкции могут содержать константные значения (например, адрес для перехода по goto/if, константа, адрес объекта или null). Арифметико-логические операции и вызовы функций выполняются с использованием стека, но для хранения промежуточных результатов могут использовать локальные переменные.

Файл .class кроме непосредственно байт-кода содержит информацию о модификаторах доступа и названии класса, ссылку на родительский класс, список реализованных интерфейсов, методы и свойства класса (включая статические). Ссылка на классы/интерфейсы осуществляется по полному имени (пакет + название) и передается для обнаружения специальному объекту ClassLoader, который по умолчанию ищет файлы в нескольких местоположениях и использует информацию из переменной окружения CLASSPATH (или аргумента командной строки java -cp). Мы не будем подробно разбирать байт-код, поскольку про это было уже написано несколько статей (например, эта), но больше сосредоточимся на анализе и генерации байт-кода изнутри Java-приложения.

Существует множество библиотек для работы с байт-кодом, мы поговорим про ObjectWeb ASM, которая используется во множестве проектов для генерации байт-кода (например, Groovy, Nashorn, Mockito, Gradle). Для использования ASM создадим простое приложение с двумя классами и несколькими арифметическими операциями и ветвлением.

package org.example;

import java.util.Random;
import java.util.random.RandomGenerator;

class Coin {
    String flip(String title) {
        Random r = Random.from(RandomGenerator.getDefault());
        if (r.nextBoolean()) {
            return title+" head";
        } else {
            return title+" tail";
        }
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("Tossing coin");
        Thread.sleep(1000);
        var coin = new Coin();
        System.out.println(coin.flip("Result is"));
    }
}

После сборки проекта изучим полученный class-файл:

javap -v build/classes/java/main/org/example/Main

Здесь мы сможем увидеть версию байт-кода (minor version / major version), флаги (модификаторы доступа для класса main), название класса и его родителя (родительским будет класс java.lang.Object), аннотации и и их аргументы, пул констант (сюда выполняются ссылки как из заголовка, так и из байт-кода), в котором определяются классы, названия и типы методов, любые константы (в т.ч. строки), ссылки на статические поля и методы в других классах (например, на System.out.println). Также ниже отображается мнемоническое представление байт-кода, а также таблицы (локальные переменные, исключения и т.д).

Байт-код для метода flip в классе Coin будет выглядеть следующим образом:

java.lang.String flip();
    descriptor: ()Ljava/lang/String;
    flags: (0x0000)
    Code:
      stack=1, locals=2, args_size=1
         0: invokestatic  #7                  // InterfaceMethod java/util/random/RandomGenerator.getDefault:()Ljava/util/random/RandomGenerator;
         3: invokestatic  #13                 // Method java/util/Random.from:(Ljava/util/random/RandomGenerator;)Ljava/util/Random;
         6: astore_1
         7: aload_1
         8: invokevirtual #19                 // Method java/util/Random.nextBoolean:()Z
        11: ifeq          17
        14: ldc           #23                 // String head
        16: areturn
        17: ldc           #25                 // String tail
        19: areturn

Здесь мы можем видеть запуск статических методов (результат первого сохраняется в стеке и извлекается как аргумент для второго). Далее полученный результат (ссылка на объект Random) сохраняется в локальной переменной и заново размещается на стеке, после чего выполняется вызов метода nextBoolean от экземпляра объекта класса Random. Результат (boolean) сохраняется в стеке и проверяется на true (ifeq), в случае успеха осуществляется переход на строку 17 (в стек загружается константа #25 из таблицы), при неудаче - продолжается выполнение со строки 14 (загружается константа #23). В любом случае полученное значение снимается со стека и возвращается как результат выполнения метода.

Теперь подключим библиотеку ASM и начнем с разбора class-файлов:

dependencies {
//другие зависимости
    implementation 'org.ow2.asm:asm-util:9.5'
    implementation 'org.ow2.asm:asm-tree:9.5'
    implementation 'org.ow2.asm:asm-analysis:9.5'
}

Разбор файла начинается с загрузки файла класса через org.objectweb.asm.ClassReader , в который передается байтовый массив с содержанием файла, поток или название класса (в последнем случае будет использоваться ClassLoader). Созданный объект позволяет выполнить запрос данных по самому классу (например, getClassName() возвращает полное название класса, где точки заменены на слэш, getAccess() возвращает битовую маску для модификаторов доступа, getSuperName() - название родительского класса, getInterfaces() - список реализуемых интерфейсов), получить константы (readInt(offset), readDouble(offset), readUTF8(offset, buffer) , readConst(offset, buffer) и т.д.), а также получить экземпляр класса getClass(), откуда уже можно получить информацию о классе с использовать механизмов рефлексии, например, getAnnotation(annotationClass), getField(name), getMethod(name, args).

Для исследования содержимого класса используется паттерн visitor и с использованием метода accept можно передать реализацию класса ClassVisitor с переопределением методов:

  • visitField - вызывается для каждого поля класса (получает информацию о модификаторах, название, тип, описание типов в виде строки, и значение по умолчанию)

  • visitMethod - вызывается для каждого метода (включая конструктор), получает тип результата, описание типов в виде строки и список исключений

  • visitAttribute - для каждого атрибута

  • visitAnnotation - для каждой обнаруженной аннотации

  • visitInnerClass - для каждого вложенного класса

  • visitEnd - вызывается в конце разбора класса

Например, разбор class-файла в нашем случае может выглядеть так:

package org.example;

import org.objectweb.asm.*;

import java.io.FileInputStream;
import java.io.IOException;

class AnalysisVisitor extends ClassVisitor {

    protected AnalysisVisitor() {
        super(Opcodes.ASM9);
    }

    @Override
    public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
        System.out.println("Visit field "+name+" Types "+descriptor);
        return super.visitField(access, name, descriptor, signature, value);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        System.out.println("Method "+name+ " Types "+descriptor);
        return super.visitMethod(access, name, descriptor, signature, exceptions);
    }

    @Override
    public void visitAttribute(Attribute attribute) {
        System.out.println("Attribute is "+attribute);
        super.visitAttribute(attribute);
    }

    @Override
    public void visitEnd() {
        System.out.println("Visit end");
        super.visitEnd();
    }
}

public class Analysis {
    public static void main(String[] args) throws IOException {
        var reader = new ClassReader(new FileInputStream("build/classes/java/main/org/example/Coin.class"));
        System.out.println("Processing "+reader.getClassName());
        reader.accept(new AnalysisVisitor(), ClassReader.EXPAND_FRAMES);
    }
}

Также для анализа можно использовать готовую реализацию Visitor-классов, например ClassNode, который дает обобщенную информацию о свойствах и методах класса и предоставляет доступ к байт-коду методов. Далее для преобразования в мнемоническое представление можно использовать класс Textifier, который используется совместно со специальной реализацией TraceMethodVisitor.

public class Analysis {
    public static void main(String[] args) throws IOException {
        var reader = new ClassReader(new FileInputStream("build/classes/java/main/org/example/Coin.class"));
        System.out.println("Processing "+reader.getClassName());
        var classNode = new ClassNode();
        reader.accept(classNode, ClassReader.EXPAND_FRAMES);
        for (MethodNode method : classNode.methods) {
            if (method.name.equals("flip")) {
                var textifier = new Textifier();
                var trackMethodVisitor = new TraceMethodVisitor(textifier);
                for (AbstractInsnNode instruction : method.instructions) {
                    instruction.accept(trackMethodVisitor);
                }
                System.out.println(textifier.text);
            }
        }
    }
}

Теперь, когда мы умеем анализировать существующий байт-код, попробуем динамически создать новый класс и метод в нем. Будем делать класс Calculator, в котором реализуется метод sum для сложения двух целых чисел (результатом будет тип int). Эквивалентный код на Java может выглядеть таким образом:

public class Calculator {
  int sum(int a, int b) {
    return a+b;
  }
}

Для генерации класса нужно будет добавить все необходимые свойства и методы (вместе с набором инструкций, составляющих программу в виде байт-кода). Обязательно также определить конструктор и вызвать конструктор родительского класса. При определении класса также всегда нужно указывать родительский класс (если никакой явно не определен, то java/lang/Object). Сначала создадим пустой класс (без методов) и попробуем обратиться к нему через стандартный ClassLoader:

        var writer = new ClassWriter(0);
        //создаем public class Calculator extends java.lang.Object (для Java 19)
        writer.visit(V19, ACC_PUBLIC + ACC_SUPER, "Calculator", null, "java/lang/Object", null);
        //создаем конструктор без аргументов
        var newConstructor = writer.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
        newConstructor.visitVarInsn(ALOAD, 0);
        //вызываем конструктор родительского класса (не интерфейс, поэтому false последний аргумент)
        //()V - сигнатура метода без аргументов и с типом результата void (V)
        newConstructor.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
        newConstructor.visitInsn(RETURN);
        //определяем размер стека и локальных переменных
        newConstructor.visitMaxs(1, 1);
        //завершаем создание конструктора
        newConstructor.visitEnd();
        var classFile = writer.toByteArray();
        var stream = new FileOutputStream("build/classes/java/main/Calculator.class");
        stream.write(classFile);
        stream.close();

        var calculator = Class.forName("Calculator");
        System.out.println("Calculator is "+calculator);

Теперь добавим функцию сложения. Поскольку здесь будут также использоваться локальные переменные (аргументы функции), то нужно будет дополнительно определить метки в байт-коде, для уточнения области видимости, и создать записи в таблице локальных переменных с указанием меток.

//создание конструктора
        var newMethod = writer.visitMethod(ACC_PUBLIC, "sum", "(II)I", null, null);
        var start = new Label();
        var end = new Label();
        //поставим метку (нужна для области видимости локальных переменных)
        newMethod.visitLabel(start);
        //положим два значения в стек из локальных переменных, сложим и вернем результат
        newMethod.visitVarInsn(ILOAD, 1);   //получение значения a
        newMethod.visitVarInsn(ILOAD, 2);   //получение значения b
        newMethod.visitInsn(IADD);
        newMethod.visitInsn(IRETURN);
        newMethod.visitLabel(end);
        //start - end определяет scope для доступности переменной
        newMethod.visitLocalVariable("a", "I", null, start, end, 1);
        newMethod.visitLocalVariable("b", "I", null, start, end, 2);
        //определим размеры стека и локальных переменных
        newMethod.visitMaxs(2, 3);  //стек из двух значений, локальных переменных тоже 2 + this
        //завершаем создание метода
        newMethod.visitEnd();

        var classFile = writer.toByteArray();
        var stream = new FileOutputStream("build/classes/java/main/Calculator.class");
        stream.write(classFile);
        stream.close();

        var calculator = Class.forName("Calculator");
        var calculatorInstance = calculator.getDeclaredConstructor().newInstance();
        System.out.println("Calculator is "+calculator);
        var method = calculator.getMethod("sum", int.class, int.class);
        var result = method.invoke(calculatorInstance, 2, 3);
        assert result instanceof Integer;
        assert (Integer) result == 5;

В этой части статьи мы рассмотрели создание простого байт-кода без выбрасывания исключений, без ветвлений и циклов и без использования статических методов. Во второй части статьи мы разберемся с тем, как описывать реализуемые интерфейсы, делать обработку ошибок и использовать возможности записей (record), которые появились в Java 14.

Исходные тексты приложения размещены в репозитории https://github.com/dzolotov/bytecode (ветка part1).

Завершить статью хочу полезной рекомендацией. Хочу порекомендовать вам бесплатный вебинар от коллег из OTUS на котором вы рассмотрите, что из себя представляет протокол http. А чтобы лучше закрепить материал, штатными средствами языка Java, напишите простейшие http-клиент и сервер на java.io.

Комментарии (1)


  1. SGordon123
    03.04.2023 14:37

    Как говорится любую программу можно сделать короче.. , я думал тут будет про то как можно выкинуть 1 ретурн и ссделать программу из 18 шагов... Это реально в жизни, или таким никто не парится?