Многие, возможно, думают, что работа с байт-кодом Java (будь то чтение или, тем более, генерация) — это какая-то особенная магия, доступная только продвинутым разработчикам с особенно крутым опытом. На самом деле, я считаю такую точку зрения ошибочной. JVM устроена гораздо проще, чем CPU; она оперирует такими высокоуровневыми понятиями как классы, интерфейсы, методы, а не просто лопатит байты в памяти. В отличие от CPU, который легко уронить криво сгенерированным машинным кодом, JVM заботливо отверифицирует любой байт-код и в общем не даст выстрелить в ногу.

Но с чего начать погружение в байт-кодную магию? В сети есть некоторое количество туториалов по этому вопросу. Как мне кажется, они либо показывают слишком простые случаи, от которых непонятно, как перейти к чему-то более интересному, либо очень основательные и требуют вникать в теорию, собирать целиком картину в голове по кусочкам. Я хотел бы попробовать внести свой вклад в эту тему — надеюсь, у меня получится показать, как можно побороть первый страх и написать что-то похожее на реалистичный сценарий без особого вникания в теорию на первом этапе.

Весь приведённый код доступен в моём репозитории.

Задача

Я вдохновился книгой Бьёрна Страуструпа, по которой лет 20 назад изучал C++. В одной из первых глав в качестве задачи для введения в язык предлагается написать калькулятор выражений. Я же предлагаю не вычислять выражения, а генерировать байт-код, который вычисляет выражения.

Итак, формулировка: необходимо написать метод, которые принимает на вход строку с математическими выражениями и выдаёт на выходе экземпляр такого интерфейса:

public interface Expression {
  double evaluate(Function<String, Double> inputs);
}

Выражения в списке разделены точкой с запятой (;), метод evaluate возвращает результат вычисления последнего из выражений. Выражения определим так:

  1. Число (например, 2, 42, 3.14) — это выражение.

  2. Идентификатор (например, foo, pi, myVar_1) — это выражение. Значение по-умолчанию для переменной вычисляется с помощью вызова inputs.apply(id).

  3. Если A и B — выражения, то A + B, A - B, A * B, A / B, -A, (A) — так же выражения

  4. Если A — это идентификатор, и B — это выражение, то A = B — так же выражение

Генерируем класс

Для начала напишем генератор класса, реализующего интерфейс Expression. Для этого вначале нужно подключить библиотеку ASM (кстати, документация по ней — это отличный мануал ещё и по байт-коду). В Gradle это может выглядеть так:

dependencies {
  implementation "org.ow2.asm:asm:9.5"
  implementation "org.ow2.asm:asm-util:9.5"
}

Теперь напишем заготовку нашего генератора:

public class ClassGenerator {
  // Имя генерируемого класса. В принципе может быть любым.
  public static final String CLASS_NAME = ClassGenerator.class.getName() 
      + "$Generated";
  
  // Имя сгенерированного класса, в терминах JVM.
  // В JVM в качестве разделителя пакетов используется / вместо .
  private static final String CLASS_INTERNAL_NAME = 
      CLASS_NAME.replace('.', '/');

  public byte[] generate(String expr) {
    // Точка входа в ASM - ClassWriter. 
    // Собственно, он и генерирует байт-код.  
    var cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES 
        | ClassWriter.COMPUTE_MAXS);
    
    // Добавляем вывод дизассемблированного байт-кода в консоль
    ClassVisitor cv = cw;
    if (Boolean.getBoolean("konsoletyper.exprbc.log")) {
      cv = new TraceClassVisitor(cv, new PrintWriter(System.out));
    }

    // Создаём класс
    cv.visit(
      Opcodes.V11,         // Версия JVM
      Opcodes.ACC_PUBLIC,  // модификаторы (public)
      CLASS_INTERNAL_NAME, // имя класса (в терминах JVM)
      null,                // generic сигнатура, нам не нужна
      Type.getInternalName(Object.class),
        // имя родительского класса
        // в Java можно не указывать extends Object,
        // однако в JVM нужно всё указывать явно
        // и предельно скрупулёзно
      new String[] { Type.getInternalName(Expression.class) }
        // имена реализуемых интерфейсов
    );

    // Создаём конструктор
    // В Java тривиальный конструктор необязателен,
    // однако в JVM нужно всё описывать явно
    generateEmptyConstructor(cv);
    
    // Создаём метод evaluate
    generateWorkerMethod(cv, expr);

    // Заканчиваем создание класса и генерируем байт-код
    cv.visitEnd();
    return cw.toByteArray();
  }
}

Поясню пару моментов.

Во-первых, мы передаём некие параметры в конструктор ClassWriter. Вот зачем это нужно. Дело в том, что в байт-коде методов обязательно указывать размер стека метода и количество слотов под локальные переменные. Так же с некоторых пор необходимо стало добавлять информацию о фреймах — типах переменных и значений на стеке в некоторых точках байт-кода, эта информация позволяет ускорить JIT-компиляцию (в давние времена фреймы были опциональными). Суровые компиляторщики скорее всего всю эту информацию вычислят самостоятельно, но и в образовательных целях, и во многих практических задачах проще поручить эту задачу ASM.

