«1. Обзор

В этом руководстве мы рассмотрим основы обработки исключений в Java, а также некоторые из его ошибок.

2. Первые принципы

2.1. Что это?

Чтобы лучше понять исключения и их обработку, давайте проведем сравнение из реальной жизни.

Представьте, что мы заказываем товар онлайн, но в пути происходит сбой доставки. Хорошая компания может справиться с этой проблемой и изящно перенаправить нашу посылку так, чтобы она все же прибыла вовремя.

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

2.2. Зачем это использовать?

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

Однако в производственной среде файловые системы могут быть повреждены, сети могут выйти из строя, а у JVM может не хватить памяти. Благополучие нашего кода зависит от того, как он справляется с «несчастливыми путями».

Мы должны обрабатывать эти условия, потому что они негативно влияют на поток приложения и формируют исключения:

public static List<Player> getPlayers() throws IOException {
    Path path = Paths.get("players.dat");
    List<String> players = Files.readAllLines(path);

    return players.stream()
      .map(Player::new)
      .collect(Collectors.toList());
}

Этот код предпочитает не обрабатывать IOException, вместо этого передавая его вверх по стеку вызовов. В идеализированной среде код работает нормально.

Но что может произойти в продакшене, если отсутствует player.dat?

Exception in thread "main" java.nio.file.NoSuchFileException: players.dat <-- players.dat file doesn't exist
    at sun.nio.fs.WindowsException.translateToIOException(Unknown Source)
    at sun.nio.fs.WindowsException.rethrowAsIOException(Unknown Source)
    // ... more stack trace
    at java.nio.file.Files.readAllLines(Unknown Source)
    at java.nio.file.Files.readAllLines(Unknown Source)
    at Exceptions.getPlayers(Exceptions.java:12) <-- Exception arises in getPlayers() method, on line 12
    at Exceptions.main(Exceptions.java:19) <-- getPlayers() is called by main(), on line 19

Без обработки этого исключения исправная программа может вообще перестать работать! Нам нужно убедиться, что в нашем коде есть план на случай, если что-то пойдет не так.

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

Время выполнения и непроверенные исключения относятся к одному и тому же. Мы часто можем использовать их взаимозаменяемо.

3.1. Проверяемые исключения

              ---> Throwable <--- 
              |    (checked)     |
              |                  |
              |                  |
      ---> Exception           Error
      |    (checked)        (unchecked)
      |
RuntimeException
  (unchecked)

Проверяемые исключения — это исключения, которые компилятор Java требует от нас обработки. Мы должны либо декларативно выбросить исключение вверх по стеку вызовов, либо обработать его самостоятельно. Подробнее об обоих из них в данный момент.

    Документация Oracle говорит нам использовать проверенные исключения, когда мы можем разумно ожидать, что вызывающая сторона нашего метода сможет восстановиться.

Несколько примеров проверенных исключений: IOException и ServletException.

3.2. Непроверенные исключения

Непроверенные исключения — это исключения, которые компилятор Java не требует от нас обработки.

Проще говоря, если мы создадим исключение, которое расширяет RuntimeException, оно будет непроверено; в противном случае он будет проверен.

И хотя это звучит удобно, документация Oracle говорит нам, что есть веские причины для обеих концепций, например, различие между ситуационной ошибкой (проверено) и ошибкой использования (непроверено).

Некоторыми примерами непроверенных исключений являются NullPointerException, IllegalArgumentException и SecurityException.

3.3. Ошибки

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

И хотя они не расширяют RuntimeException, они также не проверяются.

В большинстве случаев нам было бы странно обрабатывать, создавать экземпляры или расширять ошибки. Обычно мы хотим, чтобы они распространялись до самого верха.

Несколько примеров ошибок: StackOverflowError и OutOfMemoryError.

4. Обработка исключений

В Java API есть множество мест, где что-то может пойти не так, и некоторые из этих мест отмечены исключениями либо в подписи, либо в Javadoc:

«

