Перестаём бояться генерировать байт-код
Многие, возможно, думают, что работа с байт-кодом Java (будь то чтение или, тем более, генерация) — это какая-то особенная магия, доступная только продвинутым разработчикам с особенно крутым опытом. На самом деле, я считаю такую точку зрения ошибочной. JVM устроена гораздо проще, чем CPU; она оперирует такими высокоуровневыми понятиями как классы, интерфейсы, методы, а не просто лопатит байты в памяти. В отличие от CPU, который легко уронить криво сгенерированным машинным кодом, JVM заботливо отверифицирует любой байт-код и в общем не даст выстрелить в ногу.
Но с чего начать погружение в байт-кодную магию? В сети есть некоторое количество туториалов по этому вопросу. Как мне кажется, они либо показывают слишком простые случаи, от которых непонятно, как перейти к чему-то более интересному, либо очень основательные и требуют вникать в теорию, собирать целиком картину в голове по кусочкам. Я хотел бы попробовать внести свой вклад в эту тему — надеюсь, у меня получится показать, как можно побороть первый страх и написать что-то похожее на реалистичный сценарий без особого вникания в теорию на первом этапе.
Весь приведённый код доступен в моём репозитории.
Задача
Я вдохновился книгой Бьёрна Страуструпа, по которой лет 20 назад изучал C++. В одной из первых глав в качестве задачи для введения в язык предлагается написать калькулятор выражений. Я же предлагаю не вычислять выражения, а генерировать байт-код, который вычисляет выражения.
Итак, формулировка: необходимо написать метод, которые принимает на вход строку с математическими выражениями и выдаёт на выходе экземпляр такого интерфейса:
public interface Expression { double evaluate(Function<String, Double> inputs); }
Выражения в списке разделены точкой с запятой (;
), метод evaluate
возвращает результат вычисления последнего из выражений. Выражения определим так:
- Число (например,
2
,42
,3.14
) — это выражение. - Идентификатор (например,
foo
,pi
,myVar_1
) — это выражение. Значение по-умолчанию для переменной вычисляется с помощью вызоваinputs.apply(id)
. - Если A и B — выражения, то A + B, A - B, A * B, A / B, -A, (A) — так же выражения
- Если 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
последовательность будет такой:
Наш генератор реализует этот интерфейс:
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 '#39;: 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. Однако, у них есть ряд недостатков:
- Они плохо работают на стыке Java и Kotlin.
- Они плохо дружат с инкрементальной компиляцией. Точнее, они с ней дружат, но порой внезапно система сборки хочет перекомпилировать слишком много всего. Сгенерировать байт-код обычно намного быстрее, чем спарсить и порезолвить часть исходников модуля.
- Иногда при работе с generics в случае с исходниками приходится очень сильно заморачиваться, чтобы правильно вывести типовые аргументы. В байт-коде все generics стёрты и проблемы просто нет.
Наконец, байт-код иногда не генерируют с нуля, а модифицируют существующий. Такое уж точно нельзя заменить никакими reflection или annotation processor.
Есть, правда, пара трудностей:
- Отлаживать всё это достаточно тяжело — некуда поставить брейкпоинт. Теоретически можно через ASM вписывать в байт-код информацию для отладчика, можно параллельно генерировать какой-нибудь псевдокод. Вот только мне не известны способы заставить IDE всё это понимать.
- Порой приходится иметь дело с багами ASM. Когда мы генерируем заведомо валидный байт-код, всё работает как часы. Однако, порой при генерации невалидного байт-кода ASM просто крэшится вместо того, чтобы напечатать понятное сообщение об ошибке.