Во-вторых, для диагностики удобно добавлять распечатку сгенерированного байт-кода. Для этого мы используем некоторый ClassVisitor. Тут надо немного рассказать про архитектуру ASM. В нём чтение и запись байт-кода производятся ClassVisitor-ами (а так же MethodVisitor, FieldVisitor и т.д.). При чтении класса мы создаём ClassReader и передаём туда ClassVisitor, который получает от ClassReader-а последовательность команд. ClassWriter наследуется от ClassVisitor и для создания класса нужно вызывать всё те же методы, что вызывает ClassReader при чтении. Так можно, например, трансформировать классы, вставив между reader-ом и writer-ом некоторую логику, однако даже если мы генерируем класс из воздуха, можно пользоваться преимуществами такой архитектуры. Например, в составе ASM есть TraceClassVisitor, которые все вызовы выводит в консоль, чем мы и пользуемся, завернув в него наш ClassWriter.

Итак, теперь напишем генератор пустого конструктора:

private void generateEmptyConstructor(ClassVisitor cv) {
  // Создаём метод
  var mv = cv.visitMethod(
    Opcodes.ACC_PUBLIC,   // модификаторы
    "<init>",             // имя метода
    Type.getMethodDescriptor(Type.VOID_TYPE),
      // дескриптор метода - типы параметров и возвращаемого значения
    null,                 // сигнатура generic метода
                          // (для нас не актуально)
    null                  // выбрасываемые исключения
  );
  mv.visitCode();

  // Кладём на стек локальную переменную 0 
  // (всегда соответствует this для не статических методов)
  mv.visitVarInsn(Opcodes.ALOAD, 0);
  
  // Вызываем метод Object.<init>, т.е. конструктор
  // родительского класса
  // (аналог super() в Java - мы же помним, что в байт-коде
  // всё прописывается явно)
  mv.visitMethodInsn(
    Opcodes.INVOKESPECIAL,
    Type.getInternalName(Object.class),
      // у какого класса вызываем метод -
      // да, в байт-коде нужно быть настолько
      // скрупулёзным
    "<init>",
    Type.getMethodDescriptor(Type.VOID_TYPE),
      // дескриптор вызываемого метода
    false  // вызов *не* у интерфейса
  );
  
  // Явно пишем return (хотя в Java он и не обязателен)
  mv.visitInsn(Opcodes.RETURN);

  // Тут прописываем размер стека и количество слотов 
  // под локальные переменные, про которые упоминалось
  // выше. Мы попросили ASM вычислять их автоматически,
  // но вызвать visitMaxs мы обязаны, передав им 0
  mv.visitMaxs(0, 0);
  mv.visitEnd();
}

Обратите внимание, что конструктор в JVM называется не так же как класс (как это сделано в Java), а имеет специальное имя <init>. Для методов необходимо указывать дескриптор — тип возвращаемого значения и аргументов. JVM определяет специальный синтаксис дескрипторов, в и нашем случае дескриптор имеет вид ()V. Однако, можно попросить ASM сгенерировать дескриптор за нас.

JVM — это стековая машина. Любая операция берёт некоторое количество значений со стека, производит над ними действие и кладёт результат на стек. Например, арифметические команды IADD, ISUB и прочие, снимают с вершины стека два значения и кладут результат операции на вершину стека. В данном случае мы вызываем не статический метод и необходимо в качестве аргумента инструкции INVOKESPECIAL указать получатель (receiver) метода, т.е. то, что стоит слева от точки (хотя в случае c super() синтаксис с точкой не предусмотрен).

Кстати, об INVOKESPECIAL. Есть три инструкции для вызова метода: INVOKESPECIAL, INVOKEVIRTUAL и INVOKEINTERFACE. INVOKESPECIAL — это вызов некоторого метода с заранее известной реализацией, т.е. которую не нужно искать на рантайме. В противовес INVOKEVIRTUAL, когда необходимо нужный метод искать в виртуальной таблице. Такое бывает в следующих случаях: вызов конструктора, вызов super-метода, вызов приватного метода.

Наконец, напишем генератор метода evaluate:

private void generateWorkerMethod(ClassVisitor cv, String expr) {
  var mv = cv.visitMethod(
      Opcodes.ACC_PUBLIC,
      "evaluate",
      Type.getMethodDescriptor(Type.DOUBLE_TYPE, 
          Type.getType(Function.class)), 
      null, null
  );
  mv.visitCode();

  var generator = new CodeGenerator(mv);
  var lexer = new Lexer(expr);
  var parser = new Parser(lexer, generator);
  parser.parse();
  mv.visitInsn(Opcodes.DRETURN);

  mv.visitMaxs(0, 0);
  mv.visitEnd();
}

всю основную работу делает класс CodeGenerator, которым управляет Parser. В конце своей работы CodeGenerator оставляет одно значение на стеке — результат вычисления последнего выражения. Его и возвращает инструкция DRETURN.