«Как было сказано чуть ранее, когда мы вызываем эти «рискованные» методы, мы должны обрабатывать проверенные исключения, и мы можем обрабатывать непроверенные. Java дает нам несколько способов сделать это:

4.1. throws

Самый простой способ «обработать» исключение — повторно возбудить его:

/**
 * @exception FileNotFoundException ...
 */
public Scanner(String fileName) throws FileNotFoundException {
   // ...
}

Поскольку FileNotFoundException является проверенным исключением, это самый простой способ удовлетворить компилятор, но это означает, что любой, кто вызывает наш метод теперь должен справиться и с этим!

parseInt может генерировать исключение NumberFormatException, но, поскольку оно не проверено, мы не обязаны его обрабатывать.

4.2. try-catch

public int getPlayerScore(String playerFile)
  throws FileNotFoundException {
 
    Scanner contents = new Scanner(new File(playerFile));
    return Integer.parseInt(contents.nextLine());
}

Если мы хотим попытаться обработать исключение самостоятельно, мы можем использовать блок try-catch. Мы можем справиться с этим, перегенерировав наше исключение:

Или выполнив шаги восстановления:

4.3. finally

Бывают случаи, когда у нас есть код, который должен выполняться независимо от того, возникает ли исключение, и именно здесь появляется ключевое слово finally.

public int getPlayerScore(String playerFile) {
    try {
        Scanner contents = new Scanner(new File(playerFile));
        return Integer.parseInt(contents.nextLine());
    } catch (FileNotFoundException noFile) {
        throw new IllegalArgumentException("File not found");
    }
}

В наших примерах до сих пор было неприятная ошибка, скрывающаяся в тени, заключается в том, что Java по умолчанию не возвращает дескрипторы файлов в операционную систему.

public int getPlayerScore(String playerFile) {
    try {
        Scanner contents = new Scanner(new File(playerFile));
        return Integer.parseInt(contents.nextLine());
    } catch ( FileNotFoundException noFile ) {
        logger.warn("File not found, resetting score.");
        return 0;
    }
}

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

Давайте сначала попробуем \»ленивый\» способ:

Здесь блок finally указывает, какой код мы хотим, чтобы Java запускала независимо от того, что происходит при попытке чтения файла.

Даже если FileNotFoundException выбрасывается вверх по стеку вызовов, Java вызовет содержимое finally, прежде чем сделать это.

Мы также можем обработать исключение и убедиться, что наши ресурсы закрыты:

public int getPlayerScore(String playerFile)
  throws FileNotFoundException {
    Scanner contents = null;
    try {
        contents = new Scanner(new File(playerFile));
        return Integer.parseInt(contents.nextLine());
    } finally {
        if (contents != null) {
            contents.close();
        }
    }
}

Поскольку close также является «рискованным» методом, нам также необходимо перехватить его исключение!

Это может показаться довольно сложным, но нам нужно, чтобы каждая часть корректно обрабатывала каждую потенциальную проблему, которая может возникнуть.

4.4. try-with-resources

public int getPlayerScore(String playerFile) {
    Scanner contents;
    try {
        contents = new Scanner(new File(playerFile));
        return Integer.parseInt(contents.nextLine());
    } catch (FileNotFoundException noFile ) {
        logger.warn("File not found, resetting score.");
        return 0; 
    } finally {
        try {
            if (contents != null) {
                contents.close();
            }
        } catch (IOException io) {
            logger.error("Couldn't close the reader!", io);
        }
    }
}

К счастью, начиная с Java 7, мы можем упростить приведенный выше синтаксис при работе с вещами, расширяющими AutoCloseable:

Когда мы помещаем ссылки, которые AutoClosable, в объявление try, мы не не нужно закрывать ресурс самостоятельно.

Мы по-прежнему можем использовать блок finally для любой другой очистки, которую захотим.

Прочтите нашу статью, посвященную использованию ресурсов, чтобы узнать больше.

