Компіляція і декомпіляція 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 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.

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


Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.