Всем привет, меня зовут Сергей Прощаев, я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce. В этой статье расскажу про пять ошибок, которые джуны на Java совершают чаще всего.
Представьте: сервис собрался без единого warning, тесты зелёные, ревьюер поставил апрув, релиз уехал в прод. А через неделю в три часа ночи прилетает алерт — потребление памяти растёт, часть платежей где‑то теряется, и в логах при этом пусто. Вы открываете код, который месяц назад прошёл все проверки, и не понимаете, что не так. С виду — всё в порядке.
Знакомая картина? Так вот, ни компилятор, ни тесты на счастливом пути такие ошибки не ловят. Это не из разряда «не забыл ли я точку с запятой» — за такое ругается компилятор, и вы узнаёте об этом за две секунды. Меня интересует другой класс багов: код корректен синтаксически, но делает не то, что вы имели в виду, и проявляется это уже под нагрузкой, в проде, в самый неподходящий момент.
Я довольно много собеседовал джунов и мидлов и видел одну и ту же картину: человек бойко рассказывает про SOLID и паттерны, открывает IDE — и пишет код, на который статический анализатор краснеет на пяти строчках. В последние пару лет добавились ИИ‑ассистенты: кода стало больше, на вид он чище, но «тихих» ошибок не убавилось. Модель уверенно генерирует то, что выглядит правильно, а тот, кто ещё не набил руку, не видит подвоха.
Разберём пять конкретных ошибок: для каждой — симптом, причина, чем грозит и как исправить. А в конце соберём чек‑лист и обсудим, как в 2026 году команды страхуются от этого класса проблем на уровне процесса, а не героизма отдельного разработчика.