public int getPlayerScore(String playerFile) {
    try (Scanner contents = new Scanner(new File(playerFile))) {
      return Integer.parseInt(contents.nextLine());
    } catch (FileNotFoundException e ) {
      logger.warn("File not found, resetting score.");
      return 0;
    }
}

4.5. Множественные блоки catch

Иногда код может генерировать более одного исключения, и у нас может быть более одного блока catch, обрабатывающего каждый отдельно:

Множественные catch дают нам возможность обрабатывать каждое исключение по-разному, если возникает необходимость.

Также обратите внимание, что мы не перехватили FileNotFoundException, потому что оно расширяет IOException. Поскольку мы перехватываем IOException, Java будет считать, что любой из его подклассов также обрабатывается.

Предположим, однако, что нам нужно обрабатывать FileNotFoundException иначе, чем более общий IOException:

public int getPlayerScore(String playerFile) {
    try (Scanner contents = new Scanner(new File(playerFile))) {
        return Integer.parseInt(contents.nextLine());
    } catch (IOException e) {
        logger.warn("Player file wouldn't load!", e);
        return 0;
    } catch (NumberFormatException e) {
        logger.warn("Player file was corrupted!", e);
        return 0;
    }
}

Java позволяет нам обрабатывать исключения подкласса отдельно, не забудьте разместить их выше в списке перехватов.

4.6. Блоки Union catch

Когда мы знаем, что способ обработки ошибок будет таким же, в Java 7 появилась возможность перехватывать несколько исключений в одном блоке:

public int getPlayerScore(String playerFile) {
    try (Scanner contents = new Scanner(new File(playerFile)) ) {
        return Integer.parseInt(contents.nextLine());
    } catch (FileNotFoundException e) {
        logger.warn("Player file not found!", e);
        return 0;
    } catch (IOException e) {
        logger.warn("Player file wouldn't load!", e);
        return 0;
    } catch (NumberFormatException e) {
        logger.warn("Player file was corrupted!", e);
        return 0;
    }
}

5. Генерация исключений ~~ ~ Если мы не хотим обрабатывать исключение сами или хотим сгенерировать свои исключения для обработки другими, нам нужно ознакомиться с ключевым словом throw.

Допустим, у нас есть следующее проверенное исключение, которое мы создали сами:

и у нас есть метод, выполнение которого потенциально может занять много времени:

public int getPlayerScore(String playerFile) {
    try (Scanner contents = new Scanner(new File(playerFile))) {
        return Integer.parseInt(contents.nextLine());
    } catch (IOException | NumberFormatException e) {
        logger.warn("Failed to load score!", e);
        return 0;
    }
}

5.1. Генерация проверенного исключения

Подобно возврату из метода, мы можем генерировать исключение в любой момент.

Конечно, мы должны бросать, когда пытаемся указать, что что-то пошло не так:

public class TimeoutException extends Exception {
    public TimeoutException(String message) {
        super(message);
    }
}

Поскольку TimeoutException проверяется, мы также должны использовать ключевое слово throws в сигнатуре, чтобы вызывающие наш метод знали справиться с этим.

public List<Player> loadAllPlayers(String playersFile) {
    // ... potentially long operation
}

5.2. Генерация непроверенного исключения

Если мы хотим сделать что-то вроде проверки ввода, мы можем вместо этого использовать непроверенное исключение:

«

public List<Player> loadAllPlayers(String playersFile) throws TimeoutException {
    while ( !tooLong ) {
        // ... potentially long operation
    }
    throw new TimeoutException("This operation took too long");
}

«Поскольку флажок IllegalArgumentException снят, нам не нужно отмечать метод, хотя мы и приветствуем это.

Некоторые все равно помечают метод как форму документации.

5.3. Обтекание и повторное генерирование

public List<Player> loadAllPlayers(String playersFile) throws TimeoutException {
    if(!isFilenameValid(playersFile)) {
        throw new IllegalArgumentException("Filename isn't valid!");
    }
   
    // ...
}

