Последнее изменение: 28 октября 2007г.

try/catch/finally и исключения

Думаю, вряд ли найдется Java-разработчик, который хотя бы не слышал об исключениях. В принципе, они не являются исключительным свойством Java, есть они и в Delphi, и в C++. Однако в Java исключения используются особенно широко. И чаще всего с ними связано немало ошибок. Основная цель этой статьи – систематизация информации об исключениях, их обработке и т.п. А именно:

Итак, к делу.

Исключение как явление

Что такое вообще исключение? Это сигнал о нестандартной – исключительной – ситуации. Ситуации могут быть самые различные – ожидаемые или нет, разной степени критичности. И относиться к этим ситуациям, естественно, приходится по-разному.

Как и всё в 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. А если очень надо где-то сохранить ссылку на создаваемый объект, причем сделать это необходимо в конструкторе – лучше всего оставить это напоследок, когда весь критический код уже выполнен.

* * *

Ну, вот и все. Думаю, большая часть этого материала вам была знакома. Во всяком случае, надеюсь на это. Моей целью было просто упорядочить информацию для тех, кто с исключениями пока еще знаком поверхностно. Всем спасибо!