Пишем генератор байт-кода выражений

Парсер отдаёт результат во внешний мир через следующий интерфейс:

public interface ParserConsumer {
  void number(double value);
  void identifier(String id);
  void assignment(String id);
  void add();
  void subtract();
  void multiply();
  void divide();
  void negate();
  void statement();
}

Для выражений парсер вначале уведомляет о парсинге операндов, а затем — о самой операции. Например, для выражения 2 + 3 последовательность будет такой:

  1. number(2)

  2. number(3)

  3. add()

Наш генератор реализует этот интерфейс:

public class CodeGenerator implements ParserConsumer {
  private final MethodVisitor mv;

  public CodeGenerator(MethodVisitor mv) {
    this.mv = mv;
  }
}

Генерация кода для числа выглядит так:

@Override
public void number(double value) {
  mv.visitLdcInsn(value);
}

Имя инструкции LDC расшифровывается как "load constant". Думаю, тут всё понятно. Не менее тривиальной выглядит генерация операций:

@Override
public void add() {
  mv.visitInsn(Opcodes.DADD);
}
@Override
public void subtract() {
  mv.visitInsn(Opcodes.DSUB);
}
@Override
public void multiply() {
  mv.visitInsn(Opcodes.DMUL);
}
@Override
public void divide() {
  mv.visitInsn(Opcodes.DDIV);
}
@Override
public void negate() {
  mv.visitInsn(Opcodes.DNEG);
}

Наконец, ещё одной тривиальной операцией является statement, отделяющее выражения друг от друга:

@Override
public void statement() {
  mv.visitInsn(Opcodes.POP2);
}

Она снимает выражение со стека. Смысл в этом вот какой: каждое выражение оставляет на стеке одно значение — результат вычисления выражения. Однако, функция возвращает только значение последнего выражения. Остальные будут скорее всего присваиваниями, их значение можно проигнорировать. Почему же POP2? Дело в том, что в JVM значения типа long и double занимают по два "слота" в стеке и в локальных переменных.

Теперь возьмёмся за переменные. Генератор присвоений реализуем следующим образом:

private final Map<String, Integer> variables = new HashMap<>();
private int lastVariableIndex = 2;

@Override
public void assignment(String id) {
  var index = variables.computeIfAbsent(id, k -> introduceVariable());
  // Пишем значение со стека в локальную переменную
  mv.visitVarInsn(Opcodes.DSTORE, index);
  // Читаем значение локальной переменной в стек
  mv.visitVarInsn(Opcodes.DLOAD, index);
}

private int introduceVariable() {
  var result = lastVariableIndex;
  lastVariableIndex += 2;
  return result;
}

Т.е. выделяем по два слота в локальных переменных для каждой новой переменной и пишем в локальную переменную значение со стека, а затем снова кладём только что сохранённое значение на стек — это необходимо для того, чтобы воспроизвести семантику присвоения в C/C++, т.е. оно тоже является выражением, результатом которого является значение, присвоенное переменной.

Наконец, сгенерируем чтение переменной:

@Override
public void identifier(String id) {
  var index = variables.computeIfAbsent(id, k -> {
    var newIndex = introduceVariable();
    // Если переменная ещё не упоминалась, пытаемся
    // прочитать её из inputs
    getAndCacheVariable(newIndex, k);
    return newIndex;
  });
  mv.visitVarInsn(Opcodes.DLOAD, index);
}

private void getAndCacheVariable(int index, String id) {
  // переменная с индексом 1 - это параметр inputs
  mv.visitVarInsn(Opcodes.ALOAD, 1);
  mv.visitLdcInsn(id);
  mv.visitMethodInsn(
    Opcodes.INVOKEINTERFACE,
    // не забываем явно прописать тип receiver-а - Function
    Type.getInternalName(Function.class),
    "apply",
    // erasure в действии. После компиляции от generics
    // не остаётся и следа. Теперь любая Function<T,R>
    // "превращается" в Function<Object,Object>
    Type.getMethodDescriptor(Type.getType(Object.class),
        Type.getType(Object.class)),
    true  
  );

  // Если результат null - кидаем исключение
  var continueLabel = new Label();
  // дублируем значение на стеке, т.к. вначале первую копию 
  // поглощает IFNONNULL, а вторую - логика ниже
  mv.visitInsn(Opcodes.DUP);
  mv.visitJumpInsn(Opcodes.IFNONNULL, continueLabel);
  reportUndefinedVariable(id);

  mv.visitLabel(continueLabel);
  // erasure в действии - тип у нас стёрся,
  // поэтому нужно вставить явный cast к DOUBLE
  mv.visitTypeInsn(Opcodes.CHECKCAST, Type.getInternalName(Double.class));
  // так же с присущей JVM скрупулёзностью делаем явный unboxing
  mv.visitMethodInsn(
    Opcodes.INVOKEVIRTUAL,
    Type.getInternalName(Double.class),
    "doubleValue",
    Type.getMethodDescriptor(Type.DOUBLE_TYPE),
    false
  );
  mv.visitVarInsn(Opcodes.DSTORE, index);
}

