Иерархия Throwable
Корень — Throwable (только его объект можно бросить и поймать). От него две ветви: Error и Exception. Error — серьёзные сбои окружения, из которых обычно не восстанавливаются и которые не ловят: OutOfMemoryError, StackOverflowError, NoClassDefFoundError.
Exception — прикладные ситуации, которые код может обработать. Внутри неё особняком стоит RuntimeException — ветка unchecked, куда попадают ошибки программиста: NullPointerException, IllegalArgumentException, IllegalStateException, ClassCastException.
Checked против unchecked
Checked-исключения (наследники Exception, кроме RuntimeException) проверяются компилятором: их нужно либо объявить в throws, либо обработать. Unchecked (RuntimeException и Error) компилятор обрабатывать не требует.
Философия: checked — для ожидаемых восстановимых ситуаций, на которые вызывающий реально может среагировать (нет файла, сетевой сбой); unchecked — для багов и нарушений контракта, которые надо чинить в коде, а не «обрабатывать».
Важный нюанс зрелости: checked-исключения критикуют за многословие и за то, что они плохо дружат с лямбдами и Stream API — функциональные интерфейсы (Function, Supplier) не объявляют throws, поэтому checked из лямбды не бросить без обёртки. Поэтому многие современные библиотеки и фреймворки тяготеют к unchecked.
Что происходит при выбросе
Когда исключение брошено, JVM раскручивает стек (stack unwinding): идёт вверх по кадрам методов в поисках ближайшего подходящего catch, по пути выполняя блоки finally. Если обработчик не найден до вершины стека потока — поток завершается, и печатается stack trace.
Это объясняет, почему исключения не стоит использовать для нормального потока управления: основная стоимость — заполнение stack trace (fillInStackTrace обходит весь стек) при создании исключения. Само бросание/ловля дёшевы, дорого именно построение трассы.
Цепочки причин и не-глотание
При перехвате на границе слоёв исключение оборачивают, сохраняя первопричину: throw new ServiceException("...", e). Тогда getCause() и печать трассы покажут всю цепочку «Caused by: …» — это бесценно при отладке. Терять cause (бросать new без e) — распространённая ошибка.
Антипаттерны обработки: пустой catch (глотание ошибки), слишком широкий catch (Exception) или catch (Throwable) (ловит и баги, и Error), а также «залогировал и пробросил» (двойные логи одной ошибки на разных слоях). Несколько типов ловят одним multi-catch: catch (IOException | SQLException e).
try {
repository.save(order);
} catch (SQLException e) {
throw new OrderPersistenceException("не удалось сохранить заказ", e); // сохраняем cause
}
Практики и fail-fast
Бросайте максимально конкретное исключение и проверяйте предусловия рано (fail-fast): неверный аргумент — IllegalArgumentException, недопустимое состояние объекта — IllegalStateException, null-аргумент — Objects.requireNonNull(x, "x") (бросит NPE с понятным сообщением сразу, а не где-то глубже).
Кастомные исключения заводят, когда вызывающему нужно различать ситуацию программно; в них кладут осмысленные поля (id сущности, код ошибки), а не только текст. И помните смежное правило из многопоточности: поймав InterruptedException, восстановите флаг прерывания, а не глотайте его.
Как отвечать на собеседовании
Нарисуйте дерево Throwable (Error не ловим; Exception/RuntimeException), объясните философию checked/unchecked и их трение с лямбдами, опишите раскрутку стека и что дорого именно построение stack trace (значит, не для control flow). Подчеркните сохранение cause при оборачивании и fail-fast (requireNonNull, IllegalArgument/StateException), назовите антипаттерны (пустой/широкий catch, log-and-rethrow).