Мы также можем выбрать повторное генерирование перехваченного исключения:

Или выполнить перенос и повторное генерирование:

Это может быть удобно для объединения множества различных исключений в одно.

5.4. Повторное создание Throwable или Exception

public List<Player> loadAllPlayers(String playersFile) 
  throws IOException {
    try { 
        // ...
    } catch (IOException io) { 		
        throw io;
    }
}

Теперь особый случай.

public List<Player> loadAllPlayers(String playersFile) 
  throws PlayerLoadException {
    try { 
        // ...
    } catch (IOException io) { 		
        throw new PlayerLoadException(io);
    }
}

Если единственными возможными исключениями, которые может вызвать данный блок кода, являются непроверенные исключения, то мы можем перехватить и повторно сгенерировать Throwable или Exception, не добавляя их в сигнатуру нашего метода:

Несмотря на простоту, приведенный выше код может не выбрасывать проверенное исключение, и поэтому, даже если мы повторно выбрасываем проверенное исключение, нам не нужно помечать подпись предложением throws.

Это удобно с прокси-классами и методами. Подробнее об этом можно узнать здесь.

5.5. Наследование

public List<Player> loadAllPlayers(String playersFile) {
    try {
        throw new NullPointerException();
    } catch (Throwable t) {
        throw t;
    }
}

Когда мы помечаем методы ключевым словом throws, это влияет на то, как подклассы могут переопределять наш метод.

В случае, когда наш метод выдает проверенное исключение:

Подкласс может иметь «менее опасную» подпись:

Но не «более рискованную» подпись: ~~ ~

Это связано с тем, что контракты определяются во время компиляции ссылочным типом. Если я создам экземпляр MoreExceptions и сохраню его в Exceptions:

public class Exceptions {
    public List<Player> loadAllPlayers(String playersFile) 
      throws TimeoutException {
        // ...
    }
}

Тогда JVM только скажет мне поймать TimeoutException, что неверно, поскольку я сказал, что MoreExceptions#loadAllPlayers выдает другое исключение.

public class FewerExceptions extends Exceptions {	
    @Override
    public List<Player> loadAllPlayers(String playersFile) {
        // overridden
    }
}

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

public class MoreExceptions extends Exceptions {		
    @Override
    public List<Player> loadAllPlayers(String playersFile) throws MyCheckedException {
        // overridden
    }
}

6. Анти-шаблоны

Exceptions exceptions = new MoreExceptions();
exceptions.loadAllPlayers("file");

6.1. Проглатывание исключений

Теперь есть еще один способ, которым мы могли бы удовлетворить компилятор:

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

Бывают случаи, когда есть проверенное исключение, которое, как мы уверены, никогда не произойдет. В этих случаях мы все равно должны добавить комментарий о том, что мы преднамеренно съели исключение:

Другой способ, которым мы можем «проглотить» исключение, — это просто вывести исключение в поток ошибок: ~~ ~

public int getPlayerScore(String playerFile) {
    try {
        // ...
    } catch (Exception e) {} // <== catch and swallow
    return 0;
}

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

Было бы лучше, если бы мы использовали регистратор:

public int getPlayerScore(String playerFile) {
    try {
        // ...
    } catch (IOException e) {
        // this will never happen
    }
}

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

public int getPlayerScore(String playerFile) {
    try {
        // ...
    } catch (Exception e) {
        e.printStackTrace();
    }
    return 0;
}

Наконец, мы можем непреднамеренно проглотить исключение, не включив его в качестве причины при создании нового исключения: не удалось включить IOException в качестве причины. Из-за этого мы потеряли важную информацию, которую звонящие или операторы могли бы использовать для диагностики проблемы.

Нам лучше сделать:

public int getPlayerScore(String playerFile) {
    try {
        // ...
    } catch (IOException e) {
        logger.error("Couldn't load the score", e);
        return 0;
    }
}

Обратите внимание на тонкую разницу включения IOException в качестве причины PlayerScoreException.

