Компиляция и декомпиляция try-with-resources

Компиляция и декомпиляция try-with-resources, или рассказ о том, как я фиксил баг и что из этого вышло. PITestКакое-то время назад backlog рабочего проекта почти опустел, и вверх всплыли различного рода исследовательские задачи. Одна из них звучала весьма интригующе: прикрутить к проекту мутационное тестирования используя PITest. На Хабре уже есть весьма подробный обзор этой библиотеки (с примерами и картинками). Пересказывать эту статью своими словами я не буду, но все же рекомендую с ней предварительно ознакомиться.Признаюсь, что идеей мутационного тестирования я загорелся. Почти без дополнительных усилий получить инструмент поиска потенциально опасных мест кода — оно того стоит! Я без промедления взялся за дело. На тот момент библиотека была относительно молодой, как следствие — весьма сырой: здесь нужно немного пошаманить с конфигурацией maven«а, там — пропатчить плагин для Sonar«а. Однако через некоторое время я все же смог проверить проект целиком. Результат: сотни выживших мутаций! Эволюция в масштабе на нашем build-сервере.

Засучив рукава я погрузился в работу. В одних тестах не хватает верификаций заглушек, в других вместо логики вообще непонятно что тестируется. Правим, улучшаем, переписываем. В общем, процесс пошел, но число выживших мутаций убывало не так стремительно, как хотелось. Причина была проста: PIT давал огромное количество ложных срабатываний на блоке try-with-resources. Недолгие поиски показали, что баг известен, но до сих пор не исправлен. Что ж, код библиотеки открыт. От чего бы не склонировать его и не посмотреть, в чем же дело?

TryExample Я накидал простейший пример, юнит-тест к нему и запустил PITest. Результат перед вами: вместо одной — одиннадцать выживших мутаций, десять из которых указывают на строку с символом »}». Вызовы методов close и addSupressed наводят на мысль, что к этой строке относится сгенерированный для блока try-with-resources код. Чтобы подтвердить эту догадку, я решил декомпилировать class-файл. Для этого я воспользовался JD-GUI, хотя сейчас рекомендовал бы встроенный декомпилятор IntelliJ IDEA 14. public static void main (String[] args) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream (); Throwable var2 = null; try { baos.flush (); } catch (Throwable var11) { var2 = var11; throw var11; } finally { if (baos!= null) { if (var2!= null) { try { baos.close (); } catch (Throwable var10) { var2.addSuppressed (var10); } } else { baos.close (); } } } } Догадка подтвердилась, но остался вопрос: как две строчки try-with-resources превратились в десяток строк try-catch-finally? gvsmirnov завещал нам в любой непонятной ситуации качать исходники OpenJDK. Это я и сделал.Весь код, относящийся к задаче компиляции try-with-resources, разместился между строками 1428 и 1580 класса Lower. Javadoc подсказывает нам, что этот класс предназначен для трансляции синтаксического сахара: никакой магии, только простейшие модификации синтаксического дерева. Все в соответсвии с JLS 14.20.3.

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

Первой идеей было перехватить номер строки из метода visitGeneratedTryCatchBlock класса MethodVisitor, а потом просто сообщить библиотеке, какую строку нужно проигнорировать. Подобная функциональность уже была реализована для finally-блока. Однако я был удивлен узнав, что метода visitGeneratedTryCatchBlock не существует. ASM никак не различает сгенерированный компилятором код от сгенерированного программистом. Засада. Пришлось заглянуть в байткод, вывод и форматирование которого любезно предоставил Textifier.

