Некоторое время назад я немного поэкспериментировала, пытаясь научиться декомпилировать файлы классов Java более эффективно, чем позволяют традиционные инструменты, предназначенные для этого — например, Vineflower. В конце концов, я написала статью, в которой изложила мой подход к декомпиляции потока управления. Мои находки позволили значительно ускорить работу получившегося у меня прототипа.
На тот момент я полагала, что этот метод не составит труда расширить и на декомпиляцию потока управления, возникающего при обработке исключений, то есть что ему будут поддаваться блоки try…catch. В ретроспективе признаю: следовало ожидать, что это будет не так просто. Оказывается, здесь возникает множество пограничных случаев, варьирующихся от странного поведения javac до последствий, отражающихся на самой структуре JVM и формате файлов классов. Всё это – серьёзные осложнения. В данном посте я разберу все эти детали, расскажу, почему простые решения не работают, и на каком подходе я в итоге остановилась.
Основы JVM
JVM — это стековый язык. Большинство инструкций взаимодействуют исключительно со стеком. Например, iadd выталкивает два целых числа с вершины стека, а их сумму записывает обратно в стек. Есть несколько таких инструкций как if_icmpeq, которые передают управление по заданному адресу, в зависимости от того, выполняется ли примитивное условие (напр., в данном случае, value1 == value2). Этого достаточно, чтобы реализовать if, while и многие другие конструкции, описывающие явный поток управления.
Но поток управления, применяемый при исключениях, является неявным, поэтому с ним нельзя обращаться таким образом. Отдельно взятый блок try должен отлавливать все исключения, возникающие в зоне его ответственности, будь эти исключения пересланы от вызова метода вinvokevirtual, спровоцированы делением на ноль вidiv или разыменованием нулевого указателя вgetfield. Такие отношения невозможно эффективно выразить в самом байт-коде, поэтому они хранятся отдельно в таблице исключений.
В каждой записи из этой таблицы указывается, какая именно область инструкций ассоциирована с каким обработчик��м исключений. Если исключение возникает в пределах такой области, то стек очищается, объект исключения записывается в стек, а управление передаётся первой инструкции обработчика.
Например, вот как выглядят байт-код и таблица исключений для простого метода, в котором содержится один блокtry…catch (получено при помощиjavap -c):
static void method() {
try {
System.out.println("Hello, world!");
} catch (Exception ex) {
System.out.println("Oops, an error happened");
}
}
Code:
// В следующих 3 строках на экран выводится "Hello, world!".
0: getstatic #7 //Поле java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // Строка Hello, world!
5: invokevirtual #15 // Метод java/io/PrintStream.println:(Ljava/lang/String;)V
// После успешного вывода "Hello, world!" на экран переходим к концу функции
8: goto 20
// Здесь начинает действовать обработчик исключений. `ex` первым делом записывается в стек, и
// эта инструкция сохраняет её в локальную переменную.
11: astore_0
// В следующих 3 строках выводится "Oops, an error happened".
12: getstatic #7 // Поле java/lang/System.out:Ljava/io/PrintStream;
15: ldc #23 // Строка Oops, an error happened
17: invokevirtual #15 // Метод java/io/PrintStream.println:(Ljava/lang/String;)V
// Возврат из функции
20: return
Exception table:
from to target type
// Исключения, возникающие при выполнении инструкций, начиная с адреса 0 (включительно) до 8 (невключительно) обработчик
// разбирает по адресу 11.
0 8 11 Class java/lang/Exception
Если в таблице исключений много строк, то используется первая подходящая. Например, при наличии вложенных блоковtry, то первым в списке пойдёт внутренний блокtry, а за ним последует объемлющий его.
Вложение
Обратите внимание: таблица исключений — это просто список областей. В JVM не предъявляется никаких требований к тому, как эти области могут вкладываться друг в друга. Например, два диапазона могут пересекаться, притом, что ни один из них не будет вложен в другой, а target может располагаться до from или даже внутри диапазона from…to.
Как мы вскоре увидим, это не гипотетический сценарий, и реальные классы файлов часто нарушают эти «очевидные» допущения. Из-за этого возникает проблема, решить которую просто необходимо, если вам нужно написать не просто безусловно корректный декомпилятор, какой пытаюсь сделать я, а просто любой декомпилятор.
Finally
Прежде, чем обсудить эту проблему, давайте разберёмся с тем, как javac обрабатывает блоки try…finally. Тело finally должно быть выполнено независимо от того, было ли выброшено исключение. Но тот момент, куда именно передаётся управление по завершенииfinally, зависит от наличия исключения:

