← назад к разделу

ОСНОВЫ ЯЗЫКА: JAVA Фаза 2. Исключения

Обязательно

Исключения в Java: иерархия, checked/unchecked, try-with-resources

Java

Throwable, checked и unchecked, try/catch/finally, ресурсы и собственные исключения.

1. Исключение передаёт сбой туда, где его обработают

1 / 5

Ошибка прерывает текущий метод и поднимается по стеку вызовов, пока не встретит подходящий catch. Если её не поймать, выполнение завершится с трассировкой.

  • Исключение - объект с типом, сообщением и стеком.
  • Обрабатывать нужно на уровне, где можно изменить исход.
  • Пустой catch прячет сбой и оставляет неверное состояние.
repository service controllercatch Не игнорировать, а решить: retry, fallback или ответ об ошибке

2. Throwable разделяет Error, Exception и RuntimeException

2 / 5

Error обычно означает сбой JVM, проверяемые Exception надо объявить или поймать, RuntimeException такого требования не имеет.

  • IOException - checked.
  • NullPointerException и IllegalArgumentException - unchecked.
  • Checked не значит «важнее», это контракт восстановления.
Throwable Error Exception checked RuntimeException

3. catch выбирает тип, finally выполняется почти всегда

3 / 5

try содержит рискованный код, catch перехватывает конкретный тип, finally освобождает ресурсы и выполняется и после исключения, и после return.

  • Сначала идут более конкретные классы исключений.
  • Исключение из catch не ловится соседним catch.
  • Не стоит гасить исходное исключение новым без сохранения причины.
try catch finally finally: close() и при успехе, и при сбое

4. try-with-resources закрывает ресурс сам

4 / 5

Объект в круглых скобках try реализует AutoCloseable. Java вызовет close после блока, поэтому ручной finally больше не нужен.

  • Подходит для файлов, соединений и потоков.
  • Если и тело, и close упали, исходная ошибка остаётся главной.
  • Ошибки закрытия попадают в getSuppressed.
try (Resource r = open()) работа или исключение r.close() автоматически

5. Своя ошибка сохраняет смысл и причину

5 / 5

Собственное исключение называет бизнес-сбой, а конструктор с cause сохраняет исходную ошибку. Стек-трейс читают от своего кода к корню.

  • Для программной ошибки выбирают RuntimeException.
  • Для ожидаемого восстановления можно checked.
  • Сообщение отвечает «что случилось и с какими данными», без секретов.
OrderNotFoundException orderId = 42 cause = SQLException throw new ...(..., cause)

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

Зачем вообще исключения

Можно было бы возвращать из методов код ошибки — например -1 или null. Но тогда проверку «а не сломалось ли?» пришлось бы писать после каждого вызова, и легко её забыть. Исключения решают это иначе: проблемный код прерывается, а ошибка «всплывает» вверх по стеку вызовов, пока её кто-нибудь не поймает.

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

кадры стека вызовов Integer.parseInt("abc") throw NumberFormatException parse(s) подходящего catch нет main catch (NumberFormatException e) исключение

Исключение из parseInt поднимается по кадрам стека. Кадр, в котором нет подходящего catch, снимается вместе со своими переменными; выполнение продолжается в первом кадре, где тип совпал.

живой пример

public class ParseDemo {
    static int parse(String s) {
        return Integer.parseInt(s);
    }