логика сгенерированного байт-кода может быть выражена следующим псевдокодом:

Object value = inputs.apply("varName");
if (value == null) {
  throw new ExecutionException("Undefined variable varName");
}
double varName = ((Double) value).doubleValue();

Наконец, напишем выброс исключения:

private void reportUndefinedVariable(String id) {
  // В JVM отсутствует операция вызова конструктора.
  // Вместо этого вначале надо создать пустой объект,
  // А потом вызвать для него метод <init>
  mv.visitTypeInsn(Opcodes.NEW, 
      Type.getInternalName(ExecutionException.class));
  // Первый раз наше исключение передаётся в <init>
  // Второй раз - в инструкцию ATHROW
  mv.visitInsn(Opcodes.DUP);
  mv.visitLdcInsn("Undefined variable " + id);
  mv.visitMethodInsn(
    Opcodes.INVOKESPECIAL, 
    Type.getInternalName(ExecutionException.class),
    "<init>",
    Type.getMethodDescriptor(Type.VOID_TYPE,
        Type.getType(String.class)),
    false
  );
  mv.visitInsn(Opcodes.ATHROW);
}

Создаём класс в рантайме

В принципе, наш код уже умеет генерировать байт-код, однако что с ним делать? Можно сохранить его на диск в файл с расширением class и линковать к коду. Однако, полезность такой утилиты невысока. Интереснее запустить его тут же, используя старый добрый reflection:

public class Compiler {
  private final ClassLoader classLoader;

  public Compiler(ClassLoader classLoader) {
    this.classLoader = classLoader;
  }

  public Expression compile(String expr) {
    var bytecode = new ClassGenerator().generate(expr);
    var loader = new ProvidedBytecodeClassLoader(classLoader, bytecode);
    Class<?> cls;
    try {
      cls = Class.forName(ClassGenerator.CLASS_NAME, false, loader);
    } catch (ClassNotFoundException e) {
      throw new RuntimeException(e);
    }
    try {
      return (Expression) cls.getConstructor().newInstance();
    } catch (InstantiationException 
        | IllegalAccessException 
        | NoSuchMethodException 
        | InvocationTargetException e) {
      throw new RuntimeException(e);
    }
  }
}

Секретный ингредиент здесь:

public class ProvidedBytecodeClassLoader extends ClassLoader {
  private final byte[] bytecode;

  public ProvidedBytecodeClassLoader(ClassLoader parent, byte[] bytecode) {
    super(parent);
    this.bytecode = bytecode;
  }

  @Override
  protected Class<?> findClass(String name) throws ClassNotFoundException {
    if (!name.equals(ClassGenerator.CLASS_NAME)) {
      return super.findClass(name);
    }
    return defineClass(ClassGenerator.CLASS_NAME, bytecode, 0, bytecode.length);
  }
}

Пусть вас не смущает то, что класс всегда создаётся по одному имени. В JVM имя класса не обязано быть уникальным, уникальной должна быть пара (имя класса, ClassLoader). Каждое новое выражение будет загружено через новый ClassLoader, поэтому никаких проблем у нас не возникнет.

Пишем парсер

Здесь нет ничего особенного с точки зрения именно нашей задачи. Руководств по тому, как это правильно делать, достаточно много. Поэтому я приведу код с минимальным количеством комментариев. В качестве домашнего задания можете пропустить этот раздел и написать парсер самостоятельно.

Итак, обычно парсер разделяют на лексер и собственно парсер. Лексер разбивает поток символов на лексемы. Определим для начала список лексем нашего языка:

public enum Token {
  NUMBER,
  PLUS,
  MINUS,
  STAR,
  SLASH,
  LEFT_BRACE,
  RIGHT_BRACE,
  SEMICOLON,
  ASSIGNMENT,
  IDENTIFIER,
  EOF
}

и напишем сам лексер:

public class Lexer {
  private final CharSequence inputString;
  private int position;
  private Token token;
  private int tokenPosition;
  private double number;
  private String identifier;

  public Lexer(CharSequence inputString) {
    this.inputString = inputString;
  }

  public void next() {
    if (token == Token.EOF) {
      return;
    }
    skipWhitespace();
    identifier = null;
    number = 0;
    tokenPosition = position;
    if (position == inputString.length()) {
      token = Token.EOF;
      return;
    }

    var c = inputString.charAt(position);
    switch (c) {
      case '+':
        simpleToken(Token.PLUS);
        break;
      case '-':
        simpleToken(Token.MINUS);
        break;
      case '*':
        simpleToken(Token.STAR);
        break;
      case '/':
        simpleToken(Token.SLASH);
        break;
      case '(':
        simpleToken(Token.LEFT_BRACE);
        break;
      case ')':
        simpleToken(Token.RIGHT_BRACE);
        break;
      case '=':
        simpleToken(Token.ASSIGNMENT);
        break;
      case ';':
        simpleToken(Token.SEMICOLON);
        break;
      default:
        if (isIdentifierStart(c)) {
          parseIdentifier();
        } else if (isDigit(c)) {
          parseNumber();
        } else {
          error("Unexpected character");
        }
        break;
    }
  }