6.2. Использование возврата в блоке finally

public int getPlayerScore(String playerFile) {
    try {
        // ...
    } catch (IOException e) {
        throw new PlayerScoreException();
    }
}

Другой способ проглатывания исключений — возврат из блока finally. Это плохо, потому что, возвращаясь внезапно, JVM отбрасывает исключение, даже если оно было вызвано нашим кодом:

Согласно Спецификации языка Java:

public int getPlayerScore(String playerFile) {
    try {
        // ...
    } catch (IOException e) {
        throw new PlayerScoreException(e);
    }
}

6.3. Использование throw в блоке finally

Подобно использованию return в блоке finally, исключение, сгенерированное в блоке finally, будет иметь приоритет над исключением, возникающим в блоке catch.

Это «удалит» исходное исключение из блока try, и мы потеряем всю эту ценную информацию:

public int getPlayerScore(String playerFile) {
    int score = 0;
    try {
        throw new IOException();
    } finally {
        return score; // <== the IOException is dropped
    }
}

6.4. Использование throw в качестве оператора goto

If execution of the try block completes abruptly for any other reason R, then the finally block is executed, and then there is a choice.

If the finally block completes normally, then the try statement completes abruptly for reason R.

If the finally block completes abruptly for reason S, then the try statement completes abruptly for reason S (and reason R is discarded).

Некоторые люди также поддались искушению использовать throw в качестве оператора goto:

«

«Это странно, потому что код пытается использовать исключения для управления потоком, а не для обработки ошибок.

public int getPlayerScore(String playerFile) {
    try {
        // ...
    } catch ( IOException io ) {
        throw new IllegalStateException(io); // <== eaten by the finally
    } finally {
        throw new OtherException();
    }
}

7. Общие исключения и ошибки

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

public void doSomething() {
    try {
        // bunch of code
        throw new MyException();
        // second bunch of code
    } catch (MyException e) {
        // third bunch of code
    }		
}

7.1. Checked Exceptions

IOException — обычно это исключение указывает на сбой в сети, файловой системе или базе данных.

7.2. RuntimeExceptions

ArrayIndexOutOfBoundsException — это исключение означает, что мы попытались получить доступ к несуществующему индексу массива, например, при попытке получить индекс 5 из массива длины 3. ClassCastException — это исключение означает, что мы попытались выполнить недопустимое приведение, например, попытка преобразовать строку в список. Обычно мы можем избежать этого, выполняя защитные проверки instanceof перед приведением. IllegalArgumentException — это исключение является для нас общим способом сказать, что один из предоставленных параметров метода или конструктора недействителен. IllegalStateException — это исключение является для нас общим способом сказать, что наше внутреннее состояние, как и состояние нашего объекта, недопустимо. NullPointerException — это исключение означает, что мы попытались сослаться на нулевой объект. Обычно мы можем избежать этого, либо выполнив защитные проверки null, либо используя необязательный. NumberFormatException — это исключение означает, что мы попытались преобразовать строку в число, но строка содержала недопустимые символы, например, попытка преобразовать «5f3» в число.

    7.3. Ошибки

StackOverflowError — это исключение означает, что трассировка стека слишком велика. Иногда это может происходить в массовых приложениях; однако обычно это означает, что в нашем коде происходит некоторая бесконечная рекурсия. NoClassDefFoundError — это исключение означает, что класс не удалось загрузить либо из-за отсутствия в пути к классам, либо из-за сбоя статической инициализации. OutOfMemoryError — это исключение означает, что у JVM больше нет доступной памяти для выделения дополнительных объектов. Иногда это происходит из-за утечки памяти.

    8. Заключение

В этой статье мы рассмотрели основы обработки исключений, а также несколько хороших и плохих практических примеров.

    Как всегда, весь код, найденный в этой статье, можно найти на GitHub!

«

In this article, we’ve gone through the basics of exception handling as well as some good and poor practice examples.

As always, all code found in this article can be found over on GitHub!