Checked и unchecked исключения

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

Исключение (exception) — это сигнал о том, что во время выполнения программы произошло что-то нештатное: файл не найден, деление на ноль, обращение к несуществующему элементу массива. Java не роняет программу молча, а «выбрасывает» специальный объект-исключение, который можно поймать и обработать.

Что выведет этот код?

Начнём с вопроса-ловушки. Посмотрите на этот метод и подумайте, скомпилируется ли он:

import java.io.FileReader;

public class Main {
    public static void main(String[] args) {
        FileReader reader = new FileReader("data.txt");
        System.out.println("Файл открыт");
    }
}

Если вы пишете на Java хотя бы неделю, вы уже почувствовали подвох. Этот код не скомпилируется. Компилятор выдаст ошибку:

Вывод:

error: unreported exception FileNotFoundException; must be caught or declared to be thrown
        FileReader reader = new FileReader("data.txt");
                            ^

Конструктор FileReader может выбросить FileNotFoundException — а это исключение относится к особой категории, которую Java требует явно обрабатывать. Вот с этой категории и начнём.

Два лагеря исключений

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

Checked-исключения (проверяемые) — это ошибки, которые компилятор ЗАСТАВЛЯЕТ вас либо поймать через try/catch, либо «передать дальше» через ключевое слово throws в сигнатуре метода. Логика Java простая: если ошибка связана с внешним миром — файлами, сетью, базой данных — вы обязаны заранее подумать, что будете делать, если что-то пойдёт не так. Примеры: IOException, SQLException, FileNotFoundException.

Unchecked-исключения (непроверяемые) — это ошибки программиста, а не внешнего мира. Компилятор НЕ требует их объявлять или ловить. Логика такая: если вы разыменовали null или вышли за границы массива — это баг в вашей логике, а не непредвиденное обстоятельство, с которым можно «работать». Примеры: NullPointerException, ArrayIndexOutOfBoundsException, ArithmeticException.

Исправим наш пример двумя способами — сначала через try/catch:

import java.io.FileReader;
import java.io.FileNotFoundException;

public class Main {
    public static void main(String[] args) {
        try {
            FileReader reader = new FileReader("data.txt");
            System.out.println("Файл открыт");
        } catch (FileNotFoundException e) {
            System.out.println("Файл не найден: " + e.getMessage());
        }
    }
}

А теперь через throws — мы не обрабатываем исключение сами, а «перекладываем» ответственность на того, кто вызовет наш метод:

import java.io.FileReader;
import java.io.FileNotFoundException;

public class Main {
    public static void main(String[] args) throws FileNotFoundException {
        FileReader reader = new FileReader("data.txt");
        System.out.println("Файл открыт");
    }
}

Оба варианта компилируются. Разница в том, что во втором случае, если файла не окажется, программа аварийно завершится с трассировкой стека — обработку переложили на вызывающий код (в данном случае это JVM, которая запускает main).

Как это устроено под капотом

В основе — иерархия классов, и это ключ к пониманию, почему одни исключения checked, а другие нет.

КлассКатегорияПроверяется компилятором?
Throwableкорень всей иерархии
Errorкритические сбои среды (нехватка памяти и т.п.)нет
Exception (кроме RuntimeException)checkedда
RuntimeException и его наследникиuncheckedнет

Правило простое: класс RuntimeException и всё, что от него наследуется, — unchecked. Всё остальное, что наследуется от Exception напрямую, — checked. А Error — это вообще отдельная история: такие ошибки сигнализируют о проблемах уровня JVM (например, OutOfMemoryError), их ловить и «чинить» в обычном коде почти никогда не имеет смысла.

Компилятор Java устроен так, что на этапе сборки анализирует, какие checked-исключения может выбросить каждый вызываемый метод, и требует, чтобы вы либо поймали их, либо явно расписались в throws, что «прокидываете» их выше. Для unchecked-исключений такого анализа нет — компилятору всё равно, обработали вы NullPointerException или нет, потому что теоретически он может возникнуть в почти любой строке кода.

Когда это помогает, а когда раздражает

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

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

Раздражает, когда приходится писать try/catch или throws просто чтобы код скомпилировался, хотя обработать проблему всё равно нечем — и разработчики от бессилия начинают писать код вроде такого:

try {
    riskyOperation();
} catch (Exception e) {
    // тут пусто, потому что «а что я могу сделать»
}

Это одна из самых частых претензий к checked-исключениям в реальных проектах, и именно поэтому многие современные библиотеки (например, Spring) сознательно оборачивают checked-исключения в unchecked, чтобы не заставлять весь код «прокидывать» throws по цепочке из десятков методов.

Частые ошибки на собеседовании

  • Путают Error и Exception — говорят, что Error тоже checked. Нет, Error вообще не про «обработать», это сигнал о серьёзной проблеме среды.
  • Считают, что RuntimeException нельзя поймать через try/catch. Можно — просто компилятор не заставляет это делать.
  • Забывают, что NullPointerException, ArrayIndexOutOfBoundsException и ArithmeticException — все unchecked, потому что это баги логики, а не проблемы внешнего мира.
  • Не могут объяснить, зачем вообще нужно деление на checked/unchecked, и отвечают только «потому что так в Java» — интервьюеры ждут именно понимания компромисса «безопасность vs многословность».

Итоги-шпаргалка

  • Checked-исключения (наследники Exception, кроме RuntimeException) компилятор заставляет ловить или объявлять через throws.
  • Unchecked-исключения (наследники RuntimeException) — это баги программиста, компилятор их не проверяет.
  • Error — вообще отдельная категория, сигнал о серьёзных проблемах JVM, а не бизнес-логики.
  • Checked-исключения хороши для ожидаемых сбоев внешнего мира, но при неаккуратном использовании превращаются в пустые catch-блоки и раздражающий шум в коде.
Проверьте себя
1. Какое из этих исключений является unchecked (непроверяемым)?
AIOException
BNullPointerException
CSQLException
DFileNotFoundException
2. Что произойдёт, если метод может выбросить checked-исключение, но вы не поймаете его и не объявите через throws?
AПрограмма скомпилируется, но упадёт при запуске
BКод не скомпилируется
CJVM автоматически проигнорирует исключение
DКомпилятор превратит его в unchecked