  private void simpleToken(Token token) {
    this.token = token;
    ++position;
  }

  private void skipWhitespace() {
    while (position < inputString.length() 
        && Character.isWhitespace(inputString.charAt(position))) {
      ++position;
    }
  }

  private void parseIdentifier() {
    token = Token.IDENTIFIER;
    tokenPosition = position;
    while (position < inputString.length() 
        && isIdentifierPart(inputString.charAt(position))) {
      ++position;
    }
    identifier = inputString.subSequence(tokenPosition, position).toString();
  }

  private void parseNumber() {
    token = Token.NUMBER;
    while (position < inputString.length() 
        && isDigit(inputString.charAt(position))) {
      ++position;
    }
    if (position < inputString.length() 
        && inputString.charAt(position) == '.') {
      ++position;
      if (position >= inputString.length() 
          || !isDigit(inputString.charAt(position))) {
        error("Invalid number literal");
      }
      while (position < inputString.length() 
          && isDigit(inputString.charAt(position))) {
        ++position;
      }
    }
    try {
      number = Double.parseDouble(inputString.subSequence(
          tokenPosition, position).toString());
    } catch (NumberFormatException e) {
      error("Invalid number literal");
    }
  }

  private static boolean isIdentifierStart(char c) {
    switch (c) {
      case '$':
      case '_':
        return true;
      default:
        return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z';
    }
  }

  private static boolean isIdentifierPart(char c) {
    return isIdentifierStart(c) || isDigit(c);
  }

  private static boolean isDigit(char c) {
    return c >= '0' && c <= '9';
  }

  private void error(String error) {
    throw new ParseException(error, position);
  }

  public int getTokenPosition() {
    return tokenPosition;
  }

  public Token getToken() {
    return token;
  }

  public double getNumber() {
    return number;
  }

  public String getIdentifier() {
    return identifier;
  }
}

Предполагается, что у лексера последовательно вызывают next, он читает очередную лексему, и далее можно через публичные методы получать её свойства.

Наконец, парсер:

public class Parser {
  private final Lexer lexer;
  private final ParserConsumer consumer;

  public Parser(Lexer lexer, ParserConsumer consumer) {
    this.lexer = lexer;
    this.consumer = consumer;
  }

  public void parse() {
    lexer.next();
    parseSum();
    while (lexer.getToken() == Token.SEMICOLON) {
      skipSemicolons();
      if (lexer.getToken() == Token.EOF) {
        break;
      }
      consumer.statement();
      parseSum();
    }
    if (lexer.getToken() != Token.EOF) {
      error("End of input expected");
    }
  }

  private void skipSemicolons() {
    while (lexer.getToken() == Token.SEMICOLON) {
      lexer.next();
    }
  }

  private void parseSum() {
    parseProd();
    while (lexer.getToken() == Token.PLUS || lexer.getToken() == Token.MINUS) {
      var token = lexer.getToken();
      lexer.next();
      parseProd();
      if (token == Token.PLUS) {
        consumer.add();
      } else {
        consumer.subtract();
      }
    }
  }

  private void parseProd() {
    parsePrime();
    while (lexer.getToken() == Token.STAR || lexer.getToken() == Token.SLASH) {
      var token = lexer.getToken();
      lexer.next();
      parsePrime();
      if (token == Token.STAR) {
        consumer.multiply();
      } else {
        consumer.divide();
      }
    }
  }

  private void parsePrime() {
    switch (lexer.getToken()) {
      case NUMBER:
        consumer.number(lexer.getNumber());
        lexer.next();
        break;
      case IDENTIFIER:
        parseIdentifierOrAssignment();
        break;
      case MINUS:
        lexer.next();
        parsePrime();
        consumer.negate();
        break;
      case LEFT_BRACE:
        lexer.next();
        parseParenthesized();
        break;
      default:
        error("Unexpected token");
        break;
    }
  }

  private void parseIdentifierOrAssignment() {
    var id = lexer.getIdentifier();
    lexer.next();
    if (lexer.getToken() == Token.ASSIGNMENT) {
      lexer.next();
      parseSum();
      consumer.assignment(id);
    } else {
      consumer.identifier(id);
    }
  }

  private void parseParenthesized() {
    parseSum();
    if (lexer.getToken() != Token.RIGHT_BRACE) {
      error("Closing brace expected");
    }
    lexer.next();
  }

  private void error(String error) {
    throw new ParseException(error, lexer.getTokenPosition());
  }
}

Пример

Проверим получившийся калькулятор:

var compiler = new Compiler(ClassLoader.getSystemClassLoader());
var expr = compiler.compile("pi = 3.14159; pi * r * r");

var inputs = new HashMap<String, Double>();
inputs.put("r", 2.0);
System.out.println(expr.evaluate(inputs::get));

inputs.put("r", 5.0);
System.out.println(expr.evaluate(inputs::get));

вывод:

12.56636
78.53975