Байткод метода main класса TryExample // access flags 0×9 public static main ([Ljava/lang/String;)V throws java/io/IOException TRYCATCHBLOCK L0 L1 L2 java/lang/Throwable TRYCATCHBLOCK L3 L4 L5 java/lang/Throwable TRYCATCHBLOCK L3 L4 L6 null TRYCATCHBLOCK L7 L8 L9 java/lang/Throwable TRYCATCHBLOCK L5 L10 L6 null L11 LINENUMBER 12 L11 NEW java/io/ByteArrayOutputStream DUP INVOKESPECIAL java/io/ByteArrayOutputStream. ()V ASTORE 1 L12 ACONST_NULL ASTORE 2 L3 LINENUMBER 13 L3 ALOAD 1 INVOKEVIRTUAL java/io/ByteArrayOutputStream.flush ()V L4 LINENUMBER 14 L4 ALOAD 1 IFNULL L13 ALOAD 2 IFNULL L14 L0 ALOAD 1 INVOKEVIRTUAL java/io/ByteArrayOutputStream.close ()V L1 GOTO L13 L2 FRAME FULL [[Ljava/lang/String; java/io/ByteArrayOutputStream java/lang/Throwable] [java/lang/Throwable] ASTORE 3 L15 ALOAD 2 ALOAD 3 INVOKEVIRTUAL java/lang/Throwable.addSuppressed (Ljava/lang/Throwable;)V L16 GOTO L13 L14 FRAME SAME ALOAD 1 INVOKEVIRTUAL java/io/ByteArrayOutputStream.close ()V GOTO L13 L5 LINENUMBER 12 L5 FRAME SAME1 java/lang/Throwable ASTORE 3 ALOAD 3 ASTORE 2 ALOAD 3 ATHROW L6 LINENUMBER 14 L6 FRAME SAME1 java/lang/Throwable ASTORE 4 L10 ALOAD 1 IFNULL L17 ALOAD 2 IFNULL L18 L7 ALOAD 1 INVOKEVIRTUAL java/io/ByteArrayOutputStream.close ()V L8 GOTO L17 L9 FRAME FULL [[Ljava/lang/String; java/io/ByteArrayOutputStream java/lang/Throwable T java/lang/Throwable] [java/lang/Throwable] ASTORE 5 L19 ALOAD 2 ALOAD 5 INVOKEVIRTUAL java/lang/Throwable.addSuppressed (Ljava/lang/Throwable;)V L20 GOTO L17 L18 FRAME SAME ALOAD 1 INVOKEVIRTUAL java/io/ByteArrayOutputStream.close ()V L17 FRAME SAME ALOAD 4 ATHROW L13 LINENUMBER 15 L13 FRAME FULL [[Ljava/lang/String;] [] RETURN L21 LOCALVARIABLE x2 Ljava/lang/Throwable; L15 L16 3 LOCALVARIABLE x2 Ljava/lang/Throwable; L19 L20 5 LOCALVARIABLE baos Ljava/io/ByteArrayOutputStream; L12 L13 1 LOCALVARIABLE args [Ljava/lang/String; L11 L21 0 MAXSTACK = 2 MAXLOCALS = 6 Наивное предположение, что блок try-catch-finally реализован на уровне JVM, не подтвердилось. Никакой специальной инструкции для него нет, только таблица исключений и goto между метками. Получается, стандартными средствами распознать сгенерированный блок не получится. Нужно искать другое решение. Перед тем, как начать гадать на кофейной гуще, я решил нанести метки байткода на декомпилированный класс. Вот что из этого получилось. public static void main (String[] args) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream (); // L11 Throwable primaryExc = null; // L12 try { baos.flush (); // L3 } catch (Throwable t) { // L5 primaryExc = t; throw t; } finally { // L6 if (baos!= null) { // L4 L10 if (primaryExc!= null) { try { baos.close (); // L0 L7 } catch (Throwable suppressedExc) { // L2 L9 primaryExc.addSuppressed (suppressedExc); // L15 L19 } // L1 L16 L8 L20 } else { baos.close (); // L14 L18 } } // L17 } // L13 } Отчетливо вырисовываются два основных пути выполнения программы: L11 L12 L3 {L4 [L0 (L2 L15 L16) L1] L14} L13 L11 L12 L3 [L5 {L6] L10 [L7 (L9 L19 L20) L8] L18 L17} Друг под другом находятся метки, блоки кода которых совпадают или почти совпадают. В круглых скобках находится код, который будет выполнен в случае, когда метод close бросит исключение. Аналогично в квадратных — когда метод flush. Два пути получилось из-за того, что блок finally был подставлен компилятором дважды. Ну, а теперь, чтобы окончательно сломать ваш визуальный парсер: метки в фигурных скобках относятся к строке 11. На эту же строку ссылаются ложные срабатывания PITest.Вот оно решение! Необходимо выделить минимально повторяющийся набор инструкций. Если такой набор встретится в проверяемом байткоде, да еще и на одной строке — налицо сгенерированный код для блока try-with-resources. Звучит не очень железно, но я решил попробовать. Ниже список инструкций, на котором я в итоге остановился.

