Компиляция Try/Catch/Finally для JVM

в 13:15, , рубрики: bytecode, gosu, java, jvm, try-catch-finally, байт-код

Вместо введения

Автор статьи, Alan Keefer1, является главным архитектором компании Guidewire Software2, разрабатывающей программное обеспечение для страхового бизнеса. Еще будучи старшим разработчиком, он участвовал в работе над языком Gosu3. В частности, Алан занимался вопросами компиляции языка в байт-код Java.

Данная статья написана в 2009 году и посвящена деталям реализации try/catch/finally в JVM версии 1.6. Для ее прочтения необходимо иметь базовые знания синтаксиса Java, а также понимать назначение байт-кода, простыни которого лежат под катом. Также в конце статьи приведен ряд примеров, похожих на каверзные задачи SCJP.

Внутренности JVM

Одной из вещей, над которой по целому ряду причин мы сейчас работаем, является компиляция нашего «домашнего» языка в байт-код Java. (Для справки: не могу сказать, когда мы закончим. Даже примерно. Даже попадет ли он в будущие релизы.) Веселье заключается в изучении внутренностей JVM, а также поиске всех долбанутых острых углов собственного языка. Но больше всего «веселья» и острых углов доставляют такие операторы, как try/catch/finally. Поэтому, на этот раз, я не буду вдаваться в философию или аджайл. Вместо этого я углублюсь в JVM, куда большинству не требуется (или не хочется) углубляться.

Если бы две недели назад вы спросили меня о finally-блоках, я бы предположил, что их обработка реализована в JVM: это базовая часть языка, она должна быть встроенной, не так ли? Каково же было мое удивление, когда я узнал: нет, не так. На самом деле finally-блоки просто подставляются во все возможны места после try- или связанных с ним catch-блоков. Эти блоки оборачиваются в «catch(Throwable)», который повторно выбросит исключение после того, как finally-блок закончит работу. Осталось только подкрутить таблицу исключений, чтобы подставленные finally-блоки были пропущены. Ну как? (Небольшой нюанс: до версии JVM 1.6 для оператора finally, по всей видимости, использовались подпограммы вместо полной подстановки. Но сейчас мы говорим о версии 1.6, к которой все вышесказанное применимо.)

Чтобы понять, имеет ли такой подход смысл, отмотаем немного назад и посмотрим, как JVM обрабатывает исключительные ситуации. Их обработка встроена в JVM в виде декларирования try/catch-блоков с помощью специального метода. Все, что от вас требуется, это сказать «между точкой A и точкой B любое исключение типа E должно быть обработано кодом в точке C». Вы можете иметь столько таких деклараций, сколько потребуется. Когда исключение будет передано в этот метод, JVM найдет соответствующий catch-блок в зависимости от его типа.

Простой пример try/catch-блока

Рассмотрим простой пример:

public void simpleTryCatch() {
  try {
    callSomeMethod();
  } catch (RuntimeException e) {
    handleException(e);
  }
}

Для него, в конечном итоге, вы получите байт-код, представленный ниже. (Я использую форматирование, которое предлагает ASM Eclipse — бесценный инструмент для изучения механизмов работы JVM. Мне кажется, что код в таком формате довольно легко читать. «L0» и т.п. — это метки кода.)

public simpleTryCatch()V
TRYCATCHBLOCK L0 L1 L2 java/lang/RuntimeException
L0
  ALOAD 0
  INVOKEVIRTUAL test/SimpleTryCatch.callSomeMethod()V
L1
  GOTO L3
L2
  ASTORE 1
  ALOAD 0
  ALOAD 1
  INVOKEVIRTUAL test/SimpleTryCatch.handleException(Ljava/lang/RuntimeException;)V
L3
  RETURN

Итак, мы говорим оператору catch покрывать весь try-блок целиком (но не оператор GOTO в конце), а в случае RuntimeException передать управление в L2. Если оператор try завершен, нужно перепрыгнуть через оператор catch и продолжить выполнение. Если же вызван обработчик RuntimeException, исключение находится на вершине стека, и мы сохраняем его в локальную переменную. Затем мы загружаем указатель на «this» и исключение в таком порядке, чтобы вызвать метод handleException. После этого оставшийся код выполняется до конца. Однако если бы здесь был дополнительный catch-блок, мы бы его перепрыгнули.

Пример try/catch/finally-блока

Теперь добавим finally-блок и дополнительный оператор catch и посмотрим, что произойдет в байт-кодом. Возьмем следующий совершенно надуманный пример:

public void tryCatchFinally(boolean arg) {
  try {
    callSomeMethod();
    if (arg) {
      return;
    }
    callSomeMethod();
  } catch (RuntimeException e) {
    handleException(e);
  } catch (Exception e) {
    return;
  } finally {
    callFinallyMethod();
  }
}

В таком случае мы получим куда как менее понятный байт-код:

public tryCatchFinally(Z)V
TRYCATCHBLOCK L0 L1 L2 java/lang/RuntimeException
TRYCATCHBLOCK L3 L4 L2 java/lang/RuntimeException
TRYCATCHBLOCK L0 L1 L5 java/lang/Exception
TRYCATCHBLOCK L3 L4 L5 java/lang/Exception
TRYCATCHBLOCK L0 L1 L6
TRYCATCHBLOCK L3 L7 L6
TRYCATCHBLOCK L5 L8 L6
L0
  ALOAD 0
  INVOKEVIRTUAL test/SimpleTryCatch.callSomeMethod()V
L9
  ILOAD 1
  IFEQ L3
L1
  ALOAD 0
  INVOKEVIRTUAL test/SimpleTryCatch.callFinallyMethod()V
L10
  RETURN