Однако, гораздо интереснее посмотреть сгенерированный байт-код. Запускаем с системным свойством konsoletyper.exprbc.log=true:

  ; pi = 3.14159
  LDC 3.14159
  DSTORE 2
  DLOAD 2
  POP2
  
  ; первое вычисление r
  DLOAD 2
  ALOAD 1
  LDC "r"
  INVOKEINTERFACE java/util/function/Function.apply (Ljava/lang/Object;)Ljava/lang/Object; (itf)
  DUP
  IFNONNULL L0
  NEW konsoletyper/exprbc/ExecutionException
  DUP
  LDC "Undefined variable r"
  INVOKESPECIAL konsoletyper/exprbc/ExecutionException.<init> (Ljava/lang/String;)V
  ATHROW
L0
  CHECKCAST java/lang/Double
  INVOKEVIRTUAL java/lang/Double.doubleValue ()D
  DSTORE 4
  DLOAD 4
  
  ; pi * r
  DMUL
  
  ; второе вычисление r
  ; теперь значение закэшировано в локальной переменной
  DLOAD 4
  
  ; pi * r * r
  DMUL
  DRETURN

Зачем это всё?

Казалось бы, я должен был поместить статью в хаб "ненормальное программирование". Однако, у генерации байт-кода есть свои применения. Во-первых, это производительность. Хотя редко, но бывает, что какое-то узкое место можно оптимизировать только генерацией байт-кода. Во-вторых, это навешивание всякой дополнительной функциональности на имеющиеся классы (например, сериализация, RPC). И хотя всё то же самое можно сделать через reflection, у reflection есть один недостаток: он плохо работает с AOT-компиляторами, которым в какой-то мере является, например, Android SDK, а точнее его часть под названием r8. Сюда же относится Graal Native Image, который можно использовать для запуска Java-приложений на iOS. Альтернативой генерации байт-кода здесь являются annotation processor. Однако, у них есть ряд недостатков:

  1. Они плохо работают на стыке Java и Kotlin.

  2. Они плохо дружат с инкрементальной компиляцией. Точнее, они с ней дружат, но порой внезапно система сборки хочет перекомпилировать слишком много всего. Сгенерировать байт-код обычно намного быстрее, чем спарсить и порезолвить часть исходников модуля.

  3. Иногда при работе с generics в случае с исходниками приходится очень сильно заморачиваться, чтобы правильно вывести типовые аргументы. В байт-коде все generics стёрты и проблемы просто нет.

Наконец, байт-код иногда не генерируют с нуля, а модифицируют существующий. Такое уж точно нельзя заменить никакими reflection или annotation processor.