Ошибка 1. Сравнивать объекты через == вместо equals()
Классика, которую все знают в теории и всё равно совершают на практике.
Симптом. Вы сравниваете две строки или два Integer, и сравнение неожиданно возвращает false там, где значения визуально одинаковые. Или, что хуже, возвращает true на тестовых данных и false на боевых.
Integer a = 1000; Integer b = 1000; System.out.println(a == b); // false System.out.println(a.equals(b)); // true String s1 = new String("otus"); String s2 = "otus"; System.out.println(s1 == s2); // false
Почему возникает. Оператор == для ссылочных типов сравнивает не значения, а ссылки — то есть проверяет, один ли это объект в памяти. Для строк и обёрток над примитивами это почти всегда не то, что вам нужно. Коварство в кэше: Integer по умолчанию кэшируется в диапазоне от −128 до 127 (конкретная реализация JVM может этот диапазон расширять), и для таких значений == случайно работает. Поэтому джун пишет код, проверяет на 5, видит true и делает вывод, что всё в порядке. А на 1000 оно ломается.
Помню, как однажды разбирал багу, где сравнение идентификаторов работало на стейдже и падало в проде. Причина была ровно эта: на стейдже тестовые ID были маленькими и попадали в кэш Integer, а в проде — нет. Полдня потеряли на ровном месте.
Чем грозит. Неверная ветка бизнес‑логики, которую не видно в тестах. Самый неприятный сценарий — когда от такого сравнения зависит проверка прав или сверка сумм. В финтехе цена такой ошибки измеряется не нервами, а деньгами.
Как исправить. Для любых объектов используйте equals(). Для строк удобно ставить литерал слева — тогда не словите NullPointerException, если переменная окажется null:
if ("otus".equals(userInput)) { ... } // null-safe
А == оставьте для примитивов и для проверки на null.
Что ловит ревью. Сравнение объектов через == — одна из самых старых проверок в статических анализаторах, и ИИ‑ассистенты на ревью её обычно тоже подсвечивают. При корректно настроенных правилах анализа машина закрывает эту ошибку в большинстве случаев — но именно при настроенных, без анализатора в сборке надеяться не на что.
Ошибка 2. Переопределить equals() и забыть про hashCode()
Вытекает из первой и встречается даже у тех, кто уже выучил про equals().
Симптом. Вы кладёте объект в HashMap или HashSet, а потом не можете его найти. Объект буквально «теряется»: вы только что его положили, а contains() возвращает false.
class User { final String email; User(String email) { this.email = email; } @Override public boolean equals(Object o) { if (!(o instanceof User user)) return false; return Objects.equals(email, user.email); } // hashCode() не переопределён — вот она, ошибка } var set = new HashSet<User>(); set.add(new User("a@otus.ru")); System.out.println(set.contains(new User("a@otus.ru"))); // false
Почему возникает. Хэш‑структуры сначала ищут объект по hashCode() (определяют корзину), и только потом внутри корзины сравнивают через equals(). Если переопределить только equals(), два «равных» объекта получают разные хэш‑коды от Object по умолчанию, попадают в разные корзины, и до сравнения через equals() дело просто не доходит. Контракт нарушен: равные объекты обязаны возвращать одинаковый hashCode().
Чем грозит. Дубликаты в Set, который должен был их отсекать. Потерянные значения в Map. Особенно неприятно, что ошибка может долго никак себя не проявлять, а потом всплыть только там, где объект попадает в HashMap или HashSet — то есть далеко от того места, где equals() был написан.
Как исправить. Всегда переопределяйте equals() и hashCode() в паре, по одним и тем же полям. Для value object и неизменяемых DTO хорошим вариантом будет record: он корректно генерирует equals(), hashCode() и toString() за вас.
record User(String email) {}
Оговорюсь: record подходит не везде. Для JPA Entity, например, его обычно не используют — у сущности равенство определяется по идентификатору, а не по всем полям, и тут логику равенства приходится продумывать отдельно. Мой вариант, который я обычно использую: если класс по смыслу неизменяемый носитель данных — это record, и точка. Руками equals()/hashCode() я пишу только там, где есть нестандартная логика равенства, и тогда обкладываю это тестами.
Что ловит ревью. Переопределённый equals() без парного hashCode() статанализ и ИИ‑ревью часто замечают и предлагают дописать метод или перейти на record. А вот ошибку внутри самого equals() (сравнили не те поля) машина уже не поймает: это зона тестов.
Ошибка 3. Молча проглатывать исключения
Это уже не про синтаксис, а про отношение к коду — и, пожалуй, дороже всего обходится в эксплуатации.
Симптом. Что‑то идёт не так в проде, но в логах пусто. Ошибки нет, стектрейса нет, а функциональность не работает. Вы вслепую гадаете, где сломалось.
try { paymentClient.charge(order); } catch (Exception e) { // тишина }
Или чуть «приличнее», но по сути так же бесполезно:
try { paymentClient.charge(order); } catch (Exception e) { e.printStackTrace(); }
Почему возникает. Пустой catch появляется, когда исключение «мешает»: код не компилируется без обработки checked‑исключения, и самый быстрый способ заставить компилятор замолчать — поймать и ничего не делать. printStackTrace() чуть лучше, но в боевой системе с централизованным логированием этот вывод обычно уходит в никуда и в вашу систему сбора логов не попадает.
Кстати, именно здесь ИИ‑ассистенты подкидывают коварные варианты. Модель часто генерирует try/catch с заглушкой, чтобы код компилировался, и если не присматриваться, такой блок легко проходит в коммит. Я как‑то ревьюил сгенерированный сервис и нашёл три пустых catch подряд — каждый из них прятал реальный сетевой сбой.
Чем грозит. Потерянные ошибки превращаются в инциденты без следов. Платёж не прошёл, а система считает, что всё хорошо. Восстановление данных после такого — отдельная боль, потому что вы даже не знаете, какие именно операции потерялись и когда.
Как исправить. Минимальное правило: если вы поймали исключение и не можете его осмысленно обработать здесь — не глотайте, пробросьте выше или оберните в доменное исключение, сохранив причину. И логируйте через нормальный логгер, а не в консоль:
try { paymentClient.charge(order); } catch (PaymentException e) { log.error("Не удалось списать оплату по заказу {}", order.id(), e); throw new OrderProcessingException("Платёж отклонён", e); }
Обратите внимание на e последним аргументом — так в лог попадёт полный стектрейс с первопричиной. Но не впадайте в другую крайность: механически оборачивать каждое исключение в новый тип не нужно — лишние слои исключений часто только ухудшают диагностику. Оборачивать имеет смысл там, где вы переходите между слоями абстракции (например, прячете деталь инфраструктуры за доменным исключением). И нюанс про тип: в бизнес‑логике обычно не стоит ловить голый Exception, лучше перехватывать ожидаемые типы. Но есть слои, где ловить Exception (а иногда Throwable) — норма: граница приложения, worker‑поток, шедулер, consumer очереди, где задача обратная — не дать исключению утечь и убить поток. Главное, что и там исключение логируется, а не молча проглатывается.
Что ловит ревью. Пустой catch статанализ и ИИ‑ревью в большинстве случаев помечают как code smell. А printStackTrace() формально не ошибка, и далеко не каждый инструмент на него ругается — здесь всё ещё нужен человек, который знает, как устроено логирование в проекте.
Ошибка 4. Управлять ресурсами руками вместо try‑with‑resources
Я называю это «застрял в 2010-м»: человек выучил один паттерн на старой Java и тащит его в новый код, хотя язык давно предлагает лучше.
Симптом. Сервис работает, но со временем начинает деградировать: растёт число открытых файловых дескрипторов или соединений с базой, пока однажды не прилетает Too many open files. Перезапуск помогает — на время.
Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("SELECT ..."); // обработали результат rs.close(); stmt.close(); conn.close();
Почему возникает. Ресурсы закрываются вручную в конце метода. Пока всё идёт по счастливому пути — работает. Но стоит запросу бросить исключение между открытием и закрытием, и строки с close() просто не выполнятся. Ресурс утечёт. А если кто‑то добавит ранний return в середине метода — то же самое. Ручное управление надёжно ровно до первой нестандартной ситуации.
Чем грозит. Утечка ресурсов — это медленная смерть сервиса. Она не проявляется сразу, копится днями, а потом роняет приложение в самый неподходящий момент, обычно под нагрузкой. И диагностировать её тяжело, потому что симптом отстоит от причины во времени.
Как исправить. Конструкция try‑with‑resources появилась ещё в Java 7 и закрывает ресурсы автоматически, в правильном порядке, даже если внутри вылетело исключение. Всё, что реализует AutoCloseable, можно объявить в круглых скобках:
String sql = "SELECT id, email FROM users WHERE active = true"; try (var conn = dataSource.getConnection(); var stmt = conn.prepareStatement(sql); var rs = stmt.executeQuery()) { while (rs.next()) { // обрабатываем } } // всё закрыто автоматически, в обратном порядке
В обычном прикладном коде появление ручного close() на ревью почти всегда заставляет меня проверить, нельзя ли заменить его на try‑with‑resources. Оговорюсь, что это рекомендация, а не догма: есть области, где жизненным циклом ресурса сознательно управляют вручную или снаружи — Netty, Reactor, часть NIO‑API, off‑heap‑ресурсы, собственные lifecycle‑компоненты. Но там это осознанное решение, а не забытый close() в обычном сервисном методе. Кроме автоматического закрытия, у конструкции есть ещё один плюс, который часто упускают: она корректно сохраняет suppressed exceptions. Если исключение вылетело в теле, а потом ещё одно — при закрытии ресурса, второе не затирает первое, а прикрепляется к нему. При ручном закрытии такие исключения обычно теряются, и вы видите только то, что случилось последним.
Оговорюсь, чтобы не создавать ложного впечатления: с «голым» JDBC, как выше, в современном Spring‑коде работают редко. В JdbcTemplate, Spring Data, JPA/Hibernate управление соединениями и курсорами уже инкапсулировано, закрывать вручную не нужно. Но как только вы открываете собственный AutoCloseable — файл, сокет, свой клиент к внешней системе — правило снова в силе.
Что ловит ревью. Незакрытый ресурс статанализ находит хорошо: отслеживает AutoCloseable, открытый, но не закрытый на всех путях. ИИ‑ревью часто тоже предлагает переписать ручной close() на try‑with‑resources. Эту проверку разумно доверить машине.
Ошибка 5. Возвращать наружу изменяемую внутреннюю коллекцию
Тоньше предыдущих: её не видно ни в компиляторе, ни обычно в тестах — состояние объекта кто‑то меняет снаружи.
Симптом. У вас есть класс с внутренним списком. Вы возвращаете его геттером. А потом обнаруживаете, что список изменился, хотя ни один метод вашего класса его не трогал.
class Order { private final List<Item> items = new ArrayList<>(); public List<Item> getItems() { return items; // отдаём наружу ссылку на внутренний список } } Order order = orderRepository.findById(id); order.getItems().clear(); // снаружи очистили внутреннее состояние заказа
Почему возникает. Геттер возвращает прямую ссылку на внутреннюю коллекцию. Это значит, что любой вызывающий код получает полный доступ к вашему приватному состоянию и может его менять — добавлять, удалять, чистить. Инкапсуляция, ради которой поле сделали private, дырявая: private защищает ссылку на список, но не сам список.
Чем грозит. Состояние объекта меняется из непредсказуемых мест. Баг почти невозможно отследить, потому что виновник — где‑то совсем в другом модуле, который «просто взял список и поправил под себя». Особенно весело, когда таких потребителей несколько и они работают в разных потоках.
Как исправить. Не отдавайте внутреннюю коллекцию напрямую. Минимум — оберните в неизменяемое представление, чтобы снаружи её нельзя было поменять:
public List<Item> getItems() { return Collections.unmodifiableList(items); }
Здесь важно понимать разницу между двумя вариантами, потому что её часто путают. Оба защищают потребителя от модификации, но по‑разному. Collections.unmodifiableList() возвращает представление (view): менять список через него нельзя, но если внутренний items изменится, потребитель увидит эти изменения — он смотрит на ту же коллекцию через «стекло». А List.copyOf() (с Java 10) делает снимок (snapshot): копию, зафиксированную на момент вызова и больше не зависящую от оригинала.
public List<Item> getItems() { return List.copyOf(items); }
Выбираю так: нужен актуальный вид без права изменения — unmodifiableList(); нужен независимый снимок, который не изменится вслед за оригинальной коллекцией — List.copyOf(). Снимок бывает полезен и в многопоточном коде, когда важно получить консистентное состояние коллекции на момент вызова. Но сам по себе он не делает доступ к исходной коллекции потокобезопасным — он лишь фиксирует то, что было на момент копирования. Принцип один: класс сам управляет своим состоянием, а наружу отдаёт данные, а не рычаги управления ими.
Что ловит ревью. Возврат изменяемого внутреннего поля наружу (representation exposure) статанализ обычно детектирует, и ИИ‑ревью часто предлагает обернуть результат. Но выбор между view и snapshot — проектное решение: машина подскажет проблему, а вариант выбираете вы.
Сводная таблица
Чтобы удобно было возвращаться к статье на ревью — все пять ошибок в одной таблице.
Ошибка |
Признак в коде |
Что проверить |
|---|---|---|
|
Сравнение объектов через |
Сравнение строк и обёрток ведётся через |
|
Переопределён только один из методов |
|
Проглоченное исключение |
Пустой catch или |
Нет пустых catch; ловится конкретный тип; в лог через логгер уходит причина (e последним аргументом) |
Ручной |
|
Все AutoCloseable открываются в |
Возврат внутренней коллекции |
Геттер возвращает private‑список напрямую |
Наружу отдаётся |
Что объединяет эти ошибки и как с ними бороться системно
Если присмотреться, у всех пяти ошибок общий корень. Компилятор проверяет, что код синтаксически корректен и типы сходятся. Но он не проверяет, что код делает то, что вы имели в виду. «Скомпилировалось» и «корректно» — это два разных утверждения, и расстояние между ними как раз и есть та зона, где живут баги из этой статьи.
Схема ниже показывает этот разрыв и то, какой уровень контроля ловит ошибку на каждом этапе — от компилятора до ревью человеком. Смотрите рисунок 2.

Главная мысль этой схемы: компилятор — только первый и самый слабый фильтр, и все пять ошибок из статьи он пропускает. Зрелая команда не надеется, что разработчик никогда не ошибётся, а ставит несколько слоёв сетки, где каждый следующий ловит то, что просочилось через предыдущий.
Теперь про инструменты — в 2026 году это уже не «приятно иметь», а индустриальный стандарт.
Статический анализ в сборке. В enterprise‑Java контроль качества чаще всего держится на SonarQube, SpotBugs, PMD и Checkstyle — это инструменты, которые стоят в огромном числе команд, и нередко несколько из них работают вместе. Один из вариантов, заточенный именно под ловлю багов на этапе компиляции, — связка Google Error Prone и Uber NullAway. Error Prone — плагин к компилятору поверх стандартных проверок, ловит подозрительные сравнения и десятки других паттернов; NullAway специализируется на NullPointerException.
Этот стек распространён не везде (многие команды про Error Prone даже не слышали), но как пример анализа в момент компиляции он показателен. С аннотациями nullability похожая история: после многолетней чехарды несовместимых @Nullable отрасль постепенно движется к единому стандарту JSpecify. Версия 1.0 уже вышла, а Spring Boot 4 (релиз в ноябре 2025) перевёл на эти аннотации весь свой портфель, заменив ими прежние @Nullable/@NonNull из org.springframework.lang. Но поддержка инструментами ещё дозревает, и многие проекты пока живут на JetBrains‑аннотациях, Checker Framework или собственных наборах. Так или иначе, анализатор настраивается так, что нарушение роняет сборку — и «тихая» ошибка перестаёт быть тихой.
Платформенный анализ и quality gates. SonarQube остаётся самым массовым инструментом для контроля качества на уровне всего проекта, и он полностью поддерживает актуальные LTS‑версии Java вплоть до 25-й. Его ценность не только в правилах, но и в quality gate — пороге, который не пускает в основную ветку код, не дотягивающий до планки команды. Это переводит борьбу с ошибками из плоскости «заметит ли ревьюер» в плоскость «система не пропустит».
Если вы только выстраиваете этот конвейер у себя, порядок внедрения важен: дешёвые и быстрые проверки идут раньше дорогих. Рисунок 3 показывает последовательность, которую я обычно рекомендую командам.

Главная мысль здесь: не пытайтесь поставить всё сразу. Начните с самого дешёвого и раннего — анализа на этапе компиляции. Тогда к моменту ревью человеком машина уже отсеяла механический мусор, и ревьюер тратит внимание на логику и архитектуру, а не на расстановку
equals(). Code review стоит последним не потому, что он слабее, а потому, что человеческое внимание — самый дорогой ресурс, и расходовать его на то, что ловит линтер, расточительно.
Отдельно про код от ИИ, раз уж он в названии. Как видно из блоков «что ловит ревью» выше, машина закрывает механическую часть этих ошибок — но не всю. ИИ‑ассистент ускоряет написание кода, но не берёт ответственность за корректность: все пять ошибок он генерирует с уверенным видом, они ведь синтаксически безупречны. Поэтому правило простое: сгенерированный код проходит ровно тот же конвейер проверок, что и написанный руками. Скорее даже к нему присматриваются внимательнее — человек не держал эти строки в голове, когда они появлялись.
И это, на мой взгляд, подводит к главному.
Какой навык на самом деле проверяет эта группа ошибок
Если свести всё к одному, проверяется здесь не знание синтаксиса Java — его как раз ИИ закрывает на отлично. Проверяется инженерная привычка не доверять зелёной сборке и строить защиту так, чтобы ошибка ловилась автоматически, до того как доберётся до пользователя.
Джуна от инженера из продакшена отличает именно это. Не умение написать код, который компилируется — компилируется почти всё. А умение посмотреть на свой код глазами того, кто будет разбирать инцидент в три часа ночи, и заранее закрыть дыры, через которые «тихий» баг просочится в прод. Все пять ошибок из статьи — маркеры этого навыка.
Хорошая новость в том, что навык тренируется. В какой‑то момент рука сама пишет record вместо ручного equals() и try‑with‑resources вместо close(), а на ревью глаз цепляется за пустой catch раньше, чем за опечатку. Это и есть переход от «написал и работает» к «написал и не сломается под нагрузкой через месяц» — и именно его стоит в себе целенаправленно прокачивать.
Хотите глубже разобраться, почему Java-код может вести себя не так, как ожидается, даже после успешной компиляции? Обратите внимание на бесплатные уроки по теме:
1 июля в 20:00 — «Алгоритмическая сложность коллекций в Java»
Разберём, как коллекции работают под капотом и какие решения в коде потом влияют на производительность и корректность.22 июля в 20:00 — «DAO на Spring JDBC»
Поговорим о работе с данными в Java-приложениях и практиках, которые помогают писать более надёжный код при взаимодействии с БД.
Больше открытых уроков июля смотрите в дайджесте.
MishaBucha
но как минимум первые три бага появляются как раз на тестах, а не при нагрузке) Сомневаюсь, что на 5 рпс и на 100000 == вместо equals работает по-разному!)