L3
  ALOAD 0
  INVOKEVIRTUAL test/SimpleTryCatch.callSomeMethod()V
L4
  GOTO L11
L2
  ASTORE 2
L12
  ALOAD 0
  ALOAD 2
  INVOKEVIRTUAL test/SimpleTryCatch.handleException(Ljava/lang/RuntimeException;)V
L7
  ALOAD 0
  INVOKEVIRTUAL test/SimpleTryCatch.callFinallyMethod()V
  GOTO L13
L5
  ASTORE 2
L8
  ALOAD 0
  INVOKEVIRTUAL test/SimpleTryCatch.callFinallyMethod()V
  RETURN
L6
  ASTORE 3
  ALOAD 0
  INVOKEVIRTUAL test/SimpleTryCatch.callFinallyMethod()V
  ALOAD 3
  ATHROW
L11
  ALOAD 0
  INVOKEVIRTUAL test/SimpleTryCatch.callFinallyMethod()V
L13
  RETURN

Итак, что же здесь происходит? (Обратите внимание, что метки пронумерованы в том порядке, в каком они созданы компилятором, а не в порядке их появления в коде.) В первую очередь вы заметите, что оба блока обработки исключений теперь разделены на два: с L0 до L1 и с L3 до L4. Это произошло по причине того, что между L1 и L3 из-за оператора return был вставлен finally-блок.

По той причине, что исключения, брошенные из finally-блока, не должны быть обработаны catch-блоками, связанными с тем же оператором try, соответствующий диапазон был убран из таблицы исключений. Записи в таблице без типа исключения как раз относятся к finally-блоку. Они должны обрабатывать исключения любого типа, брошенные из оператора try или из catch-блоков, при этом они должны игнорировать любые подставленные finally-блоки. Таким образом finally-блоки не будут ловить исключения, брошенные такими же finally-блоками. Подобных записей получилось три, потому что в дополнение к finally, подставленному внутрь try-блока, блок «catch(Exception)» так же содержит оператор return.

Так же вы можете удивиться увидев, что finally-блок встречается в коде 5 (пять) раз. Первый подставленный finally, соответствующий оператору return try-блока, встречается между L1 и L3. Второй finally-блок чуть более запутан: он подставлен в конец первого catch-блока, который затем прыгает через остальной finally код. (Лично я полагаю, что здесь нужно было сделать переход в конец вместо очередного встраивания.) Третий раз он появляется между L8 и L6 перед оператором return во втором catch-блоке. Четвертый раз finally-блок появляется в коде между L6 и L11, что соответствует случаю возникновения исключительной ситуации: нужно быть уверенными, что finally-блок выполнится в случае необработанного исключения, брошенного в try-блоке или любом catch-блоке. Обратите внимание, что исключение как ни в чем не бывало сохраняется, осуществляется вызов оператора finally, после чего исключение загружается и бросается снова. В последний finally-блок управление переходит из конца try-блока.

Если бы у нас были вложенные блоки try/catch или try/finally, все было бы еще более странным. Оператор return внутреннего try-блока требует, чтобы перед ним были подставлены finally-блоки как внутреннего, так и внешнего try. Таблица исключений должна быть настроена таким образом, чтобы исключение, брошенное внутренним finally, было поймано внешними операторами catch и finally, а исключение, брошенное внешним finally, не было поймано ни кем. Сейчас вы, наверное, пытаетесь представить, какой набор состояний ваш компилятор вынужден таскать с собой, чтобы знать, что куда подставить и как заполнить таблицу исключений.

Интересно было бы узнать, по крайней мере мне, как создатели JVM решились засунуть оператор finally в компилятор вместо того, чтобы встроить его в виртуальную машину. Очевидно, что выполнение этой работы компилятором позволяет значительно упростить виртуальную машину, однако делает жизнь немного сложнее для людей вроде нас, которые создают другой язык для JVM.

Нестандартные примеры

Понимание того, как реализован компилятор, помогает проще разбираться в некоторых нестандартных случаях. Например:

try {
  return "foo";
} finally {
  return "bar";
}

Результат будет «bar», потому что оператор finally будет подставлен перед оператором return, а значит return из finally-блока будет вызван первым, а return из try-блока вообще не будет вызван.

String value = "foo";
try {
  return value;
} finally {
  value = "bar";
}

Результат будет «foo», потому что значение для оператора return будет положено в стек до того, как будет вызван оператор finally, после которого оно будет восстановлено и возвращено. (Мой пример это не показывает, но это именно то, что вы увидите, если посмотрите байт-код.) Таким образом, изменение значения «value» в блоке finally не имеет ни какого значения для оператора return. И напоследок, что-то вроде:

while(true) {
  try {
    return "foo";
  } finally {
    break;
  }
}
return "bar";

Результат будет «bar». Это было удивлением даже для меня, однако все логично, если вы знаете, что оператор break всего лишь GOTO в байт-коде. Т.е. когда finally-блок подставляется как часть внутреннего оператора return, оператор GOTO вызывается раньше, чем инструкция RETURN, что приводит к выходу из цикла. (То же самое для оператора continue внутри finally-блока.)

Заключение

С нашей стороны, мы решили запретить операторы return, break и continue внутри finally-блоков из-за неопределенной семантики, как это сделано в C#. (И я чувствую, что собралась хорошая компания из тех, кто принял такое решение.)

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

Глоссарий

подпрограмма = sub-routine
оператор = statement
подстановка = inlining
исключение = exception
внешняя функция = enhancement = extension function = mixin

Ссылки

[1] devblog.guidewire.com/author/akeefer/
[2] www.guidewire.com
[3] gosu-lang.org

Автор: artspb

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js