private static final List JAVAC_CLASS_INS_SEQUENCE = Arrays.asList ( ASTORE, // store throwable ALOAD, IFNULL, // closeable!= null ALOAD, IFNULL, // localThrowable2!= null ALOAD, INVOKEVIRTUAL, GOTO, // closeable.close () ASTORE, // Throwable x2 ALOAD, ALOAD, INVOKEVIRTUAL, GOTO, // localThrowable2.addSuppressed (x2) ALOAD, INVOKEVIRTUAL, // closeable.close () ALOAD, ATHROW); // throw throwable Примерно так его можно сопоставить коду в finally-блоке. } finally { if (closeable!= null) { // IFNULL if (localThrowable2!= null) { // IFNULL try { closeable.close (); // INVOKEVIRTUAL or INVOKEINTERFACE } catch (Throwable x2) { localThrowable2.addSuppressed (x2); // INVOKEVIRTUAL } } else { closeable.close (); // INVOKEVIRTUAL or INVOKEINTERFACE } } } // ATHROW «Не так уж и сложно», — подумал я после нескольких дней напряженной работы. Накидал еще несколько примеров; написал тесты, которые их используют. Все отлично, все работает. Попытался собрать PITest, чтобы запустить его на живом коде: упали тесты. Не те, что я написал; другие. Итак, код перешел из стадии «не компилируется» в стадию «не работает». Упал один из существовавших до этого тестов. Откатился — работает. Внутри теста проверяется файл Java7TryWithResources.class.bin, который уже был в проекте. Распечатав байткод, я не поверил своим глазам: для компиляции try-with-resources использован совершенно другой порядок инструкций! Стараясь не поддаваться панике, я начал проверять все находившиеся под рукой компиляторы. С javac из Oracle JDK я работал, javac из OpenJDK ожидаемо дал аналогичный результат. Попробовал разные версии: безрезультатно. Настал черед компиляторов, которых под рукой не было. Eclipse Compiler for Java, ECJ. Скомпилировал, распечатал байткод — на первый взгляд похож на тот, что я ищу.

Байткод метода main класса TryExample by ECJ // access flags 0×9 public static main ([Ljava/lang/String;)V throws java/io/IOException TRYCATCHBLOCK L0 L1 L2 null TRYCATCHBLOCK L3 L4 L4 null L5 LINENUMBER 12 L5 ACONST_NULL ASTORE 1 ACONST_NULL ASTORE 2 L3 NEW java/io/ByteArrayOutputStream DUP INVOKESPECIAL java/io/ByteArrayOutputStream. ()V ASTORE 3 L0 LINENUMBER 13 L0 ALOAD 3 INVOKEVIRTUAL java/io/ByteArrayOutputStream.flush ()V L1 LINENUMBER 14 L1 ALOAD 3 IFNULL L6 ALOAD 3 INVOKEVIRTUAL java/io/ByteArrayOutputStream.close ()V GOTO L6 L2 FRAME FULL [[Ljava/lang/String; java/lang/Throwable java/lang/Throwable java/io/ByteArrayOutputStream] [java/lang/Throwable] ASTORE 1 ALOAD 3 IFNULL L7 ALOAD 3 INVOKEVIRTUAL java/io/ByteArrayOutputStream.close ()V L7 FRAME CHOP 1 ALOAD 1 ATHROW L4 FRAME SAME1 java/lang/Throwable ASTORE 2 ALOAD 1 IFNONNULL L8 ALOAD 2 ASTORE 1 GOTO L9 L8 FRAME SAME ALOAD 1 ALOAD 2 IF_ACMPEQ L9 ALOAD 1 ALOAD 2 INVOKEVIRTUAL java/lang/Throwable.addSuppressed (Ljava/lang/Throwable;)V L9 FRAME SAME ALOAD 1 ATHROW L6 LINENUMBER 15 L6 FRAME CHOP 2 RETURN MAXSTACK = 2 MAXLOCALS = 4 После этого я решил декомпилировать полученный class-файл. Результат работы декомпилятора обратно компилироваться отказался. Ну ничего, с этим уже можно работать. Руками приведя программный код в соответствие с байткодом, я получил следующее. public static void main (String[] paramArrayOfString) throws Throwable { Throwable primaryExceptionVariable = null; // L5 Throwable caughtThrowableVariable = null; try { ByteArrayOutputStream baos = new ByteArrayOutputStream (); // L3 try { baos.flush (); // L0 } catch (Throwable t) { primaryExceptionVariable = t; // L2 throw primaryExceptionVariable; // L7 } finally { if (baos!= null) { // L1 baos.close (); } } } catch (Throwable t) { caughtThrowableVariable = t; // L4 if (primaryExceptionVariable == null) { primaryExceptionVariable = caughtThrowableVariable; } else if (primaryExceptionVariable!= caughtThrowableVariable) { // L8 primaryExceptionVariable.addSuppressed (caughtThrowableVariable); } throw primaryExceptionVariable; // L9 } // L6 } ECJ использует совершенно другой подход для компиляции try-with-resources. Меток заметно меньше, блоки кода заметно больше. Вместо раздутой таблицы, исключения просто пробрасываются на уровень выше. В примерах посложнее можно заметить, что получается этакая матрешка.Что же под капотом? Я снова пошел качать исходники, на этот раз ECJ. Компиляция оператора try прячется в файле TryStatement. На этот раз никаких деревьев, только opcodes, только хардкор. Байткод, отвечающий за try-with-resources, генерируется между строками 500 и 604. По истории коммитов хорошо видно, что тело блока try просто обрамили цепочкой вызовов создания и закрытия ресурсов.

Т.к. нет подстановки finally-блока, то нет и дублирования кода. Однако из-за вложенности, одинаковые действия повторяются для разных исключений. Этим я и воспользовался. Набор инструкций для ECJ выглядит следующим образом.

private static final List ECJ_INS_SEQUENCE = Arrays.asList ( ASTORE, // store throwable2 ALOAD, IFNONNULL, // if (throwable1 == null) ALOAD, ASTORE, GOTO, // throwable1 = throwable2; ALOAD, ALOAD, IF_ACMPEQ, // if (throwable1!= throwable2) { ALOAD, ALOAD, INVOKEVIRTUAL, // throwable1.addSuppressed (throwable2) ALOAD, ATHROW); // throw throwable1 А так выглядит соответствующий им java-код. if (throwable1 == null) { // IFNONNULL throwable1 = throwable2; } else { if (throwable1!= throwable2) { // IF_ACMPEQ throwable1.addSuppressed (throwable2); // INVOKEVIRTUAL } } // ATHROW Что же с остальными компиляторами? Оказалось, что AspectJ генерирует почти такой же байткод, что и ECJ. Для него отдельную последовательность придумывать не пришлось. Компилятор от IBM я так и не смог скачать (да и не особо хотелось). Остальные компиляторы были проигнорированы в следствие малой распространенности. Внимательный читатель уже заметил, что набор инструкций для javac не учитывает один нюанс. Для вызова методов класса и интерфейса на самом деле используются разные инструкции: INVOKEVIRTUAL и INVOKEINTERFACE соответственно. Описанная выше реализация учитывает только первый случай и не учитывает второй. Ну ничего, это не сложно исправить.Итак, что же получилось в итоге?

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

Во-вторых, я узнал, какие бывают способы компиляции блока try-with-resources. Как следствие, я разобрался с тем, как выглядит try-catch-finally на уровне байткода. Ну, а побочным продуктом стал перевод статьи, которую я уже упоминал выше по тексту.

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

А где же польза и мораль, спросите вы? Оставляю их поиск читателю. Замечу только, что я получил удовольствие, пока писал эту статью. Надеюсь, вы получили его от чтения. До новых встреч!

P.S. В качестве бонуса предлагаю посмотреть на ранние предложения к реализации try-with-resources от Joshua Bloch.

Stumbled on original ARM block (try-with-resources) proposals, if anyone’s curious. V1: https://t.co/Qngv2STN1W, V2: https://t.co/YiR1RvyZWg

 — Joshua Bloch (@joshbloch) 13 июня 2015

Выглядит забавно. { final LocalVariableDeclaration; boolean #suppressSecondaryException = false; try Block catch (final Throwable #t) { #suppressSecondaryException = true; throw #t; } finally { if (#suppressSecondaryException) try { localVar.close (); } catch (Exception #ignore) { } else localVar.close (); } }

© Habrahabr.ru