Практический курс по «Эффективному Java» для Java-разработчика

Курс помогает системно пройти ключевые идеи книги «Эффективное Java» и применить их в реальном коде. Разберём проектирование API, работу с объектами, generics/enum/аннотации, лямбды и потоки, а также параллелизм и практики надёжности.

1. Создание и уничтожение объектов: фабрики, синглтоны, зависимости

Создание и уничтожение объектов: фабрики, синглтоны, зависимости

Создание объектов в Java выглядит простым: вызвал new — получил экземпляр. Но в реальном коде способ создания и уничтожения объектов сильно влияет на:

  • читаемость API
  • производительность (кэширование, повторное использование)
  • тестируемость (подмена зависимостей)
  • безопасность (контроль количества экземпляров)
  • корректность (закрытие ресурсов)
  • В этой статье разберём три ключевые темы из духа Effective Java: фабрики (в том числе статические фабричные методы), синглтоны и зависимости, а также практичное уничтожение объектов через управление ресурсами.

    !Сравнение прямого создания через new, фабрик и внедрения зависимостей

    Фабрики вместо конструкторов

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

    Статические фабричные методы

    Статический фабричный метод — это public static метод, который возвращает экземпляр класса.

    Пример:

    Снаружи это выглядит так:

    Почему фабрики часто лучше конструкторов

  • Говорящее имя: ofDollars понятнее, чем new Money(500000).
  • Контроль создания: метод может возвращать не новый объект, а кэшированный.
  • Возврат подтипа: фабрика может вернуть реализацию интерфейса, скрыв конкретный класс.
  • Можно менять реализацию без изменения клиентского кода: сигнатура метода остаётся, а логика внутри эволюционирует.
  • Хорошо знакомые примеры из стандартной библиотеки:

  • Boolean.valueOf(String) может возвращать заранее созданные Boolean.TRUE или Boolean.FALSE.
  • List.of(...) создаёт неизменяемые списки.
  • Ссылки:

  • Javadoc Boolean.valueOf)
  • Javadoc List.of)
  • Ограничения фабрик

    У фабрик тоже есть минусы:

  • Нельзя создавать подклассы, если конструкторы скрыты и класс final (иногда это плюс).
  • Методы не так заметны, как конструкторы: разработчик может не догадаться, что создавать объект нужно через of(...).
  • Чтобы уменьшить второй минус, придерживаются распространённых имён фабрик:

  • of(...) — создание из набора параметров
  • valueOf(...) — преобразование/парсинг в значение
  • getInstance() — вернуть экземпляр (возможно один и тот же)
  • newInstance() — обычно обещает новый экземпляр
  • getType() — вернуть объект определённого типа (часто из фабрики)
  • Когда фабрика — это отдельный класс

    Иногда создание сложно или зависит от окружения (конфигурации, файлов, сети). Тогда выносите создание в отдельный класс.

    Такой подход особенно полезен вместе с внедрением зависимостей: фабрика становится зависимостью, которую можно подменить в тестах.

    Синглтоны

    Синглтон — это класс, у которого должен существовать ровно один экземпляр (или логически один экземпляр на JVM).

    Когда синглтон оправдан

  • объект действительно глобален по смыслу: реестр метрик, конфигурация, фабрика без состояния
  • создание очень дорого и его нужно разделить на всех
  • Но синглтоны часто усложняют тестирование и повышают связность. Поэтому прежде чем делать синглтон, спросите себя: можно ли передавать этот объект как зависимость?

    Лучший способ: enum-синглтон

    Рекомендуемый вариант в Java:

    Использование:

    Почему это хорошо:

  • гарантируется один экземпляр
  • проще сериализация
  • сложнее “сломать” рефлексией
  • Ссылка:

  • JLS: Enum Classes
  • Классический синглтон через поле

    Иногда нужен “обычный” класс. Тогда используют:

    Или ленивую инициализацию (когда объект создаётся при первом обращении). Но ленивые варианты легко сделать неправильно с точки зрения потокобезопасности, поэтому применяйте их только осознанно.

    Зависимости и внедрение зависимостей

    Зависимость — это объект, который вашему классу нужен для работы (например, репозиторий, логгер, клиент внешнего сервиса).

    Главная идея: класс не должен сам создавать свои зависимости, если вы хотите гибкость и тестируемость.

    Плохой признак: зависимость создаётся внутри

    Минусы:

  • сложно тестировать (нельзя подставить фейковый репозиторий)
  • класс жёстко привязан к конкретной реализации
  • сложнее менять окружение (другая БД, другие настройки)
  • Хороший вариант: передавать зависимость снаружи

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

    Теперь в продакшене можно передать PostgresUserRepository, а в тестах — InMemoryUserRepository.

    Внедрение фабрики или поставщика

    Если зависимость создавать дорого, или она нужна не всегда, можно внедрить поставщика (Supplier<T>):

    Ссылка:

  • Javadoc Supplier
  • Синглтон против DI

    Частая практическая рекомендация:

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

    В Java обычные объекты уничтожает сборщик мусора (GC). Разработчик обычно не “удаляет” объект вручную.

    Но есть отдельный класс объектов: ресурсы, которые держат внешние штуки:

  • файлы
  • сокеты
  • соединения с БД
  • дескрипторы
  • потоки ввода-вывода
  • Такие ресурсы нужно закрывать явно.

    AutoCloseable и try-with-resources

    Если объект реализует AutoCloseable, его можно безопасно закрывать через try-with-resources.

    Что важно:

  • close() вызовется автоматически даже при исключении
  • если ресурсов несколько, они закрываются в обратном порядке создания
  • Ссылки:

  • Javadoc AutoCloseable
  • Java Tutorials: The try-with-resources Statement
  • Почему не стоит полагаться на finalizer и cleaner

    Исторически в Java был механизм finalize(), который мог выполняться перед сборкой мусора. На практике он:

  • непредсказуем по времени
  • ухудшает производительность
  • может приводить к проблемам безопасности и утечкам
  • В современных версиях Java финализация считается устаревшим подходом. Если и нужен механизм “страховки”, то он не должен быть основным способом закрытия ресурса.

    Практическое правило: ресурс должен закрываться тем кодом, который его открыл, и лучше всего через try-with-resources.

    Практические рекомендации

  • Используйте статические фабрики, когда важны имена, кэширование, сокрытие реализации или контроль экземпляров.
  • Синглтоны применяйте осторожно; если нужен — предпочтите enum-синглтон.
  • Не создавайте зависимости внутри бизнес-классов: используйте внедрение зависимостей (конструктор, фабрика, Supplier).
  • Для ресурсов используйте AutoCloseable и try-with-resources.
  • В следующей статье логично перейти к теме “контракта методов и классов”: как правильно проектировать equals, hashCode, toString и другие базовые элементы, чтобы созданные объекты корректно работали в коллекциях и логике приложения.

    2. Методы common: equals/hashCode/toString/compareTo и безопасность

    Методы common: equals/hashCode/toString/compareTo и безопасность

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

    В этой статье фокус смещается на поведение уже созданных объектов в типичных контекстах Java:

  • сравнение объектов на равенство
  • работа в HashMap/HashSet
  • логирование и диагностика
  • сортировка в TreeSet/TreeMap и через Collections.sort
  • Ошибки в equals, hashCode, toString, compareTo редко видны сразу, но затем проявляются как:

  • “пропавшие” элементы в коллекциях
  • дубли в Set
  • некорректная сортировка
  • утечки секретов в логах
  • уязвимости, связанные с наследованием и подменой типов
  • !Как методы равенства и сравнения влияют на поведение хэш- и деревообразных коллекций

    equals: что такое равенство и какой у него контракт

    Метод equals отвечает на вопрос: являются ли два объекта логически одним и тем же значением.

    Когда переопределять equals

    Переопределяйте equals, когда:

  • у класса есть понятие значения (например, деньги, координаты, диапазоны)
  • объекты должны корректно работать как ключи в Map или элементы в Set
  • Не переопределяйте equals, когда:

  • идентичность важнее значения (например, сущность с уникальным id, где два объекта с одинаковыми полями не обязаны быть равными)
  • класс представляет ресурс с жизненным циклом (соединение, поток), где равенство по полям бессмысленно
  • Ссылка на контракт:

  • Javadoc Object.equals)
  • Контракт equals

    Контракт требует, чтобы equals был:

  • рефлексивным: x.equals(x) всегда true
  • симметричным: если x.equals(y) true, то y.equals(x) тоже true
  • транзитивным: если x.equals(y) и y.equals(z) true, то x.equals(z) тоже true
  • согласованным: повторные вызовы при неизменных данных дают одинаковый результат
  • корректным для null: x.equals(null) всегда false
  • Частые ошибки equals

  • Сравнение ссылок вместо значений, где нужно значение.
  • Использование изменяемых полей: объект положили в HashSet, поле изменили, и элемент становится “невидимым”.
  • Нарушение симметрии и транзитивности при наследовании.
  • equals и наследование: где чаще всего ломается контракт

    Проблемный сценарий: базовый класс определяет equals через instanceof, а подкласс добавляет новые значимые поля.

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

    Практический подход из духа Effective Java:

  • если вы хотите безопасный equals, предпочитайте композицию наследованию для “значимых” типов
  • если класс предназначен для наследования, особенно осторожно выбирайте стратегию equals и документируйте её
  • Шаблон корректного equals

    Типичный шаблон для финального класса (или класса, где вы уверены в модели равенства):

    Замечания:

  • this == o даёт быстрый выход.
  • getClass() делает равенство строгим по типу: это часто проще и безопаснее для value-типов.
  • Для сравнения объектов используйте Objects.equals(a, b).
  • Ссылка:

  • Javadoc Objects.equals)
  • hashCode: обязательная пара к equals

    hashCode нужен хэш-коллекциям: HashMap, HashSet, ConcurrentHashMap.

    Ссылки:

  • Javadoc Object.hashCode)
  • Javadoc HashMap
  • Контракт hashCode

    Если x.equals(y) равно true, то x.hashCode() должен быть равен y.hashCode().

    Обратное не требуется: одинаковый hashCode не гарантирует equals.

    Главный практический вывод

    Если вы переопределили equals, вы почти всегда должны переопределить hashCode.

    Иначе объект может:

  • “теряться” в HashSet
  • не находиться по ключу в HashMap
  • Реализация hashCode

    Удобный и достаточно качественный вариант для большинства случаев:

    Ссылка:

  • Javadoc Objects.hash)
  • Замечания:

  • Objects.hash(...) проще, но может быть медленнее на очень горячих путях.
  • для неизменяемых объектов допустимо кешировать вычисленный хэш-код, если это реально нужно по профилированию.
  • Безопасность и устойчивость

    Плохой hashCode (например, константа) делает HashMap похожим на список и может привести к деградации производительности.

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

    Практика:

  • делайте hashCode зависимым от значимых полей
  • не используйте константу
  • не включайте в equals/hashCode данные, которые могут меняться, пока объект используется как ключ
  • toString: диагностика без утечек

    toString нужен людям: логирование, отладка, сообщения об ошибках.

    Ссылка:

  • Javadoc Object.toString)
  • Рекомендации по toString

  • выводите важные поля, чтобы объект можно было понять по логам
  • делайте формат стабильным, если на него могут завязаться тесты или мониторинг
  • явно документируйте формат, если он предназначен для машинного парсинга
  • Пример:

    Безопасность: что нельзя делать в toString

    toString часто оказывается в логах автоматически (например, при логировании DTO или исключений). Поэтому:

  • не выводите секреты: пароли, токены, ключи API, содержимое приватных данных
  • будьте осторожны с персональными данными
  • Практика:

  • маскируйте: показывайте только часть (token=abcd...wxyz)
  • разделяйте модели: отдельные “безопасные для логов” представления
  • compareTo: естественный порядок и его контракт

    compareTo задаёт естественный порядок объектов и используется в сортировках и структурах данных.

    Ссылки:

  • Javadoc Comparable
  • Javadoc TreeSet
  • Базовые правила compareTo

    Метод возвращает:

  • отрицательное число, если this меньше аргумента
  • ноль, если равны по порядку
  • положительное число, если больше
  • Контракт compareTo

    Важно обеспечить:

  • антисимметричность знака: sign(x.compareTo(y)) == -sign(y.compareTo(x))
  • транзитивность порядка
  • согласованность: при неизменных данных результат стабилен
  • compareTo и equals: самая частая ловушка

    Если compareTo возвращает 0 для двух объектов, TreeSet и TreeMap будут считать их одинаковыми ключами/элементами, даже если equals говорит, что они разные.

    Поэтому практическое правило:

  • делайте естественный порядок согласованным с equals, если объекты предполагается хранить в TreeSet/TreeMap
  • Как правильно сравнивать числа

    Частая ошибка:

    Она может дать переполнение int и нарушить порядок.

    Правильно:

    Или для long:

    Ссылки:

  • Javadoc Integer.compare)
  • Javadoc Long.compare)
  • Comparator как альтернатива

    Если у класса нет единственного “естественного” порядка (например, сортировка пользователя по имени, по дате регистрации, по рейтингу), вместо Comparable удобнее использовать Comparator.

    Ссылка:

  • Javadoc Comparator
  • Практические связки с темой создания объектов

    Эти темы напрямую связаны с тем, как вы проектируете создание объектов:

  • если вы используете статические фабрики, вы можете возвращать кешированные неизменяемые value-объекты, и тогда equals/hashCode становятся ещё важнее
  • если вы внедряете зависимости, то equals сервисов обычно не нужен, а вот toString полезен для диагностики конфигурации, но без утечки секретов
  • если объект является синглтоном, то равенство по ссылке часто совпадает с логическим, но это не причина писать плохой equals в value-типах вокруг него
  • Итоги

  • equals определяет логическое равенство и должен соблюдать контракт.
  • hashCode обязателен к переопределению вместе с equals, иначе ломаются хэш-коллекции.
  • toString должен помогать диагностике, но не должен раскрывать секреты.
  • compareTo задаёт естественный порядок; избегайте вычитания при сравнении чисел и следите за согласованностью с equals, если используете деревья.
  • Следующая логичная тема курса после этих контрактов — проектирование API методов: параметры, возвращаемые значения, исключения и документирование поведения, чтобы классы были не только корректными, но и удобными для использования.

    3. Классы и интерфейсы: инкапсуляция, наследование, иммутабельность, API-дизайн

    Классы и интерфейсы: инкапсуляция, наследование, иммутабельность, API-дизайн

    В предыдущих статьях мы разобрали:

  • как создавать объекты (фабрики, синглтоны, внедрение зависимостей) и как управлять ресурсами
  • как сделать объекты корректными в коллекциях и сортировках через equals/hashCode/toString/compareTo
  • Следующий слой практики Effective Java — проектирование типов: классов и интерфейсов. Именно здесь закладываются ограничения, безопасность, расширяемость и удобство API.

    !Граница публичного API и сравнение наследования с композицией

    Инкапсуляция как основа дизайна

    Инкапсуляция — это управление тем, что другие части программы могут видеть и использовать. Цель простая: уменьшить количество связей между частями кода.

    Минимизируйте доступность

    Практическое правило: делайте всё максимально закрытым, пока не появится причина открыть.

  • private — по умолчанию для полей и внутренних методов.
  • отсутствие модификатора (package-private) — полезно для тестов и внутренней архитектуры пакета.
  • public — обещание поддержки и обратной совместимости.
  • Ссылка:

  • JLS: Access Control
  • Поля должны быть скрыты

    Публичные поля ломают инварианты (внутренние правила объекта) и делают изменение реализации практически невозможным.

    Плохо:

    Лучше:

    Не выдавайте наружу изменяемые внутренности

    Если класс хранит изменяемый объект (например, Date, массив, List) и вы возвращаете его напрямую, клиент может изменить состояние в обход ваших правил.

    Опасно:

    Правильно через защитные копии:

    Ссылка:

  • Javadoc Date
  • Наследование: где оно помогает, а где ломает дизайн

    Наследование — сильный инструмент, но в прикладном коде часто приводит к хрупкости.

    Наследование нарушает инкапсуляцию

    Подкласс зависит не только от публичного API базового класса, но и от его внутреннего поведения, даже если оно не задокументировано. Изменили реализацию базового класса — сломали подкласс.

    Классический пример — попытка расширить коллекцию:

    Почему это опасно: HashSet.addAll внутри может вызывать add, и счётчик начнёт считать дважды — поведение зависит от деталей реализации.

    Ссылка:

  • Javadoc HashSet
  • Предпочитайте композицию наследованию

    Композиция — это когда ваш класс содержит другой объект и делегирует ему работу, сохраняя контроль над поведением.

    Композиция:

  • не зависит от внутренних деталей реализации делегата
  • проще тестируется (делегат можно заменить)
  • хорошо сочетается с темой внедрения зависимостей из предыдущей статьи
  • Когда наследование оправдано

    Наследование подходит, если вы действительно моделируете отношение is-a и базовый класс специально спроектирован для расширения.

    Чтобы класс был безопасен для наследования, он должен:

  • документировать, какие методы можно переопределять и в каком порядке они вызываются
  • избегать вызовов переопределяемых методов из конструктора
  • Ссылка:

  • Javadoc Object
  • Интерфейсы как контракт

    Интерфейс — это обещание поведения без привязки к реализации. Это напрямую помогает API-дизайну: пользователи кода зависят от контракта, а не от конкретного класса.

    Проектируйте через интерфейсы

    Практика:

  • принимайте параметры как интерфейсы (List, Map, Set), а не как конкретные классы (ArrayList, HashMap)
  • возвращайте интерфейсы, чтобы иметь свободу менять реализацию
  • Ссылки:

  • Javadoc List
  • Javadoc Map
  • Абстрактные классы и скелетные реализации

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

    Примеры в JDK:

  • AbstractList
  • AbstractMap
  • Ссылки:

  • Javadoc AbstractList
  • Javadoc AbstractMap
  • Default-методы: осторожно с совместимостью

    default-методы позволяют расширять интерфейсы без ломки существующих реализаций, но:

  • они могут вводить неоднозначности при множественном наследовании интерфейсов
  • они становятся частью публичного контракта и их сложно менять
  • Ссылка:

  • JLS: Interface Method Declarations
  • Иммутабельность: проще контракт, меньше ошибок

    Неизменяемый объект — тот, чьё состояние нельзя изменить после создания.

    Почему неизменяемые классы выгодны

  • проще соблюдать контракт equals/hashCode (из прошлой статьи): поля не меняются, объект не «теряется» в HashSet
  • легче писать потокобезопасный код: неизменяемые объекты безопасно шарить между потоками
  • меньше скрытых побочных эффектов
  • Правила построения неизменяемого класса

  • не предоставляйте методов, меняющих состояние (никаких set...)
  • сделайте класс final или запретите расширение другим способом
  • сделайте все поля private final
  • гарантируйте, что все компоненты тоже неизменяемы, либо используйте защитные копии
  • Пример value-типа Money (связка с фабриками из первой статьи):

    Records: удобный инструмент, но не «магическая иммутабельность»

    record помогает быстро описывать прозрачные носители данных и автоматически генерирует equals/hashCode/toString.

    Важно понимать:

  • поля record — final, но если поле ссылается на изменяемый объект (например, List), содержимое списка остаётся изменяемым
  • если нужен настоящий неизменяемый API, используйте копирование и неизменяемые коллекции
  • Ссылка:

  • JLS: Record Classes
  • API-дизайн класса: что обещать и как не загнать себя в угол

    Хороший API — это тот, который сложно использовать неправильно.

    Определите и защищайте инварианты

    Инварианты — это условия, которые всегда должны быть истинными для корректного состояния объекта.

    Практика:

  • валидируйте параметры в конструкторах и фабриках
  • используйте Objects.requireNonNull
  • выбрасывайте понятные исключения (IllegalArgumentException, NullPointerException при нарушении контракта)
  • Ссылка:

  • Javadoc Objects.requireNonNull)
  • Явно определяйте политику null

    Для публичных API полезно выбрать одно из направлений:

  • запрещать null и валидировать на входе
  • разрешать null, но тогда документировать и обрабатывать во всех ветках
  • В большинстве доменных моделей проще и безопаснее запретить null.

    Если «значение может отсутствовать» как часть модели, рассмотрите Optional как возвращаемый тип (обычно для результатов, а не для полей).

    Ссылка:

  • Javadoc Optional
  • Возвращайте «безопасные» представления коллекций

    Если метод возвращает внутреннюю коллекцию, чаще всего нужно:

  • вернуть неизменяемый view (Collections.unmodifiableList)
  • либо вернуть копию
  • Ссылки:

  • Javadoc List.copyOf)
  • Javadoc Collections.unmodifiableList)
  • Делайте типы и методы как можно более узкими

    Чем меньше поверхность API, тем проще:

  • сопровождать код
  • обеспечивать обратную совместимость
  • поддерживать безопасность
  • Практика:

  • не делайте класс public, если он нужен только внутри модуля или пакета
  • не делайте метод public, если его можно оставить package-private
  • не экспортируйте детали реализации в сигнатурах
  • Финальность и расширяемость: осознанный выбор

    Два разных подхода к дизайну:

  • закрытый тип (final), рассчитанный на композицию и использование как value-объект
  • расширяемый тип, где вы документируете точки расширения и тестируете сценарии наследования
  • Если вы не планируете поддерживать наследование как часть контракта — чаще всего лучше сделать класс final.

    Как темы курса складываются вместе

  • Фабрики и DI помогают контролировать создание и подмену реализаций, а интерфейсы дают стабильный контракт.
  • Иммутабельность делает equals/hashCode надёжными и снижает количество ошибок при использовании объектов в коллекциях.
  • Композиция вместо наследования уменьшает связанность и позволяет менять реализацию без ломки клиентов.
  • Инкапсуляция защищает инварианты, снижает риск утечек данных через API и упрощает сопровождение.
  • В следующей логичной теме обычно переходят к деталям проектирования методов: какие параметры принимать, какие значения возвращать, как проектировать исключения и документировать поведение так, чтобы API было трудно использовать неправильно.

    4. Generics, enum и аннотации: типобезопасность и выразительность кода

    Generics, enum и аннотации: типобезопасность и выразительность кода

    В предыдущих статьях курса мы строили фундамент: правильное создание объектов, корректные контракты equals/hashCode/compareTo, а также дизайн классов и интерфейсов (инкапсуляция, композиция, иммутабельность).

    Следующий слой Effective Java — инструменты языка, которые делают код одновременно:

  • типобезопасным (ошибки ловятся компилятором, а не в продакшене)
  • выразительным (по сигнатурам понятно, что можно передавать и что вернётся)
  • расширяемым (легче менять реализации без массовых правок)
  • В Java за это особенно отвечают дженерики (generics), enum и аннотации.

    !Визуальная памятка по инвариантности дженериков и правилу PECS

    Generics: типы параметров вместо приведения и ClassCastException

    Generics — это возможность параметризовать типы: вместо "список чего угодно" написать "список строк" (List<String>). Главная цель — чтобы компилятор проверял корректность типов.

    Официальный туториал:

  • Java Tutorials: Generics
  • Почему raw types опасны

    Raw type — использование обобщённого типа без параметра, например List вместо List<String>.

    Проблема raw types:

  • компилятор перестаёт гарантировать типобезопасность
  • вы получаете предупреждения unchecked
  • ошибка откладывается до рантайма и проявляется как ClassCastException
  • Пример:

    Типобезопасный вариант:

    Связь с предыдущими темами курса:

  • вы проектируете API (классы/интерфейсы) так, чтобы сложно было использовать неправильно
  • List<String> — более сильный контракт, чем List
  • Ссылки:

  • Javadoc List
  • Инвариантность: почему List<Dog> не является List<Animal>

    В Java обобщённые типы инвариантны: даже если Dog extends Animal, это не делает List<Dog> подтипом List<Animal>.

    Почему так сделано: если бы присваивание было разрешено, то в List<Animal> можно было бы положить Cat, а на самом деле это был бы List<Dog>.

    Ограничения типов: extends

    Чтобы ограничить допустимые типы параметра, используют ограничение (bound):

    Здесь <T extends Number> означает: T может быть Integer, Long, BigDecimal и любым другим наследником Number.

    Wildcards и правило PECS

    Wildcard — это "неизвестный параметр типа": ?. Он нужен, чтобы сделать API гибче.

    Ключевое практическое правило из духа Effective Java:

  • PECS: Producer Extends, Consumer Super
  • То есть:

  • если коллекция производит значения для чтения, используйте ? extends T
  • если коллекция потребляет значения (вы в неё пишете), используйте ? super T
  • Пример: копирование из одного списка в другой:

    Почему так:

  • из List<? extends T> безопасно читать T (или его подтипы), но небезопасно писать
  • в List<? super T> безопасно писать T, но при чтении вы обычно получаете Object
  • Дженерики и стирание типов

    В Java generics реализованы через стирание типов (type erasure): в рантайме параметр типа обычно недоступен как конкретный String или Integer. Это влияет на ограничения:

  • нельзя сделать new T()
  • нельзя сделать new List<String>[10]
  • иногда появляются unchecked предупреждения при взаимодействии со старым кодом или рефлексией
  • Если вы вынуждены подавлять предупреждения, делайте это точечно и документируйте причину:

  • Javadoc SuppressWarnings
  • Varargs и generics: риск heap pollution

    T... (varargs) и дженерики вместе могут приводить к ситуации, когда реальный массив в рантайме содержит элементы "не того" типа. Это называют heap pollution.

    Если вы пишете метод, который безопасен, но компилятор предупреждает, используйте @SafeVarargs (только там, где это действительно корректно).

  • Javadoc SafeVarargs
  • Практика для API-дизайна:

  • предпочитайте List<T> вместо T[], особенно в публичных API
  • не игнорируйте unchecked предупреждения, они часто указывают на реальные риски
  • enum: больше, чем "набор констант"

    enum — это тип, множество фиксированных значений. В отличие от int-констант, enum:

  • типобезопасен
  • может содержать поля, методы и поведение
  • хорошо интегрирован с switch
  • безопаснее в рефакторинге
  • Ссылки:

  • JLS: Enum Classes
  • Javadoc Enum
  • Почему enum лучше, чем int-константы

    int-константы позволяют смешивать несвязанные значения:

    enum не позволит перепутать типы:

    Enum с данными и логикой

    enum отлично подходит для выражения доменных правил:

    Избегайте зависимости от ordinal()

    ordinal() — это порядковый номер константы в enum. Он меняется при перестановке/вставке констант и плохо подходит для хранения в базе или передачи наружу.

    Если нужен стабильный идентификатор:

  • храните явный код в поле (private final int code)
  • или используйте имя (name()), если оно является частью контракта
  • EnumSet и EnumMap

    Для множеств и отображений по ключам-энумам есть специализированные структуры:

  • EnumSet — эффективная реализация множества энумов (обычно как битовый набор)
  • EnumMap — эффективная Map с ключами-энумами
  • Ссылки:

  • Javadoc EnumSet
  • Javadoc EnumMap
  • Практическая связка с темой API-дизайна:

  • если ваш метод принимает "набор флагов" — лучше EnumSet<Flag> вместо int mask
  • если ваша конфигурация индексируется по перечислению — лучше EnumMap<Key, Value> вместо Map<Key, Value>
  • Связь с темой синглтонов

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

    Плюс: это один из немногих случаев, когда enum — не "набор значений", а гарантия единственного экземпляра.

    Аннотации: метаданные как часть контракта

    Аннотация — это метка, которую вы добавляете к коду (классам, методам, полям, параметрам). Она не меняет поведение сама по себе, но:

  • помогает компилятору проверять код
  • помогает инструментам (IDE, статические анализаторы)
  • может использоваться в рантайме через рефлексию
  • Ссылки:

  • Javadoc Annotation
  • Javadoc package java.lang.annotation
  • Аннотации, которые стоит использовать постоянно

    @Override:

  • защищает от ошибок при переопределении методов
  • если сигнатура не совпала, вы получите ошибку компиляции
  • Javadoc Override
  • @Deprecated:

  • сигнализирует пользователям API, что элемент устарел
  • почти всегда должен сопровождаться Javadoc с причиной и альтернативой
  • Javadoc Deprecated
  • @FunctionalInterface:

  • фиксирует намерение: интерфейс должен оставаться функциональным (один абстрактный метод)
  • Javadoc FunctionalInterface
  • @SuppressWarnings:

  • используйте точечно, на минимально возможной области
  • не подавляйте предупреждения "чтобы было чисто", сначала убедитесь, что это безопасно
  • Как создавать свои аннотации

    Минимальный пример: аннотация для пометки публичных API, где запрещён null:

    Что здесь происходит:

  • @Retention(RetentionPolicy.RUNTIME) означает, что аннотация будет доступна в рантайме через рефлексию
  • @Target(...) ограничивает места применения
  • @Documented говорит, что аннотация попадёт в Javadoc
  • Ссылки:

  • Javadoc Retention
  • Javadoc RetentionPolicy
  • Javadoc Target
  • Javadoc ElementType
  • Javadoc Documented
  • Рантайм и компайл-тайм обработка аннотаций

    Есть два основных способа использовать аннотации:

  • в рантайме через рефлексию (например, фреймворк читает аннотации и строит поведение)
  • на этапе компиляции через annotation processing (генерация кода, дополнительные проверки)
  • При проектировании библиотек это важно для API-дизайна:

  • рантайм-подход проще подключить, но он добавляет стоимость рефлексии и ошибки проявляются позже
  • компайл-тайм проверки ловят ошибки раньше и дают более жёсткий контракт
  • Аннотации как часть "контракта" метода

    Связь с темой инкапсуляции и контрактов из предыдущих статей:

  • @Override подтверждает контракт наследования
  • @Deprecated и документация фиксируют контракт обратной совместимости
  • собственные аннотации могут формализовать правила вашего API (например, "запрещён null", "метод потокобезопасен", "идемпотентен")
  • Важно: аннотации не должны заменять нормальные типы и дизайн. Например, если отсутствие значения — часть модели, часто лучше Optional как возвращаемый тип, чем "магическая" аннотация.

  • Javadoc Optional
  • Итоги

  • Generics делают API типобезопасным: меньше кастов, меньше ClassCastException, больше проверок компилятором.
  • Wildcards и PECS помогают проектировать гибкие и безопасные методы для коллекций.
  • enum — полноценный тип: безопаснее int-констант, умеет хранить данные и поведение; для наборов и мап по enum используйте EnumSet и EnumMap.
  • Аннотации добавляют метаданные и усиливают контракты: используйте @Override, аккуратно применяйте @SuppressWarnings, а свои аннотации делайте осмысленными (с корректным @Retention и @Target).
  • 5. Лямбды, Stream API и многопоточность: производительность и корректность

    Лямбды, Stream API и многопоточность: производительность и корректность

    В прошлых статьях курса мы строили основу качественного Java-кода:

  • контролировали создание объектов (фабрики, зависимости) и корректно закрывали ресурсы
  • соблюдали контракты equals/hashCode/compareTo и не утекали секретами через toString
  • проектировали классы и интерфейсы так, чтобы API было сложно использовать неправильно
  • делали код типобезопаснее через generics, enum и аннотации
  • Теперь логичный следующий шаг: как писать выразительный и при этом корректный код с лямбдами и Stream API, и как не потерять корректность и производительность, когда в игру входит многопоточность.

    Эта тема в духе Effective Java почти всегда сводится к двум вопросам:

  • Корректность: нет ли скрытых побочных эффектов, гонок данных и нарушений контракта?
  • Производительность: не создаём ли мы лишние объекты, боксинг, синхронизацию и ненужный параллелизм?
  • !Конвейер Stream API и где чаще всего появляются ошибки и лишние расходы

    Лямбды и функциональные интерфейсы

    Что такое лямбда и зачем она нужна

    Лямбда-выражение в Java — это краткая запись реализации функционального интерфейса (интерфейса с одним абстрактным методом). Это позволяет:

  • уменьшить шум кода по сравнению с анонимными классами
  • усилить выразительность операций над данными
  • передавать поведение как значение
  • Ключевые функциональные интерфейсы из JDK:

  • Function<T, R>
  • Predicate<T>
  • Supplier<T>
  • Consumer<T>
  • Ссылки:

  • Function
  • Predicate
  • Supplier
  • Consumer
  • Пример: фильтрация и преобразование без создания отдельного класса:

    Ссылки:

  • Stream
  • Stream.toList)
  • Лямбды и захват переменных

    Лямбда может использовать переменные из внешней области видимости, но только если они эффективно финальные.

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

    Плохо: попытка менять локальную переменную из лямбды не скомпилируется:

    Но опаснее другое: если лямбда замыкает ссылку на изменяемый объект, то гонки данных возможны.

    Ссылки на методы и читаемость

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

    Плюсы:

  • меньше визуального шума
  • проще рефакторить
  • легче видеть, что операция не содержит “скрытой” логики
  • Stream API: модель вычислений и базовые правила корректности

    Ленивость и “один проход”

    У стримов есть важные свойства:

  • промежуточные операции ленивые и не выполняются сразу
  • выполнение начинается только в терминальной операции
  • stream рассчитан на одноразовое использование
  • Плохо:

    Стримы лучше всего работают без побочных эффектов

    Главный практический принцип корректности:

  • функции внутри map/filter/flatMap должны быть без побочных эффектов
  • Проблемный пример:

    Почему это плохо:

  • нарушается идея декларативности: результат зависит от внешнего состояния
  • при переходе на parallelStream() сломается потокобезопасность
  • даже без параллелизма это хуже читается и тестируется
  • Правильнее вернуть результат через collect:

    reduce и collect: не путайте назначение

  • reduce — свёртка в одно значение
  • collect — сбор в контейнер (список, мапу, строку, статистику)
  • Ссылки:

  • Stream.reduce)
  • Stream.collect)
  • Collectors
  • Плохо: делать reduce ради построения списка или строки через конкатенацию:

    Лучше:

    Ссылка:

  • Collectors.joining)
  • Производительность Stream API: где чаще всего теряются ресурсы

    Боксинг и примитивные стримы

    Если вы обрабатываете числа, Stream<Integer> создаёт боксинг/анбоксинг, лишние объекты и нагрузку на GC.

    Используйте примитивные стримы:

  • IntStream
  • LongStream
  • DoubleStream
  • Ссылки:

  • IntStream
  • LongStream
  • Пример:

    Порядок операций

    Порядок операций влияет на производительность. Старайтесь:

  • фильтровать раньше, чтобы уменьшить поток данных
  • использовать короткозамыкающие операции, когда это уместно (findFirst, anyMatch, limit)
  • Ссылка:

  • Stream.anyMatch)
  • Пример:

    Когда цикл лучше стрима

    Стримы не всегда быстрее. Цикл может быть лучше, если:

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

    parallelStream: параллельность “по кнопке” и почему она опасна

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

    На чём работает parallelStream

    Параллельные стримы обычно используют общий ForkJoinPool.commonPool().

    Ссылка:

  • ForkJoinPool
  • Это важно, потому что:

  • вы делите пул с чужим кодом в процессе
  • блокирующие операции внутри параллельного стрима могут “задушить” пул
  • Когда parallelStream может помочь

    Параллельность может дать выигрыш, если одновременно выполняются условия:

  • большая коллекция
  • операции CPU-bound (нагружают процессор, а не ждут сеть/диск)
  • операции независимы и без побочных эффектов
  • данные хорошо распараллеливаются (хороший Spliterator у источника)
  • Ссылка:

  • Spliterator
  • Когда parallelStream почти наверняка навредит

    Типовые случаи:

  • операции делают I/O (запросы в сеть, БД, файловая система)
  • есть синхронизация, общий mutable state, блокировки
  • важен порядок (forEachOrdered) и вы заставляете систему постоянно “сшивать” порядок
  • источник данных плохо делится на части
  • Корректность: побочные эффекты и гонки данных

    Опасный пример:

    Правильнее:

    Или если нужна специальная структура:

    Ссылка:

  • ConcurrentLinkedQueue
  • Корректность reduce в параллели: ассоциативность и отсутствие побочных эффектов

    Если вы используете reduce в параллельном стриме, операция объединения должна быть:

  • без побочных эффектов
  • ассоциативной (чтобы разный порядок группировки не менял результат)
  • Пример корректного объединения чисел:

    Многопоточность: что важнее всего помнить

    Stream API не заменяет основы многопоточности. Даже в “красивом” функциональном стиле можно написать некорректный код.

    Общие причины ошибок

  • общий изменяемый объект без синхронизации
  • публикация объекта без гарантии видимости между потоками
  • использование “не тех” коллекций
  • Практическая связка с предыдущими статьями:

  • иммутабельность и инкапсуляция резко снижают риск гонок данных
  • корректный equals/hashCode критичен, если вы используете объекты как ключи в конкурентных структурах
  • Используйте высокоуровневые инструменты вместо “ручных” потоков

    В прикладном коде чаще всего лучше:

  • ExecutorService вместо прямого управления Thread
  • CompletableFuture для композиции асинхронных операций
  • конкурентные коллекции вместо Collections.synchronizedXxx
  • Ссылки:

  • ExecutorService
  • Executors
  • CompletableFuture
  • ConcurrentHashMap
  • Пример: выполнение задач и сбор результатов:

    Здесь важно:

  • пул обязательно завершать (shutdown) так же, как ресурсы закрываются через try-with-resources из первой статьи
  • Future.get() блокирует поток, поэтому для большого числа операций часто удобнее CompletableFuture
  • CompletableFuture: композиция вместо ручной синхронизации

    Плюсы:

  • легче комбинировать этапы
  • проще обрабатывать ошибки
  • меньше ручных блокировок
  • Состояние и атомарные типы

    Если вам нужен счётчик, не используйте общий int с ++ из разных потоков.

    Используйте:

  • AtomicLong, AtomicInteger
  • либо делайте агрегацию без shared state (например, через collect)
  • Ссылка:

  • AtomicLong
  • Пример:

    Практические правила: как выбрать подход

    Лямбды

  • используйте лямбды для кратких, понятных операций
  • если лямбда становится длинной, подумайте о выделении метода
  • где уместно, заменяйте лямбды ссылками на методы
  • Stream API

  • предпочитайте стримы, когда это повышает читаемость и уменьшает риск ошибок
  • избегайте побочных эффектов внутри конвейера
  • используйте примитивные стримы для чисел, чтобы не платить за боксинг
  • Параллельность

  • parallelStream() используйте осознанно и проверяйте профилировщиком
  • для I/O и внешних вызовов чаще подходят ExecutorService и CompletableFuture
  • защищайте инварианты и состояние: неизменяемость, инкапсуляция, конкурентные структуры
  • !Почему побочные эффекты в параллельных стримах приводят к гонкам данных

    Итоги

  • Лямбды и функциональные интерфейсы помогают писать короче и выразительнее, но не отменяют правил проектирования API и инкапсуляции.
  • Stream API даёт декларативный стиль обработки данных, но требует дисциплины: минимум побочных эффектов, корректный выбор collect и примитивных стримов.
  • parallelStream() не является универсальным ускорителем: он может ухудшить и производительность, и корректность.
  • Для многопоточности чаще выигрывают высокоуровневые инструменты: ExecutorService, CompletableFuture, конкурентные коллекции и атомики.
  • Дальше в курсе логично углубиться в практики надёжной обработки ошибок и исключений (в том числе в асинхронном коде), а также в принципы проектирования API, которые сложно использовать неправильно даже в многопоточной среде.