Новый перевод от команды Spring АйО расскажет вам (с примерами кода), как JVM обрабатывает исключения на низком уровне, что такое таблица исключений и какие сценарии используются для вариантов try-catch и try-finally.


То, как JVM обрабатывает инструкции на байткоде, уже само по себе довольно интересно. Но знаете ли вы, что происходит, когда выбрасывается исключение? Как JVM справляется с передачей управления? Как это выглядит в байткоде? 

Сейчас я вам по-быстренькому это покажу! 

Пример

Вот небольшой кусочек кода на Java, в котором присутствуют все важные действующие лица, участвующие в игре в исключения (try-catch-finally):

int a = 0;
try {
    if (a == 0) { // to make it less boring, let's have some branching logic
        throw new Exception("Exception message!");
    }
} catch (Exception e) {
    doSomethingOnException();
} finally {
    doSomethingFinally();
}

Таблица исключений

Получившийся байткод включает в себя интересный фрагмент внутри атрибута Code, который называется таблицей исключений. Каждый метод может иметь свою собственную таблицу исключений, но она включается в код только тогда, когда ее присутствие имеет смысл. Если в методе отсутствует логика обработки исключений, то и таблицы исключений тоже не будет. 

В нашем примере таблица исключений выглядит вот так:

Exception table:
   from    to  target type
       2    16    22   Class java/lang/Exception
       2    16    32   any
      22    26    32   any

Числа указывают на адреса байткод-инструкций. Каждая строка в таблице показывает диапазон байткод-инструкций (from и to), за которым следит обработчик исключений. Сам обработчик — это тоже всего лишь набор байткод-инструкций, а колонка target указывает на адрес, где начинается код обработки. type просто задает тип исключения, который обрабатывается тем или иным обработчиком. 

Байткод-инструкции 

Чтобы точно понять, куда ведут эти адреса, давайте посмотрим также на набор байткод-инструкций для данного метода (объяснение приведено ниже):

 0: iconst_0
 1: istore_0
 2: iload_0
 3: ifne          16
 6: new           #12          // class java/lang/Exception
 9: dup
10: ldc           #14           // String Exception message!
12: invokespecial #16     // Method java/lang/Exception."<init>":(Ljava/lang/String;)V
15: athrow
16: invokestatic  #19      // Method doSomethingFinally:()V
19: goto          38
22: astore_1
23: invokestatic  #22      // Method doSomethingOnException:()V
26: invokestatic  #19      // Method doSomethingFinally:()V
29: goto          38
32: astore_2
33: invokestatic  #19      // Method doSomethingFinally:()V
36: aload_2
37: athrow
38: return

Давайте пройдемся по этому коду.

Инструкции 0 - 2 отвечают за создание переменной int a = 0;.

Дальше идет инструкция ifne, которая представляет собой условный переход, а 16 — это адрес, на который надо перейти. Если a не равна 0, мы переходим на адрес 16. Там мы находим содержимое нашего блока finally, за которым идет инструкция goto, ведущая в конец метода.

Если условие принимает значение false, мы переходим к следующим инструкциям. Инструкции 6 - 12 создают экземпляр класса Exception. Заметьте, что ссылка на этот экземпляр дублируется, поэтому к концу этого набора инструкций она появится в стеке операндов дважды.

Инструкция с адресом 15, athrow, выбрасывает само исключение! Она “съедает” одну из ссылок на экземпляр Exception из стека операндов и таким образом узнает, какое именно исключение следует выбросить. По факту, она подготавливает стековый фрейм для обработки исключения (очищая его), оставляя только одну ссылку на исключение сверху.

Когда JVM встречает инструкцию athrow, она проверяет таблицу исключений метода, чтобы найти подходящее местоположение, с которого следует для продолжить выполнение. 

Сценарий try-catch-finally 

Давайте посмотрим на таблицу исключений еще раз, ведь теперь у нас есть больше контекста.

Exception table:
   from    to  target type
       2    16    22   Class java/lang/Exception
       2    16    32   any
      22    26    32   any

Мы обнаружили athrow по адресу 15. Этот адрес покрывается первыми двумя дорожками таблицы, поскольку попадает в диапазон [2, 16).

Зачем нужны два обработчика? Один — это блок catch (target показывает на 22, откуда начинается логика catch), а второй — это блок finally (target 32 указывает на логику finally). Третья дорожка в таблице закрывает диапазон инструкций, содержащих нашу логику catch, а target указывает на блок finally. Это означает, что если во время выполнения блока catch что-то случится, мы все еще хотим оказаться в блоке finally.

Когда мы находимся в блоке catch (адрес 22), какие-то данные из стека операндов сохраняются в переменной. Помните, у нас была дополнительная ссылка на экземпляр Exception в нашем стеке? Здесь инструкция astore сохраняет ее как переменную. Внутри JVM переменная не имеет имени, но по логике вещей это наша переменная e, которую мы можем использовать в блоке catch для логирования или других действий.

Менее удачный сценарий

Описанный выше сценарий — самый удачный. JVM поискала по таблице исключений и нашла обработчик, который знал, что делать с Exception. Если бы существовал только обработчик finally, этого было бы недостаточно.

Как JVM узнает, относится ли найденный обработчик к catch или к finally? Мы видим, что колонка type для обработчика finally содержит тип any, что означает, что он должен быть выполнен в любом случае, независимо от того, было выброшено исключение или нет. Если вместо этого значения задать реальный тип, это будет обработчик для catch. Тип, который ищет JVM, является точным совпадением с исключением или его супертипом.

Но что если подходящего обработчика нет? JVM завершает текущий метод и передаёт исключение вызывающему методу. Если в стеке вызовов нет подходящего обработчика, поток завершает работу с трассировкой стека.

Обобщенный сценарий

Механизм try-catch-finally можно обобщить следующим образом:

  • Инструкция athrow говорит JVM, что выброшено исключение. Какое исключение? То, что находится сверху в стеке операндов.

  • Когда это происходит, JVM проверяет таблицу исключений для текущего метода. Получив адрес инструкции athrow, она может найти адреса подходящих обработчиков (или одного обработчика).

  • Если обнаружен обработчик catch, (обработчик, который имеет правильный тип исключения в колонке type), ссылка на исключение сохраняется из стека в переменную. Затем выполняется логика catch.

  • Если найден обработчик finally, он выполняется.

  • Если не найден подходящий обработчик catch, JVM прерывает выполнение текущего фрейма и ищет обработчик в предыдущем фрейме.

  • JVM проходит через все фреймы, пока не найдет обработчик catch, выполняя все обработчики finally, обнаруженные по пути.

  • Если не найден ни один обработчик, выполнение программы прекращается.


Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм - Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано

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