Непонятно, откуда блоку finally знать, куда именно нужно далее передать управление. Один из вариантов — хранить возможное исключение для rethrow в скрытой переменной и расценивать null как сигнал, что блок try закончил работу без исключения — но этого недостаточно. В блоке try могут быть такие точки выхода, через которые его можно покинуть ещё до того, как успеешь пройти на всю глубину. Точками выхода могут быть continue, break и даже return. Для каждой из них потребуется своя цель, идущая уже после finally. Для обработки этого свойства потребовалась бы таблица переходов, которая, скорее всего, без оптимизации получится медленной. Я уже не говорю о том, как она спутает JIT-компиляторы и статические анализаторы, в том числе таковой в JVM, в обязанности которого входит проверка, в самом ли деле код не обращается к неинициализированным переменным.
Вместо этого javac вытворяет нечто одновременно порочное и гениальное; добавляет к телу finally его дубль на каждом пути к выходу. Рассмотрим следующий фрагмент кода:
static void method() {
try {
try_body();
} catch (Exception ex) {
throw ex;
} finally {
finally_body();
}
}
Code:
0: invokestatic #7 // Метод try_body:()V
3: invokestatic #12 // Метод finally_body:()V
6: goto 18
9: astore_0
10: aload_0
11: athrow
12: astore_1
13: invokestatic #12 // Метод finally_body:()V
16: aload_1
17: athrow
18: return
Exception table:
from to target type
0 3 9 Class java/lang/Exception
0 3 12 any
9 13 12 any
Сначала javac убеждается, что тело try можно пройти насквозь, поэтому добавляет вызов finally_body сразу после тела try, после чего следует переход к return. Тело catch насквозь пройти нельзя, поэтому finally_body не вставляется после 11: athrow.
Затем javac выясняет, что тело try может выбрасывать исключение, поэтому обёртывает его во всеулавливающий обработчик (в таблице это 0 3 12 any), который сохраняет выброшенное исключение, вызывает finally_body, а затем повторно выбрасывает сохранённое исключение. Аналогично, блок catch может выбрасывать исключение, поэтому он тоже обёртывается во всеулавливающий обработчик (в таблице это 9 13 12 any).
По какой-то причине область этого последнего всеулавливающего обработчика также распространяется на первую инструкцию самого обработчика. Я локализовала эту проблему вплоть до этой небесспорной строки в коде javac, но она там находится так давно, что сомневаюсь, что кто-нибудь возьмётся её трогать. Даже если рано или поздно этот недочёт исправят, старые файлы классов по-прежнему будут страдать от этой проблемы. Поэтому маловероятно, что можно надеяться когда-нибудь об этом забыть.
Инструкции, выбрасывающие исключения
Теперь вам, возможно, кажется, что инструкция astore_1 не может выбрасывать исключения, поэтому проблему должно быть легко исправить на этапе парсинга. Просто понижаем to до target, если в диапазоне target…to нет никаких инструкций, которые могут выбрасывать исключения. Но подоплёка этих решений гораздо шире, чем может показаться.
Во-первых, любая инструкция JVM может выбросить исключение. В спецификации JVM об этом сказано очень чётко: «VirtualMachineError “[…] может быть выброшено в любой момент эксплуатации Виртуальной машины Java». VirtualMachineError — это надкласс таких проблемных штук как OutOfMemoryError и StackOverflowError — думаю, вам не составит труда вообразить такой интерпретатор JVM, который выбрасывает StackOverflowError, когда внутренняя функция JVM выходит за пределы стека, либо представить OutOfMemoryError, когда не удаётся выделить память прямо на месте выполнения. Реалистичен даже такой сценарий, при котором исключение выбросит astore_1 — в случае, когда массив локальных переменных выделяется по запросу. Хорошо уже, что не приходится иметь дело с Thread.stop, который, начиная с Java 20, может где угодно выбрасывать произвольные исключения.
Но ложноположительные срабатывания (например, отлавливание исключения там, где оно не должно отлавливаться) — это лишь часть проблемы. Бывает и так, что мы можем получить ложноотрицательный результат. Рассмотрим следующий код:
static int method(boolean condition) {
try {
if (condition) {
return 1;
}
} finally {
finally_body();
}
return 2;
}
Наша основная цель здесь — создать оператор return внутри блока try…finally. Тогда как часть if (condition) и инициализация 1 попадают в пределы области try, самому return не обязательно должен предшествовать вызов finally_body, который должен находиться за пределами try. Так куда же пойдёт сама инструкция return? Оказывается, что javac генерирует её за пределами блока try:
Code:
// материал `if` .
0: iload_0
1: ifeq 11
// сохраняем `1` для `return` на будущее.
4: iconst_1
5: istore_1
// тело `finally`
6: invokestatic #7 // Метод finally_body:()V
// Загружаем возвращаемое значение и возвращаемся.
9: iload_1
10: ireturn
// Проваливаемся до цели, т.e., до конца тела `try`.
// `finally` body.
11: invokestatic #7 // Метод finally_body:()V
// Переход к 2`.
14: goto 23
// Обработчик исключения. Сохраняем исключение, выполняем тело `finally`, повторно выбрасываем исключение.
17: astore_2
18: invokestatic #7 // Метод finally_body:()V
21: aload_2
22: athrow
// `возвращаем 2`.
23: iconst_2
24: ireturn
Exception table:
from to target type
0 6 17 any
Судя по исходному коду, следовало бы ожидать возникновения исключений в ходе выполнения return, так, чтобы они отлавливались в блоке try — но этого не происходит. Однако определённо return может выбрасывать только такие VirtualMachineError, на которые можно закрыть глаза, верно? Не вполне: согласно спецификации JVM, return также может выбрасывать IllegalMonitorStateException. Например, в том случае, если несколько мониторов, которыми функция завладела в процессе выполнения, не были высвобождены к моменту возврата функции. Компилятор javac генерирует код, в котором такое поведение никогда не проявляется, и, поскольку мониторы несовместимы с корутинами, маловероятно, что в какой-то другой клиентской части эта фича будет активно использоваться. Но если байт-код на Java написан вручную, то не гарантировано, что он будет полностью корректен в этом отношении. Поэтому декомпилятору всё равно приходится учитывать при работе эту причуду проектирования.
Вас не впечатлит, как я решила эту проблему. Если мониторы поддаются статической проверке на корректность, то return не может выбрасывать исключения, и худшее, что может произойти — это нехватка памяти (OOM) или переполнение стека в то время, как astore ошибочно отловлено/не отловлено. Такое не может произойти ни на HotSpot, ни на какой-либо другой достаточно эффективной реализации JVM. Таким образом, можем исходить из того, что во всех отношениях и с любой точки зрения большинство инструкций не могут выбрасывать исключения. С другой стороны, если невозможно проверить, правильно ли оформлены мониторы, то декомпилятор всё равно не сможет выдавать код Java, поэтому и неважно, как именно javac интерпретирует полученный в результате псевдокод.
Достижимость
Прежде чем перейдём к обсуждению других тонких материй, хочу рассмотреть более простую тему.
Одна из странностей JVM заключается в том, что в ней есть два механизма проверки типов. Если компилятор байт-кода предоставляет таблицу (именуемую StackMapTable) с информацией о том, каков тип каждого элемента стека в любой момент, то JVM остаётся лишь удостовериться, что все операции правильно типизированы. Если такая таблица не предоставляется, то JVM приходится логически выводить типы. Поскольку на вывод типов требуется немало времени, StackMapTable обязательно должна присутствовать во всех файлах классов, начиная с Java 6. Правда, современные JVM по-прежнему способны загружать старые файлы классов, поэтому некоторое время придётся мириться с сосуществованием двух систем проверки типов.
Между двумя этими системами есть серьёзное отличие. Тогда как при проверке типов путём верификации (т.e., с использованием StackMapTable) проверяется каждая инструкция в байт-коде, при проверке типов путём логического вывода приходится довольствоваться проверкой лишь всех достижимых инструкций, поскольку программа не может знать, как скомпонован стек недостижимых инструкций. Таким образом, в старых файлах классов могут присутствовать недопустимые сочетания инструкций байт-кода, например, iconst_1; ladd, а в новых их не будет.
Насколько это важно при обработке исключений? Поскольку у строк в таблице исключений по два параметра to и target, которые на уровне кода Java обычно идут рядом (try заканчивается на }, за которым сразу следует catch (...) {), но на уровне байт-кода часто сильно разнесены (например, из-за того, что между ними встанет goto), можно по глупости попытаться расширить диапазон try прямо до target, если в диапазоне to…target не найдётся никаких инструкций, способных выбрасывать исключения. У такого расширения есть странный побочный эффект: если ранее ни одна инструкция из диапазона from…to не была достижима, но диапазон to…target достижим, то вы только что сделали обработчик исключений достижимым в коде, а в байт-коде он остался недостижимым. Из-за этого в старых файлах классов валидный код мог показаться некорректно типизированным. Это плохо!
Разумеется, вам может быть совсем не интересна обработка старых файлов классов, но время поговорить о том, почему такой «пластырь» всё равно работать не будет, сколько шансов ему ни давай.
Диапазоны
Вероятно, интуиция подсказывает, что один блок try…catch должен компилироваться в одну строку таблицы исключений, но на самом деле это не так. Поскольку блоки finally необходимо дублировать в каждой точке выхода, а мы, конечно же, не хотим отлавливать исключения внутри finally, на некоторые подобласти обработка ошибок распространяться не должна. Например:
try {
if (condition) {
return 1;
} else {
return 2;
}
} finally {
finally_body();
}
Code:
0: iload_0
1: ifeq 11
4: iconst_1
5: istore_1
// Точка выхода `return 1`
6: invokestatic #7 // Метод finally_body:()V
9: iload_1
10: ireturn
11: iconst_2
12: istore_1
// Точка выхода `return 2`
13: invokestatic #7 // Метод finally_body:()V
16: iload_1
17: ireturn
// Точка выхода при исключении.
18: astore_2
19: invokestatic #7 // Метод finally_body:()V
22: aload_2
23: athrow
Exception table:
from to target type
0 6 18 any
11 13 18 any
Даже при отсутствии блока finally, javac просто считает его пустым и всё равно исключает из диапазонов try операторы return и goto:
try {
if (condition) {
return 1;
} else {
return 2;
}
} catch (Exception ex) {
return 3;
}
Code:
0: iload_0
1: ifeq 6
4: iconst_1
5: ireturn // Исключения, возникающие в ходе этого `return`, не обрабатываются.
6: iconst_2
7: ireturn
8: astore_1
9: iconst_3
10: ireturn
Exception table:
from to target type
0 5 8 Class java/lang/Exception
6 7 8 Class java/lang/Exception
(Это также означает, что код между to и target не всегда ограничивается goto или return — он также может включать содержимое блока finally, для которого невозможно гарантировать, что исключения в нём выбрасываться не будут.)
Пожалуй, наиболее смущающее следствие из этого таково: притом, что диапазоны обработки ошибок могут пересекать границы конструкций в потоке управления (например, from может находиться вне if, а to — внутри if), диапазоны изъятия из зоны действия обработчика исключений соответствуют конкретным позициям в исходном коде, поэтому описанного выше пересечения с потоком управления не будет. Так что, с точки зрения декомпилятора, предыдущий код нужно распарсить вот так:
try #1 {
if (condition) {
int tmp = 1;
exempt #1 {
return tmp;
}
} else {
int tmp = 2;
exempt #1 {
return tmp;
}
}
} catch (Exception ex) {
}
return 3;
…а не создавать по блоку try для каждой строки. Тогда декомпилятор не сможет проверить, в самом ли деле блоки exempt присутствуют на каждом пути выхода из блока try и имеют подходящее содержимое — и упростить код до …finally. Детали этого процесса мутные, и я сама в них пока не до конца разобралась, но считаю, что это в принципе реализуемо за один шаг.
Заключение
Одна маленькая проблема, которую осталось затронуть — как должны выглядеть обработчики исключений в IR (промежуточном представлении). При входе в обработчик стек очищается, а объект исключения заносится в стек. Занимаясь декомпиляцией, я рассчитываю на некий линейный порядок, в котором идут отдельные инструкции из байт-кода — так куда же в данном случае пойдёт сохранение в стек? Эту операцию нельзя просто вставить на входе в обработчик, поскольку первая инструкция обработчика исключений также может быть достижима goto или другим явным механизмом потока управления, а не только try…catch. Нет иного выхода, кроме как расценивать такое сохранение в стек как особенную операцию и ввести её в IR несколько позднее, например, при создании синтаксического блока для try…catch.
Я хотела, чтобы этот пост рассказывал в большей мере о причудах Java, чем о моём собственном декомпиляторе, так что пока на этом всё.