Компиляция и декомпиляция 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 0x9
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.<init> ()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<Integer> 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 0x9
 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.<init> ()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<Integer> 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.


Выглядит забавно.

{
    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();
    }
}

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


  1. ekapinos
    16.06.2015 15:12

    Вот те на, всего лишь синтаксический сахар. А столько шума эта фитча наделала. Спасибо за Ваш труд.


    1. grossws
      16.06.2015 15:20

      Да и так было понятно, что сахар. И плюс ввести AutoClosable и использовать его в rt.jar


  1. leventov
    16.06.2015 15:39

    Тоже недавно имел дело с ECJ, та еще забагованная шняга.

    Интересно, а чем компилирует IntelliJ? Им же тоже надо исходники в AST переводить. Или это не имеет оформления библиотеки, а просто набор классов в их проекте?


    1. grossws
      16.06.2015 16:04

      IDEA использует javac или ecj, по выбору. По умолчанию у меня javac, но я мог когда-то переключаться.


    1. artspb Автор
      16.06.2015 16:10

      В настройках IDEA можно выбрать те же три, что упомянуты в статье, и Groovy-Eclipse.