Есть, правда, пара трудностей:

  1. Отлаживать всё это достаточно тяжело — некуда поставить брейкпоинт. Теоретически можно через ASM вписывать в байт-код информацию для отладчика, можно параллельно генерировать какой-нибудь псевдокод. Вот только мне не известны способы заставить IDE всё это понимать.

  2. Порой приходится иметь дело с багами ASM. Когда мы генерируем заведомо валидный байт-код, всё работает как часы. Однако, порой при генерации невалидного байт-кода ASM просто крэшится вместо того, чтобы напечатать понятное сообщение об ошибке.

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


  1. BugM
    09.09.2023 19:57
    +1

    И все-таки Ненормальное программирование ближе. Нет ни одного повода использовать это в продакшене. Прокси объекты, генерация лямбд, аннотации, рефлекшен, переписать на плюсы. Этого хватает для любой задачи.

    Оно вероятно нужно в netty подобных либах. Но они от начала до конца попадают в ненормальное программирование.


    1. konsoletyper Автор
      09.09.2023 19:57
      +8

      У всех свой продакшен. Мы вот используем и рады. Да и потом, если не все генерируют байт-код в продакшене, мало ли что. Где-то что-то не срослось и приходится смотреть глазами вывод `javap -c`. Или где-то заглючил спринг или хибернейт и сгенерил какую-нибудь обёртку, не проходящую валидацию. Так что понимать, как устроен байт-код - очень полезный навык, а для этого хорошо поупражняться таким способом.

      Кстати, а можете привести конкретные доводы, почему именно "нет ни одного повода"?


      1. BugM
        09.09.2023 19:57
        +1

        Понимать полезно. Не могу не согласиться. Главное чтобы это на собеседованиях спрашивать не начали, блин.

        За много лет с такими глюками не сталкивался. Хотя допускаю что бывает. Особо страшную спринг магию я не люблю и в моих проектах ее не так много. При этом jvm я сегфолтил в общем-то легальным и нормальным кодом не один раз.

        Кстати, а можете привести конкретные доводы, почему именно "нет ни одного повода"?

        Все задачи за редчайшими исключениями решаются другими более простыми, надежными и понятными способами. Прокси, лямбды и все такое что я перечислил. Если вам надо лезть в байт код, например для оптимизации, то пора переписывать код на плюсы. Ну не работает джава тут больше. Надо смириться и переходить на более подходящие языки.

        Исключения это как раз супероптимизированные джава либы. Netty и подобное. Их на плюсах писать обычно не стоит, а максимум оптимизации нужен. Но сколько людей реально такое писало?


        1. konsoletyper Автор
          09.09.2023 19:57
          +3

          Главное чтобы это на собеседованиях спрашивать не начали, блин.

          Проекты проектам рознь. Где-то понимать, как генерируют байт-код JVM - это первейший необходимый навык. В компиляторных командах, например.

          Прокси, лямбды и все такое что я перечислил

          Прокси, как я уже написал, плохо работают с AOT-компиляторами и proguard/r8. Постоянно приходится думать, а не придётся ли мне после написания или рефакторинга очередной пачки кода завайтлистить ещё каких классов, чтобы приложение не упало. Можно всё то же самое сделать через кодогенерацию. Обычно всё же генерируют исходники через annotation processors, но бывают нюансы.

          Ну и кстати, прокси работают только с интерфейсами, а не с абстрактными классами.

          Если вам надо лезть в байт код, например для оптимизации, то пора переписывать код на плюсы

          Это смотря какого рода оптимизации. Если есть алгоритм, который раз и навсегда написан, то да, это кейс. Если же пользователь что-то вводит и это что-то надо максимально быстро исполнить для разных входных данных (собственно, игрушечный проект в статье - намёк на такой кейс), то я лучше сгенерю байт-код Java, чем напишу на C++ генератор нативного кода. Пример: различные пользовательские скрипты. Да, в 90% случаев проще прикрутить что-то стандартное вроде JS, но бывает всякое. Ну например, представьте, что вы пишете Excel-подобную штуку и пользователь хочет (а он 100% хочет), чтобы формулы пересчитывались максимально быстро.

          Плюс JNI имеет достаточно большие накладные расходы. И нативный код не инлайнится.


          1. BugM
            09.09.2023 19:57
            +1

            Проекты проектам рознь. Где-то понимать, как генерируют байт-код JVM - это первейший необходимый навык. В компиляторных командах, например.

            Я про обычные места. Компиляторы очень мало кто пишет. Сколько там людей на планете понимает как c2 работает?

            Прокси, как я уже написал, плохо работают с AOT-компиляторами и proguard/r8

            Тут я пас. javac наше все. Код на сервере.

            Ну и кстати, прокси работают только с интерфейсами, а не с абстрактными классами.

            С чем угодно они уже довольно работают.

            Ну например, представьте, что вы пишете Excel-подобную штуку и пользователь хочет (а он 100% хочет), чтобы формулы пересчитывались максимально быстро.

            Возьмите любой готовый движок. SpEl для примера. Не надо писать такое.

            Если и такого движка мало, то это уже не написать за сколь-либо разумные деньги и все равно есть готовые скриптовые движки. Они подойдут лучше.

            Плюс JNI имеет достаточно большие накладные расходы. И нативный код не инлайнится.

            Уже поменьше. Доработали неплохо. Но да они все равно нормальные такие.

            Переписывать лучше всего весь микросервис. Или выделять микросервис где надо упороться в производительность. Это в любом случае полезно. Такие штуки стоит держать отдельно от основного кода.


            1. konsoletyper Автор
              09.09.2023 19:57
              +2

              Сколько там людей на планете понимает как c2 работает

              Ну зачем же c2? Я про javac, kotlinc, scalac и иже с ними. c2 - это уже уровнем пониже и немного другие скилы.

              Тут я пас. javac наше все. Код на сервере.

              Так AOT-компиляторы обычно берут байт-код, там перед AOT-компилятором старый-добрый javac.

              С чем угодно они уже довольно работают.

              Вы про java.lang.reflect.Proxy? Не поддерживают.


              1. BugM
                09.09.2023 19:57
                +2

                Так AOT-компиляторы обычно берут байт-код, там перед AOT-компилятором старый-добрый javac.

                Мои знания кончаются ровно на моменте "байт-код готов". Мне достаточно что он нормально исполняется стандартной джава машиной.

                Вы про java.lang.reflect.Proxy? Не поддерживают.

                Возьмите стандартный AspectJ или Spring AOP и все у вас будет.

                Прокси это довольно широкое понятие.


    1. urvanov
      09.09.2023 19:57
      +1

      А у меня на одном из мест работы генерировали классы вполне. И даже в продакшене использовали, на сколько я помню.


  1. qw1
    09.09.2023 19:57
    +1

    у reflection есть один недостаток: он плохо работает с AOT-компиляторами, которым в какой-то мере является, например, Android SDK

    Ваш пример будет работать на Android? Насколько я знаю, там другая java-машина, не стековая, а регистровая, и опкоды другие, и бинарный формат классов другой.


    1. konsoletyper Автор
      09.09.2023 19:57
      +1

      Вот конкретно этот пример - не уверен, не пробовал. Если взять из моего примера генерацию байт-кода класса, сохранить его в виде класса с расширением class и прилинковать к проекту - класс загрузится. Какой бы ни был внутри android формат классов, SDK умеет переводить байт-код JVM в свой собственный формат. Не вижу причин, почему бы нельзя было в ClassLoader поддержать прозрачную трансляцию из байт-кода JVM в собственный формат - это должно быть тривиально. Боюсь только, что Android такой сгенерированный на лету байт-код будет интерпретировать или если сконвертирует в нативный код, то с минимальным числом оптимизаций.

      Что касается моего личного опыта, то я использую на своём текущем проекте самописные генерялки байт-кода для нужд сериализации данных, RPC и привязки данных к OT. Но работают они в compile time.


      1. qw1
        09.09.2023 19:57
        +1

        Не вижу причин, почему бы нельзя было в ClassLoader поддержать прозрачную трансляцию из байт-кода JVM в собственный формат — это должно быть тривиально

        Не совсем тривиально перекинуть стековую машину в регистровую. В compile-time это делает утилита https://developer.android.com/tools/d8


        А причина не включать эту возможность в рантайм андроида, я думаю в том, что гугл хотел максимально обезопаситься от оракла по лицензиям и для этого разработал принципиально отличный формат байт-кода. И включать загрузчик классического java-кода — это обесценить все эти усилия.


        Но работают они в compile time.

        Вот ещё нашёл на Хабре
        https://habr.com/en/articles/469237/


  1. Guul
    09.09.2023 19:57
    +2

    В модах к играм это хорошо используется. Например, Rimworld использует harmony, что в целом позволяет переписать байткод во время загрузки(даёт IEnumerable байткода и ждёт то же самое). Forge для Minecraft наверное что-то подобное позволяет.
    Что очень удобно: исходников нормальных нет, а шаловливыми ручками изменить нет-нет, да и хочется


  1. Ewans33
    09.09.2023 19:57
    +2

    Спасибо за статью. ASM и генерация байт-код интересная штука. Мы у себя на работе пишем плагин под Android, который добавляет байт-код инструкции в существующие классы фреймворка, как (fragment, activity), или в различные библиотеки (okhttp, jetpack compose).

    Согласен, тестирование и контроль качества не тривиальное дело, но все возможно. Как минимум, можно проверить, что ClassLoader может загрузить измененный класс.

    Мы в тестах применяем наш плагин, через кастомный лоадер грузим классы и через рефлексию дергаем нужный нам функционал, ну и смотрим результаты.

    Ну и финальный шаг - используем приложение тестовое , применяем плагин и пишем end2end тесты и смотрим поведение.


    1. konsoletyper Автор
      09.09.2023 19:57
      +1

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

      Кстати, для junit 4 можно писать раннеры, которые тестовые классы загружают в кастомном лоадере, что может быть очень удобно при тестировании генераторов. Увы, в 5-м авторы перемудрили с системой расширений и такая возможность пропала.


  1. igor_suhorukov
    09.09.2023 19:57
    +2

    Самый неоспоримый use case, когда есть некая проприетарная библиотека(как было у нас с Coherence) и надо залазить в ее потроха. Тут и AspectJ, манипуляции с байт кодом, свои загрузчики классов(умеющие подгружать зависимости из maven) и динамическое подключение агента(которое в Java21 уже как раньше не сработает). В таких задачах все средства хороши!


  1. dmitrii-bu
    09.09.2023 19:57
    +2

    Вставлю свои 5 копеек: Есть довольно экзотические применения, когда java-компилятор не позволяет писать определенные конструкции, в то время как байткод с похожим смыслом может быть успешно загружен в JVM HotSpot. Понятно, что следующий код не может быть скомпилирован

    public class Cl{
        private static final int fld;
    
        public static void setFinalField1(){
            fld = 5;
        }
    
        public static void setFinalField2(){
            fld = 2;
        }
    }

    Однако, если нагенерить байткод, который меняет static final поле за пределами static initializer то JVM HotSpot успешно его загрузит. Для этого в коде JVM HotSpot есть специальные проверки cо следующим комментарием (ссылка на исходники):

    // Check if any final field of the class given as parameter is modified
    // outside of initializer methods of the class. Fields that are modified
    // are marked with a flag. For marked fields, the compilers do not perform
    // constant folding (as the field can be changed after initialization).
    //
    // The check is performed after verification and only if verification has
    // succeeded. Therefore, the class is guaranteed to be well-formed.

    Наткнулся на этот фрагмент несколько лет назад совершенно случайно, когда разбирался в исходниках JVM HotSpot. Зачем это нужно -- для меня так и осталось загадкой.


    1. Sap_ru
      09.09.2023 19:57
      +2

      Менять static final нужно для всяких ленивых инициализаторов.

      Например, у вас там объект лога и базы данных лежит с очень тяжёлой инициализацией. В можете захотеть инициализировать его не при загрузке класса, а при первом обращении к полю. Но при этом поле остаётся final и не должно изменятся. Этот функционал уже довольно давно добавляют, и он уже частично доступен из Java при помощи всяких магических декораторов. Используется в стандартной библиотеке. Полноценно в язык его никак не добавят из-за боязни, что программисты почувствуют свободу и начнут его повально использовать, а там есть побочные эффекты.