Последнее изменение: 28 октября 2007г.
try
/catch
/finally
и исключения
Думаю, вряд ли найдется Java-разработчик, который хотя бы не слышал об исключениях. В принципе, они не являются исключительным свойством Java, есть они и в Delphi, и в C++. Однако в Java исключения используются особенно широко. И чаще всего с ними связано немало ошибок. Основная цель этой статьи – систематизация информации об исключениях, их обработке и т.п. А именно:
- Исключение как явление
- Классификация исключений
- Инициация исключений
- Обработка исключений
- Перехват исключений, вызвавших завершение потока
- Конструкция
try
/catch
/finally
– тонкости - Отсутствие транзакционности
Итак, к делу.
Исключение как явление
Что такое вообще исключение? Это сигнал о нестандартной – исключительной – ситуации. Ситуации могут быть самые различные – ожидаемые или нет, разной степени критичности. И относиться к этим ситуациям, естественно, приходится по-разному.
Как и всё в Java, исключения тоже представленны в виде классов. Корнем иерархии служит класс
java.lang.Throwable
, дословно – "бросаемый". Его прямыми наследниками являются java.lang.Exception и java.lang.Error, от которых и
унаследованы все остальные исключения. И от которых рекомендуется наследовать собственные.
Хочу подчеркнуть, что исключения не есть нечто из ряда вон выходящее. Это нормальный механизм. Потому не стоит стараться избегать их использования любой ценой. И тем более не стоит стараться избегать исключений в конструкторах, к чему склонны разработчики, хорошо знакомые с С++. Ввиду наличия сборщика мусора утечек памяти в этом случае не будет. Разве что очень постараться, я имею в виду намеренно...
Теперь рассмотрим типы исключений ближе.
Классификация исключений
Как я уже упоминал, есть два "базовых" типа исключений – java.lang.Exception
и java.lang.Error. Я бы сказал, что различаются степенью критичности. Не,
разумеется, оба типа могут быть перехвачены, ибо перехватывается исключение начиная с уровня
java.lang.Throwable
. Это разделение скорее логическое. Если произошло что-то серьезное – не найден метод,
который надо вызвать, закончилась память, переполнение стека, в общем, что-то, после чего восстановить
нормальную работу уже вряд ли реально – это ошибка, java.lang.Error. Если
продолжение работы теоретически возможно – это исключение, java.lang.Exception.
Кроме уже упомянутых двух типов существует также еще один класс, являющийся существенным – java.lang.RuntimeException. Он унаследован от java.lang.Exception и является корнем для всех исключений времени выполнения – т.е. возникающих при работе виртуальной машины. Нулевые ссылки, деление на ноль, неверное значение аргумента, выход за пределы массивы и т.п. – все это исключительные ситуации времени выполнения. Чем они отличаются от обычных, мы рассмотрим ниже.
Поскольку исключения – точно такие же классы, в них можно включать и собственные переменные, и даже какую-то
логику. Увлекаться, правда, не стоит. Дело в том, что исключение – класс все-таки выделенный. При создании
Throwable
существуют большие накладные расходы – заполнение стека вызова. Это достаточно длительная
процедура. И потому создавать исключение просто так – себе дороже. Его нужно создавать только тогда, когда
без него не обойтись, когда оно точно будет выброшено. Проще это делать сразу вместе с директивой
throw
.
А вот держать в классе исключения какую-то информацию, которая позволит его обработать – вполне допустимо. Передавать эту информацию лучше через конструктор.
Итак, приступим к более подробному рассмотрению. Первый базовый тип –
java.lang.Exception
Исключения этого типа я бы охарактерировал так – они возникают в ситуациях, которые нам неподконтрольны. Скажем, десериализуем мы класс, а данные в неверном формате. И мы с этим ничего не можем сделать, кроме как адекватно отреагировать.
Любое исключение типа java.lang.Exception
должно быть обработано. Это закон. За его исполнением
следит компилятор. Если исключение не обработано в методе – оно должно быть декларировано в его сигнатуре
и в этом случае обработано выше. Или еще выше. Но обработано оно будет. Единственное исключение – это
java.lang.RuntimeException, о котором мы поговорим дальше.
Метод может декларировать любое количестко исключений. Нет, любое – это, наверное, слишком сильно сказано. Если мне не изменяет память, количество исключений ограничено 64K – 65536 штук. Для меня это уже означает, что я могу декларировать столько исключений, сколько мне заблагорассудится.
Следующий тип –
java.lang.RuntimeException
Не знаю, насколько со мной согласятся, но я бы охарактеризовал этит тип так:
java.lang.RuntimeException
– это ошибки разработчика. Неверное состояние
монитора, деление на ноль, неверный аргумент, нулевая ссылка, выход за границы массива, неверное состояние
класса – все это можно предусмотреть и предупредить. Еще раз полностью просмотрев всю иерархию исключений
времени выполнения, я не нашел ни одного, которого нельзя было бы избежать какой-нибудь проверкой.
Поскольку ожидать ошибок – глупо, исключения типа java.lang.RuntimeException
не
декларируются. Компилятор это допускает, соответственно, позволяя их не обрабатывать. Более того, я бы сказал,
что ловить java.lang.RuntimeException
– дурной тон, ибо вместо того, чтобы устранять причину, мы
нейстрализуем последствия. И хуже всего то, что ошибки разработчика устранить во время выполнения практически
нереально, фактически, они по степени критичности приближены к java.lang.Error.
Перейдем теперь к последнему типу –
java.lang.Error
Как я уже упоминал, это критические ошибки, после которых восстановить нормальную работу практически
нереально. В самом деле – ну что сделаешь при переполнении стека? Или если не найден метод? Или если вызван
абстрактный метод? Или в случае ошибки в байт-коде класса? Абсолютно ничего. По крайней мере, без серьезного
вмешательства человека, который может заменить библиотеку, например. Или поменять classpath
.
В некоторых случаях ситуация не столь критична. Скажем, нехватка памяти, вызывающая
java.lang.OutOfMemoryError
. Если эта ошибка произошла в момент выделения большого объема памяти –
например, при создании массива, – ее можно перехватить и попытаться выделить память в меньших объемах, изменив
каким-то образом алгоритм, который будет эту память использовать.
Или, скажем, в случае, когда не JVM поддерживает системную кодировку – при попытке создать
InputStreamReader
без указания кодировки будет выброшена ошибка. Однако нужна ли нам системная кодировка
и насколько для нас критично ее отсутствие – решать нам.
Как и в случае с java.lang.RuntimeException компилятор разрешает не
декларировать и не обрабатывать ошибки из иерархии java.lang.Error
. Перехватывать их или нет –
решать вам. Зависит от того, можете ли вы предложить приемлемый алгоритм для восстановления.
С классификацией закончили, переходим к следующему вопросу.
Инициация исключений
А вопрос следующий – что и когда бросать.
Представим себе, что у вас создалась исключительная ситуация и работать код дальше не может. Какой именно тип исключения использовать?
Тут самое время воспользоваться характеристиками исключений, приведенными выше. Если возникшая исключительная
ситуация такова, что дальнейшая работа невозможна в принципе – можно бросить исключение из иерархии
java.lang.Error. Хотя у меня лично таких ситуаций не было в практике. Если
исключительная ситуация возникла по вине внешних обстоятельств или пользователя приложения – не те данные дали,
например, – то это случай, когда ошибки должны быть обработаны, следовательно, лучше всего использовать
java.lang.Exception. Если же ситуация возникла по вине разработчика – например,
передали вам в качестве параметра null
, при том что в javadoc вы английским по белому написали, что
этого делать нельзя – тогда это java.lang.RuntimeException.
При написании бизнес-логики – в особенности это касается библиотек – часто бывает полезным создать собственный тип исключения. Просто для того, чтобы дать возможность обрабатывать ваши исключения отдельно от остальных. Подробнее об обработке – в следующем разделе.
Пара слов насчет приведенного примера – null
в качестве параметра. Теоретически в
этой ситуации можно бросить два исключения – NullPointerException
и
IllegalArgumentException
. Нескотря на кажущуюся очевидность выбора я лично предпочитаю второй вариант.
На мой взгляд NullPointerException
должна бросать исключительно виртуальная машина. Если же я сам
проверил значение и убедился, что оно равно null
, а этого быть не должно – гораздо более
информативно использовать IllegalArgumentException
, если это значение аргумента, или же
IllegalStateException
– если это значение члена класса.
Ну ладно, что бросить именно – это мы как-нибудь определим. Но иногда возникает какое-то иррациональное желание не только бросить исключение, но и как-то его конкретизировать, указав причину. Не, можно, конечно, и по исключению на каждую ошибку создать, но сойти с ума намного проще и быстрее.
Первый вариант, который видится – текстовое сообщение. Практически у любого класса исключения есть
конструктор, принимающий в качестве параметра текст. Именно этот текст фигурирует в консоли при печати сообщения
об исключени, его же можно получить через getMessage()
. Это позлезно, когда ошибку обнаруживаем мы
сами. А что делать, когда ошибка для нас тоже "внешняя", скажем – мы поймали исключение, брошенное глубже?
В этом случае может выручить механизм т.н. exception chaining – связывания исключений. Практически
у каждого класса исключения есть конструктор, принимающий в качестве параметра Throwable
– причину
исключительной ситуации. Если же такого конструктора нет – у все того же Throwable
, от которого
унаследованы все исключения, есть метод initCause(Throwable)
, который можно вызвать ровно один раз.
И передать ему исключение, явившееся причиной того, что было инициировано следующее исключение.
Зачем это нужно. Дело в том, что хорошо спроектированный код – он как черный ящик. У него есть свои интерфейсы, определенное поведение, набор исключительных ситуаций, наконец. А что происходит внутри – это не играет роли для того, что находится снаружи. Более того – иногда может сыграть и обратный эффект. Причин для ошибки может быть добрый десяток, и ловить их все отдельно и так же обрабатывать... Чаще всего просто не нужно. Именно потому проще определить, например, свое исключение (ну или использовать имеющееся, не суть важно) и бросить именно его, указав как причину то, которое мы поймали. И волки сыты, и пользователям кода работы меньше.
Ну вот, от инициации исключений мы плавно переходим к их обработке.
Обработка исключений
Казалось бы, чего проще? Написал catch(Throwable th)
– и всё. Однако именно от этого я их хочу
предостеречь.
Во-первых, как я уже говорил выше, ловить исключения времени выполнения – дурной тон. Они свидетельствуют об ошибках. Ловить java.lang.Error или производные стоит только если вы точно знаете, что делаете. Восстановление после таких ошибок не всегда возможно и почти всегда нетривиально.
Во-вторых – как именно обрабатывать? Я об этом подробно писал в статье о качестве кода, раздел
Обработка исключений, но повторюсь. По каждому
пойманому исключению необходимо принимать решение. Просто проглотить его или вывести в консоль – самое
неприятное, что можно придумать. А этим грешат, к сожалению, многие. Человек читает XML из файла, получает
исключение, глотает его – и пытается дальше работать с этим XML так, как будто ничего не произошло. Естественно
получает NullPointerException
, поскольку класс, который он использует, не инициализировался. Ошибка
разработчика, хотя и не столь очевидная.
Почему я говорил о том, что стоит заводить свои типы исключений – так вы проще сможете их выделить на стадии
обработки. Представьте себе, что существуют пять причин, по которым может быть выброшено исключение, и во всех
пяти случаях бросается java.lang.Exception
. Вы же спятите разбираться, чем именно это исключение
вызвано. А если это будет пять разных типов – тут уже проще простого. На каждый – свой блок catch
.
И третье – что делать с иерархиями исключений. Пусть у вас метод может выбросить как
IOException
, так и Exception
. Так вот, совершенно не все равно, в каком порядке будут стоять
блоки catch
, поскольку они обрабатываются ровно в той последовательности, как объявлены. И если
первым будет catch(Exception ex)
– до второго (catch(IOException ioex)
) управление
просто не дойдет. Компилятор об этом, конечно, предупредит, более того – это считается ошибкой. Тем не менее
об этом стоит помнить.
Следующее, чего бы я хотел коснуться –
Перехват исключений, вызвавших завершение потока
При использовании нескольких потоков бывают ситуации, когда надо знать, как поток завершился. В смысле – если
это произошло из-за исключения, то из-за какого именно. Для этой цели начиная с версии Java 5 существует
специальный интерфейс – Thread.UncaughtExceptionHandler
. Его реализацию можно установить нужному
потоку с помощью метода setUncaughtExceptionHandler
. Можно также установить обработчик по умолчанию
с помощью статического метода Thread.setDefaultUncaughtExceptionHandler
.
Интерфейс Thread.UncaughtExceptionHandler
имеет один единственный метод –
uncaughtException(Thread t, Throwable e)
– в который передается экземпляр потока, завершившегося
исключением, и экземпляр самого исключения. Восстановить работу потока, естественно, уже не удастся, но
зафиксировать факт его ненормального завершения таким образом можно.
Следующее, о чем я хотел бы поговорить – сама конструкция
try
/catch
/finally
.
Конструкция try
/catch
/finally
– тонкости
С блоком try
, думаю, вопросов не возникает. С блоком catch
– надеюсь, уже тоже нет.
Остается блок finally
. Что это, и с чем его едят?
Блок finally
идет в самом конце конструкции
try
/catch
/finally
. Ключевая его особенность в том, что он выполняется
всегда, вне зависимости от того, сработал catch
или нет.
Зачем это нужно. Посмотрите вот на этот фрагмент кода:
try { Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/db"); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("select * from my_table"); rs.first(); while (rs.next()) { // do something } rs.close(); stmt.close(); conn.close(); } catch (SQLException ex) { // handle exception }
Казалось бы все в порядке. А что будет, если, например, при вызове rs.first()
произойдет
исключение? Да, оно обработается. Однако ни ResultSet
, ни Statement
, ни
Connection
закрыты не будут. Последствия весьма печальны – исчерпание открытых курсоров, соединений в
пуле и т.п.
Можно, конечно, поместить этот же код и в блок catch
. Однако дублировать код – не лучшая идея.
Правильнее вынести код закрытия в блок finally
, в этом случае он будет исполнен как в случае
возникновения исключения, так и в случае, когда этого не происходит.
Рассмотрим еще один пример:
package test; /** * FinallyTest * * @author Eugene Matyushkin */ public class FinallyTest{ public static int stringSize(Object s) { try { return s.toString().length(); } catch (Exception ex) { return -1; } finally { return 0; } } public static void main(String[] args){ System.out.println("stringSize(\"string\"): "+stringSize("string")); System.out.println("stringSize(null): "+stringSize(null); } }
Вопрос на засыпку – каков будет результат? Выполняем тест и получаем:
stringSize("string"): 0 stringSize(null): 0
Т.е. в обоих случаях результат будет одинаковый, причем не тот, которого можно было бы ожидать!
Почему так происходит? В первом случае – из блока try
должно вернуться значение 6 – длина
переданной строки. Однако выполняется блок finally
, в котором возвращается 0. В результате
исходное значение теряется. Во втором случае – попытка вызвать toString()
при переданном
значении null
приведет к исключению – NullPointerException
. Это исключение будет
перехвачено, т.к. является наследником Exception
. Из блока catch
должно вернуться
значение -1, однако выполняется блок finally
и возвращается опять-таки 0, а -1 – теряется!
Точно так же блок finally
может вызвать потерю исключений. Посмотрите вот на этот пример:
package test; import java.io.IOException; /** * ExceptionLossTest */ public class ExceptionLossTest{ public static void main(String[] args){ try { try { throw new Exception("a"); } finally { if (true) { throw new IOException("b"); } System.err.println("c"); } } catch (IOException ex) { System.err.println(ex.getMessage()); } catch (Exception ex) { System.err.println("d"); System.err.println(ex.getMessage()); } } }
Этот тест очень часто давался на интервью. И правильно отвечали единицы. Между тем – результатом его
выполнения будет вывод в консоль b. И только. После инициации первого исключения – new
Exception("a")
– будет выполнен блок finally
, в котором будет брошено исключение new
IOException("b")
. И именно это исключение будет поймано и обработано. Исходное же исключение теряется.
Хотелось бы затронуть еще один момент. В каких комбинациях могут существовать блоки try
,
catch
и finally
? Понятно, что try
– обязателен. Он может быть совместно с
catch
, либо с catch
и finally
одновременно. Отдельно try
не
используется. А есть ли еще варианты?
Есть. try
может быть в паре с finally
, без catch
. Работает это точно
так же – после выхода из блока try
выполняется блок finally
. Это может быть полезно,
например, в следующей ситуации. При выходе из метода вам надо произвести какое-либо действие. А return
в этом методе стоит в нескольких местах. Писать одинаковый код перед каждым return
нецелесообразно. Гораздо проще и эффективнее поместить основной код в try
, а код, выполняемый
при выходе – в finally
.
И последняя тема –
Отсутствие транзакционности
В самом начале я упоминал о том, что организовать утечку памяти при использовании исключений в конструкторах сложно. На самом деле – можно. Смотрим на пример.
package test; /** * PartialInitTest * * @author Eugene Matyushkin */ public class PartialInitTest{ static PartialInitTest self; private int field1 = 0; private int field2 = 0; public PartialInitTest(boolean fail) throws Exception{ self = this; field1 = 1; if (fail) { throw new Exception(); } field2 = 1; } public boolean isConsistent(){ return field1 == field2; } public static void main(String[] args){ PartialInitTest pit = null; try { pit = new PartialInitTest(true); } catch (Exception ex){ // do nothing } System.out.println("pit: "+pit); System.out.println("PartialInitTest.self reference: "+PartialInitTest.self); System.out.println("PartialInitTest.self.isConsistent(): "+PartialInitTest.self.isConsistent()); } }
Итак, что тут происходит. В конструкторе мы присваиваем ссылку на созданный объект статической переменной.
Дальше мы инициализируем одно поле и... бросаем исключение. Я это делаю намеренно, однако это может произойти и
в результате выполнения кода конструктора. В методе main
это исключение обрабатывается. Однако
ссылка на объект – существует! В чем можно убедиться, выполнив приведенный код:
pit: null PartialInitTest.self reference: test.PartialInitTest@1e0bc08 PartialInitTest.self.isConsistent(): false
Переменная метода main
равна null
, как и положено. А вот статическая переменная
self
– нет. Через нее можно получить доступ к объекту, инициализация которого была прервана
исключением. И объект этот, естественно, в неверном состоянии. Вот она – утечка памяти. Объект не будет удален
до тех пор, пока на него есть ссылка.
Пример этот искуственный, но лишь до известной степени. Он говорит об очень важной особенности исключений:
Cвойством транзакционности исключения не обладают – действия, произведенные в блоке
try
до возникновения исключения, не отменяются поcле его возникновения.
И это необходимо учитывать. Применительно к приведенному примеру – стоит все операции, могущие привести к
исключению, выполнять как можно раньше. Например, проверять переданные параметры сразу же. В некоторых случаях
для отмены уже произведенных действий может пригодиться блок finally
. А если очень надо где-то
сохранить ссылку на создаваемый объект, причем сделать это необходимо в конструкторе – лучше всего оставить
это напоследок, когда весь критический код уже выполнен.
* * *
Ну, вот и все. Думаю, большая часть этого материала вам была знакома. Во всяком случае, надеюсь на это. Моей целью было просто упорядочить информацию для тех, кто с исключениями пока еще знаком поверхностно. Всем спасибо!