    public static void main(String[] args) {
        System.out.println("число: " + parse("42"));
        try {
            parse("abc");
        } catch (NumberFormatException e) {
            System.out.println("не число: " + e.getMessage());
        }
        System.out.println("программа продолжает работать");
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

На строке "abc" метод не возвращает мусор: он прерывается, а решение принимает вызывающий код.

Иерархия: Throwable, Error, Exception

В корне всего стоит класс Throwable — только его наследников можно бросать (throw) и ловить (catch). У него две главные ветки:

  • Error — отказы, после которых продолжать бессмысленно: OutOfMemoryError, StackOverflowError. Слово «Error» обманывает: это почти никогда не поломка машины, а следствие вашего же кода — бесконечная рекурсия, утечка, коллекция, в которую сложили больше, чем помещается в память. Чинить надо код, а не JVM. Но не ловят их по месту: чинить такое в catch посреди бизнес-логики бессмысленно, программа уже в плохом состоянии. Единственное разумное место — самый верх: там ловят Throwable, чтобы записать причину в лог и остановиться по-человечески, а не умереть молча. Нехватку памяти чаще закрывают вообще без кода — флагом -XX:+ExitOnOutOfMemoryError: процесс завершается, и его перезапускают.
  • Exception — ошибки уровня приложения, с которыми можно и нужно работать.

Внутри Exception есть особая подветка — RuntimeException. Именно по ней проходит граница между checked и unchecked исключениями.

Иерархия исключений: в корне Throwable, от него Error (OutOfMemoryError, StackOverflowError — не ловим) и Exception; внутри Exception — checked (IOException, SQLException) и ветка RuntimeException с unchecked (NullPointerException, IllegalArgumentException)

Checked и unchecked

Это деление — одна из самых обсуждаемых особенностей Java.

Checked-исключения — всё, что наследует Exception, но не RuntimeException (например IOException, SQLException). Компилятор заставляет их обработать: либо обернуть вызов в try/catch, либо объявить в сигнатуре метода через throws. Не сделаешь — код не скомпилируется. Оба выхода видно в одной программе:

живой пример

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class ConfigDemo {
    static String read(Path path) throws IOException {
        return Files.readString(path);
    }

    static String readOrDefault(Path path) {
        try {
            return read(path);
        } catch (IOException e) {
            return "порт=8080";
        }
    }

    public static void main(String[] args) {
        System.out.println(readOrDefault(Path.of("config.properties")));
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Файла нет, Files.readString бросает IOException — но программа не падает: readOrDefault подставляет значение по умолчанию, а read передаёт ответственность выше через throws.

Unchecked-исключения — наследники RuntimeException (NullPointerException, IllegalArgumentException, IllegalStateException, NumberFormatException). Компилятор их не контролирует: ловить можно, но не обязательно. Обычно это ошибки в коде — обращение к null, неверный аргумент, нарушение контракта метода.

Короткая формула: checked — «ожидаемая внешняя проблема, с которой вызывающий должен что-то сделать»; unchecked — «программист ошибся, чини код, а не лови исключение».

Есть правило, в которое упираются при первой же реализации интерфейса. Метод, который переопределяет родительский, не может объявить checked-исключений больше, чем родитель: убрать throws или сузить его можно, расширить нельзя. Причина в полиморфизме: код, который работает с типом родителя, обработал ровно те исключения, что объявлены у родителя, и не готов к новым. Поэтому Runnable.run() без throws нельзя реализовать методом с throws IOException, и по той же причине в лямбде, о которой статья про лямбды, нельзя вызвать метод с checked-исключением: у Function.apply в сигнатуре его нет. Выход один и тот же: поймать и обернуть в unchecked.

Споры вокруг checked

Идея checked-исключений — заставить разработчика не игнорировать ошибки. Критика тоже по делу: в длинных цепочках throws IOException тянется через десятки методов, а пустой catch, написанный лишь бы компилятор замолчал, хуже, чем ничего. Многие библиотеки и фреймворки (включая значительную часть экосистемы Spring) заворачивают checked в runtime-обёртки. Готового ответа для всей индустрии нет, а для своего кода рабочее правило есть: в прикладном коде свои исключения делают unchecked, наследуя RuntimeException, потому что почти всегда вызывающий ничего осмысленного сделать не может и ошибка должна долететь до общего обработчика (у сервиса это ответ с кодом ошибки и запись в лог). Checked оставляют для тех редких случаев, где у вызывающего есть реальный второй путь: файла нет — взять значение по умолчанию, соединение занято — повторить. Чужие checked-исключения на границе с библиотекой ловят и оборачивают в своё unchecked с сохранением причины, чтобы throws IOException не расползался по сигнатурам.

try / catch / finally

Рискованный код идёт в try, разбор ошибки — в catch, обязательная уборка — в finally. Три входных значения в примере проходят по трём разным путям.

живой пример

public class CatchOrderDemo {
    static void divide(String value) {
        try {
            System.out.println("частное: " + 100 / Integer.parseInt(value));
        } catch (ArithmeticException e) {
            System.out.println("на ноль делить нельзя");
        } catch (RuntimeException e) {
            System.out.println("другая беда: " + e.getClass().getSimpleName());
        } finally {
            System.out.println("finally — в любом случае");
        }
    }

    public static void main(String[] args) {
        divide("4");
        divide("0");
        divide("не число");
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Что здесь важно:

  • Несколько catch проверяются сверху вниз — срабатывает первый подходящий по типу. Поэтому конкретный ArithmeticException стоит выше общего RuntimeException: поменяешь их местами — код не скомпилируется, вторая ветка окажется недостижимой.
  • Мульти-catch объединяет ветки с одинаковой обработкой: catch (IOException | SQLException e).
  • finally выполняется в любом случае — даже если внутри try был return. Сюда исторически клали освобождение ресурсов (закрытие файла, соединения).
  • А вот return или throw в самом finally писать нельзя: они затирают и результат, и исключение из try. Ошибка при этом не всплывёт и не попадёт в лог — она просто исчезнет, а метод вернёт значение из finally, как будто ничего не было. Это самый тихий способ потерять ошибку в Java.
  • Ловить «всё подряд» через catch (Exception e) стоит осторожно — так легко перехватить то, что обрабатывать не собирался.

try-with-resources и AutoCloseable

Ручное закрытие в finally — это шумно и легко ошибиться (а что если close() сам бросит исключение?). С Java 7 для этого есть try-with-resources: ресурсы объявляются в круглых скобках после try и закрываются автоматически, в обратном порядке, даже если возникнет исключение.

Работает это для любого класса, реализующего интерфейс AutoCloseable (у него один метод — close()). Большинство стандартных «закрываемых» типов (потоки, файлы, соединения) его реализуют.

живой пример

public class ResourceDemo {
    record Connection(String name, boolean brokenOnClose) implements AutoCloseable {
        @Override public void close() {
            System.out.println("закрыли " + name);
            if (brokenOnClose) {
                throw new IllegalStateException("не смог закрыть " + name);
            }
        }
    }

    public static void main(String[] args) {
        try (var file = new Connection("файл", false);
             var socket = new Connection("сокет", true)) {
            System.out.println("работаем: " + file.name() + ", " + socket.name());
            throw new IllegalStateException("сеть отвалилась");
        } catch (IllegalStateException e) {
            System.out.println("поймали: " + e.getMessage());
            for (Throwable suppressed : e.getSuppressed()) {
                System.out.println("а ещё подавленное: " + suppressed.getMessage());
            }
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

В выводе видно порядок: сокет закрылся раньше файла, и оба закрылись до того, как сработал catch. И видно, что стало со второй ошибкой. Закрытие сокета тоже упало, но исходное «сеть отвалилась» оно не затёрло: приехало рядом как подавленное и достаётся через getSuppressed(). В ручном finally эта вторая ошибка либо потерялась бы, либо вытеснила бы настоящую причину.

1 открыли файл 2 открыли сокет 3 ошибка в try 4 закрыли сокет 5 закрыли файл 6 вошли в catch

Шаги примера по порядку: ресурсы закрываются в обратном порядке объявления, и оба успевают закрыться до входа в catch.

Свои исключения

Когда стандартные типы не передают смысл ошибки в вашей предметной области, создают собственное исключение. Обычно наследуют от RuntimeException (если не хотите навязывать обязательную обработку) или от Exception (если хотите).

живой пример

public class OrderDemo {
    public static void main(String[] args) {
        try {
            loadOrder(42);
        } catch (OrderLoadException e) {
            System.out.println(e.getMessage());
            System.out.println("причина: " + e.getCause());
        }
    }

    static void loadOrder(long id) {
        try {
            long total = Long.parseLong("сломанные данные");
            System.out.println("сумма заказа: " + total);
        } catch (NumberFormatException e) {
            throw new OrderLoadException(id, e);
        }
    }
}

class OrderLoadException extends RuntimeException {
    OrderLoadException(long orderId, Throwable cause) {
        super("Не удалось загрузить заказ " + orderId, cause);
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Полезные привычки:

  • Понятное сообщение — что произошло и с какими данными: в выводе видно номер заказа, а не безличное «ошибка».
  • Сохраняй причину. Исходное исключение передаётся вторым аргументом конструктора — тогда в логе будет полная цепочка (Caused by: ...), а не оборванный след.

Антипаттерны

Три способа навредить себе, по убыванию ущерба.

Потеря причины. Исключение обернули в своё, а исходное не передали:

try {
    risky();
} catch (IOException e) {
    throw new RuntimeException("ошибка");
}

Наверх уходит RuntimeException("ошибка") без e: в логе не будет ни имени файла, ни стека того места, где всё сломалось, и искать придётся по времени в журнале. Оборачивая, передавайте причину вторым аргументом: new RuntimeException("ошибка", e).

Пустой catch.

try {
    risky();
} catch (Exception e) {
}

Исключение исчезло, программа пошла дальше с неполными данными, и проблему заметят через неделю по отчёту, а не в момент сбоя. Минимум — залогировать.

Управление потоком через исключения.

try {
    return list.get(index);
} catch (IndexOutOfBoundsException e) {
    return null;
}

Дорого здесь не throw и не catch, а само создание объекта: конструктор Throwable вызывает fillInStackTrace, который проходит по всем кадрам стека и записывает их, и на глубоком стеке сервиса это микросекунды против наносекунд на проверку index < list.size(), то есть разница в тысячи раз. Читатель кода при этом не понимает, штатный это случай или авария. Исключения для исключительных ситуаций, не для обычного if. Две детали для тех, кто всё же гонит через них поток: у Throwable есть конструктор с флагом writableStackTrace = false, который отключает сбор стека и делает исключение дешёвым; а JIT в HotSpot сам перестаёт собирать стек у часто бросаемых встроенных исключений вроде NullPointerException в горячем коде, и в логе появляется исключение без единой строки at. Второе выглядит как загадка, пока не знаешь, что это оптимизация (-XX:-OmitStackTraceInFastThrow её выключает).

Дополнительно: при первом чтении можно пропустить

Глубже: как читать стек-трейс и как отлаживатьсярасширенное

Фаза учит писать код, который падает, и ни разу не показывает, как выглядит падение целиком. Стек-трейс это первое, что вы увидите каждый день, и читать его нужно уметь до того, как писать catch.

Exception in thread "main" java.lang.IllegalStateException: заказ 42 уже оплачен
	at shop.Order.pay(Order.java:58)
	at shop.PaymentService.charge(PaymentService.java:31)
	at shop.Main.main(Main.java:12)
Caused by: java.lang.NumberFormatException: For input string: "abc"
	at java.base/java.lang.Integer.parseInt(Integer.java:652)
	at shop.Order.pay(Order.java:55)
	... 2 more

Читают сверху вниз и снизу вверх одновременно. Первая строка это тип исключения и сообщение: что случилось. Строки at это стек вызовов на момент броска, сверху то место, где исключение возникло, ниже кто его вызвал, и так до main; первая строка с вашим пакетом (shop.Order.pay, файл и строка) это куда смотреть в коде, строки из java.base и фреймворков обычно пропускают. Блок Caused by это исходная причина, обёрнутая в другое исключение: настоящий виновник внизу цепочки, и ... 2 more означает, что хвост стека совпадает с уже напечатанным. Правило: смотреть на самый нижний Caused by, потом на первую свою строку в нём.

Три ловушки чтения. Стек показывает, где бросили, а не где ошиблись: NullPointerException в order.getCustomer().getEmail() возник здесь, а null пришёл откуда-то раньше, и с Java 14 сообщение подсказывает, что именно было null. Стек в логе сервера бывает обрезан или перепутан, если исключение перебросили без cause, о чём раздел про антипаттерны. И асинхронный код (пулы потоков, стримы) даёт стек, в котором нет вашего вызывающего кода, только внутренности исполнителя, потому что вызов был в другом потоке.

Отладка это второй инструмент, когда трейса мало. В IDE ставят точку останова на строке (клик слева от номера), запускают в режиме отладки, и программа замирает перед выполнением этой строки: справа видны значения всех переменных кадра, снизу стек вызовов, по которому можно перейти в любой кадр и посмотреть его переменные. Три кнопки: шаг через строку (выполнить и остановиться на следующей), шаг внутрь (зайти в вызываемый метод), продолжить до следующей точки. Условная точка останова (остановиться, только когда orderId == 42) экономит часы в циклах; точка останова на исключении останавливает в момент броска любого NullPointerException, до того как его кто-то поймал. И «вычислить выражение» позволяет вызвать любой метод на живых объектах. Отладчик для тестов работает так же, и это обычный способ разобраться в чужом коде: поставить точку в начале сценария и пройти его по шагам.

Глубже: файлы и ввод-вывод: java.nio.file, кодировки и потокирасширенное

Files.readString используется в этой статье и в статье про лямбды как данность. Работа с файлами в Java стоит на нескольких понятиях, и без них первый же файл с кириллицей читается кракозябрами.

Путь и файл. Path из java.nio.file описывает путь (Path.of("data", "orders.csv"), разделитель подставится под систему), Files делает с ним всё: существует ли, создать каталоги, прочитать, записать, удалить, перечислить. Старый java.io.File встречается в библиотеках, и у него есть toPath().

Маленькие файлы целиком. Files.readString(path) и Files.readAllLines(path) читают всё в память, Files.writeString(path, text) пишет. Годится для конфигураций и небольших данных; файл на гигабайт так читать нельзя, потому что он целиком окажется в куче.

Большие файлы потоком. Files.lines(path) отдаёт Stream<String> строк, читаемых по мере обработки, и его закрывают через try-with-resources, как любой ресурс с дескриптором; Files.newBufferedReader и newBufferedWriter для построчной работы; InputStream и OutputStream для байтов (картинки, архивы), всегда буферизованные и всегда закрытые. Строка lines.skip(1).mapToInt(...) в примере ниже - конвейер Stream API со стрелочной лямбдой: «пропустить заголовок, из каждой строки взять второе поле, сложить». Лямбды и конвейеры подробно разобраны в следующей статье фазы; здесь важна суть для ресурсов: Stream<String> читает файл лениво, при обходе, поэтому и закрывать его надо после обхода, а не при создании.

Кодировка. Файл это байты, а строка это символы, и между ними кодировка. С Java 18 умолчание везде UTF-8, до этого оно зависело от системы, и файл, записанный на Windows в cp1251, читался на сервере мусором. Кодировку указывают явно там, где файл пришёл извне: Files.readString(path, Charset.forName("windows-1251")); свои файлы пишут в UTF-8 и говорят об этом в контракте.

живой пример

import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.stream.Stream;

public class FilesDemo {
    public static void main(String[] args) throws Exception {
        Path dir = Files.createTempDirectory("demo");
        Path file = dir.resolve("orders.csv");
        Files.writeString(file, "id;total\n1;120\n2;350\n", StandardCharsets.UTF_8);
        System.out.println(Files.exists(file) + " " + Files.size(file));
        try (Stream<String> lines = Files.lines(file)) {
            System.out.println(lines.skip(1).mapToInt(l -> Integer.parseInt(l.split(";")[1])).sum());
        }
        Files.delete(file);
        Files.delete(dir);
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Три привычки. Пути не склеивают строками с /, а собирают через Path.of и resolve, и путь от пользователя проверяют на выход за пределы каталога (normalize().startsWith(base)), о чём статья про безопасность. Ресурсы из classpath (файлы внутри jar) читают через getResourceAsStream, а не через Path: внутри архива путей файловой системы нет. И временные файлы создают через Files.createTempFile, а не руками в /tmp, чтобы не спорить с соседями за имя.

Сериализация объектов. Объект сам по себе файлом не является: чтобы положить его в файл, в кэш или отправить по сети, его превращают в байты и обратно. Встроенный механизм Java включается маркерным интерфейсом Serializable: ObjectOutputStream.writeObject записывает граф объектов целиком, ObjectInputStream.readObject восстанавливает его. Поле с модификатором transient в поток не попадает и после чтения получает значение по умолчанию, так прячут пароли, соединения и кэши. Константа serialVersionUID это номер версии класса: если её не объявить, JVM вычислит номер из структуры класса, и после добавления любого поля старые байты перестанут читаться с InvalidClassException; явная константа позволяет читать старые данные, пока изменения совместимы.

живой пример

import java.io.*;

public class SerialDemo {
    static class Session implements Serializable {
        private static final long serialVersionUID = 1L;
        String user;
        transient String password;            // в поток не попадёт
        Session(String user, String password) { this.user = user; this.password = password; }
    }

    public static void main(String[] args) throws Exception {
        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
            out.writeObject(new Session("ivan", "qwerty"));
        }
        try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
            Session restored = (Session) in.readObject();
            System.out.println(restored.user + " " + restored.password);
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Две оговорки. Для обмена с другими системами и для долгого хранения берут JSON или Protobuf, а не байты Java: формат привязан к классам и к версии библиотек, и прочитать его может только Java. И readObject на данных извне (тело запроса, сообщение из очереди, cookie) выполняет код из зависимостей ещё до того, как вы увидите объект, о чём статья про дыры помимо доступа; встроенная сериализация остаётся для своих файлов и кэшей.

Коротко

  • Исключения отделяют обработку ошибок от основного кода: проблемный участок прерывается, ошибка всплывает вверх по стеку до первого подходящего catch.
  • В корне — Throwable. Error не ловим по месту (сбои JVM), Exception обрабатываем.
  • Checked (наследники Exception, кроме RuntimeException) компилятор заставляет обработать; unchecked (RuntimeException) — нет, это обычно ошибки в коде.
  • catch проверяются сверху вниз, от конкретного типа к общему; finally выполняется всегда — даже при return внутри try. Но return или throw внутри самого finally молча съедают исключение из try.
  • Для ресурсов — try-with-resources (AutoCloseable): закрывает сам, в обратном порядке, до входа в catch.
  • Свои исключения дают осмысленные ошибки; главные антипаттерны — пустой catch, потеря исходной причины и управление потоком через исключения.
  • Стек-трейс читают по первой своей строке at и по самому нижнему Caused by; стек показывает, где бросили, а не где ошиблись; отладчик даёт точку останова, шаги, условия и остановку на исключении.
  • Файлы: Path и Files, маленькие целиком через readString, большие потоком через lines в try-with-resources, кодировка явно для чужих файлов и UTF-8 для своих, пути через resolve, ресурсы из jar через getResourceAsStream.
  • Переопределяя метод, нельзя расширить его список checked-исключений (отсюда обёртки в лямбдах); рабочее правило: свои исключения unchecked, checked только там, где у вызывающего есть реальный второй путь.
  • Дорого создание исключения (сбор стека в fillInStackTrace, микросекунды против наносекунд), а не throw; JIT в горячем коде может отдать NullPointerException без строк at.
  • Serializable включает встроенную сериализацию: transient не пишется, serialVersionUID объявляют явно, иначе новое поле ломает чтение старых байтов; для внешних данных берут JSON или Protobuf, а readObject на чужих байтах не вызывают.

Что почитать дальше