сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Spring и Hibernate

Бины Spring: цикл и скоупы

Жизнь бина: девять шагов и место, где рождается магия

Повесил на бин отметки во всех точках и поднял контекст. Порядок вышел такой: конструктор (зависимость ещё null), внедрение зависимости, обработчик ДО инициализации, @PostConstruct, afterPropertiesSet, обработчик ПОСЛЕ инициализации, и только потом бин готов. При закрытии контекста - @PreDestroy и destroy.

Шестой шаг тут ключевой: именно там бин подменяют на прокси. Понимать этот конвейер нужно, чтобы объяснять, почему @Transactional иногда молча не срабатывает.

// Формулировки: «опиши жизненный цикл бина», «чем singleton отличается от prototype?», «почему @Transactional не работает при вызове через this?».

Конвейер создания бина

Вот замер целиком, по шагам: 1 - конструктор, поле зависимости ещё null; 2 - внедрение зависимости; 3 - BeanPostProcessor до инициализации; 4 - @PostConstruct, зависимость уже на месте; 5 - afterPropertiesSet; 6 - BeanPostProcessor после инициализации; 7 - контекст поднят. При закрытии: 8 - @PreDestroy, 9 - destroy.

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

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

@PostConstruct
инициализация после внедрения зависимостей, а не в конструкторе
прокси
обёртка вокруг бина, которая вклинивается перед вызовом метода

Скоупы: сколько экземпляров живёт

Скоуп - это ответ на вопрос «сколько экземпляров бина существует». По умолчанию singleton: один на весь контейнер, общий для всех запросов и потоков. Я проверил - два обращения к контейнеру вернули один и тот же объект.

Отсюда жёсткое требование: singleton не должен хранить изменяемое состояние. Поле-накопитель в таком сервисе под нагрузкой перемешает данные разных пользователей, потому что объект у них общий. Есть ещё prototype - новый экземпляр на каждое обращение, и веб-скоупы request и session, живущие на один HTTP-запрос и на сессию.

И вот ловушка, которую я замерил. Внедрил prototype-бин полем в singleton и обратился к нему трижды: каждый раз приходил один и тот же экземпляр номер 1. Ничего удивительного - поле заполнили однажды, при создании владельца. Рядом взял тот же бин через ObjectProvider - объект, у которого можно попросить экземпляр в момент вызова: пришли номера 2, 3 и 4. Всего создалось четыре экземпляра.

class Holder {
    @Autowired Proto injectedField;            // prototype полем
    @Autowired ObjectProvider<Proto> provider; // он же через провайдера
}

// обращение 1: поле -> Proto#1,  провайдер -> Proto#2
// обращение 2: поле -> Proto#1,  провайдер -> Proto#3
// обращение 3: поле -> Proto#1,  провайдер -> Proto#4
singleton / prototype
один экземпляр на контейнер / новый на каждое обращение
ObjectProvider
попросить свежий экземпляр в момент вызова, а не при создании владельца

Два вида прокси и обход через this

Посмотрел, во что превратились бины. Класс без интерфейса стал Orders$$SpringCGLIB$$0 - это сгенерированный ПОДКЛАСС, и проверка instanceof на исходный класс проходит. Класс с интерфейсом стал $Proxy18 - отдельный объект, реализующий тот же интерфейс, и экземпляром исходного класса он уже не является.

Отсюда ограничения. Подкласс нельзя построить для final-класса и нельзя переопределить final-метод, значит перехватить их не выйдет. А прокси по интерфейсу не даст запросить бин по конкретному классу.

Главное следствие - self-invocation, вызов собственного метода через this. Внешний вызов идёт через прокси: он открывает транзакцию и передаёт управление твоему методу. А this.save() внутри того же класса обращается к объекту напрямую, минуя обёртку. Я это замерил: при внешнем вызове в логе появились открытие и коммит, при вызове через this - НИЧЕГО. Ни ошибки, ни транзакции.

@Service
class Orders {
    @Transactional void save(Order o) { ... }

    void batch(List<Order> os) {
        for (Order o : os) this.save(o);   // мимо прокси
    }
}

// вызов снаружи:  BEGIN ... COMMIT
// вызов через this: в логе пусто
self-invocation
вызов своего метода через this идёт мимо прокси, аннотация не работает
CGLIB / JDK-прокси
подкласс, когда интерфейса нет / отдельный объект по интерфейсу

Как отвечать: «Почему @Transactional не работает при вызове this.method()?»

Потому что аннотация живёт не в самом объекте, а в прокси - обёртке, которой Spring подменяет бин на шестом шаге создания, в обработчике после инициализации. Когда метод зовут снаружи, через ссылку на бин, вызов проходит через обёртку: она открывает транзакцию и передаёт управление настоящему методу. Когда я внутри класса пишу this.save, я обращаюсь к объекту напрямую, обёртка не задействована - и транзакции нет. Причём молча: я проверял на живом контексте, при внешнем вызове в логе есть открытие и коммит, при вызове через this в логе пусто, никакой ошибки. То же самое касается @Async и @Cacheable - механизм один. Лечу выносом метода в отдельный бин, который вызываю снаружи, либо перестраиваю так, чтобы транзакционным был публичный метод-вход.

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

На чём валят

  • Изменяемое поле-накопитель в singleton-сервисе. Объект общий для всех, поэтому под нагрузкой данные пользователей перемешаются.
  • Инициализация в конструкторе вместо @PostConstruct. На первом шаге зависимости ещё null - я это замерил.
  • Prototype-бин полем в singleton. Экземпляр создаётся один раз вместе с владельцем; свежий даёт ObjectProvider.
  • @Transactional или @Async на вызове через this. Прокси обойдён, поведение пропало без единого сообщения.
  • final-класс или final-метод там, где нужен прокси-подкласс. Переопределить нечего, перехват не построится.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 12, остальные разбираются в тренажёре.

  1. #bean_lifecycle_scopes1 / 5
    Потокобезопасен ли singleton-бин Spring по умолчанию?
    A)Да, Spring автоматически синхронизирует все методы singleton-бина, поэтому гонок в нём встречается редко
    B)Нет — один экземпляр делят все потоки; за потокобезопасность отвечает разработчик
    C)Да, потому что каждый поток при обращении получает свою отдельную копию singleton-бина из контейнера
    D)Вопрос неприменим: singleton-бины в Spring не вызываются из нескольких потоков сразу
    показать ответ и разбор
    +B)Нет — один экземпляр делят все потоки; за потокобезопасность отвечает разработчик

    // разбор: Spring-скоуп singleton != потокобезопасность. Это ОДИН экземпляр, разделяемый ВСЕМИ потоками (веб-запросы обслуживаются параллельно). Spring ничего не синхронизирует автоматически. Поэтому если в singleton-бине хранить изменяемое состояние (поля, меняющиеся между запросами), возникнут гонки. Правильный дизайн — делать сервисы БЕЗ СОСТОЯНИЯ (stateless): всё нужное передавать в параметрах/локальных переменных. Изменяемое состояние на запрос — в request-scope бинах или локально, а не в полях синглтона.

  2. #bean_lifecycle_scopes2 / 5
    Когда используют scope prototype?
    A)Когда бин должен существовать в одном экземпляре и переиспользоваться во всём приложении
    B)Когда бин обслуживает ровно один HTTP-запрос и уничтожается по его завершении контейнером
    C)Когда нужен новый экземпляр бина на каждое получение/внедрение
    D)Когда бин обязан создаваться заранее при старте контейнера, ещё до первого обращения к нему
    показать ответ и разбор
    +C)Когда нужен новый экземпляр бина на каждое получение/внедрение

    // разбор: prototype-бин контейнер создаёт ЗАНОВО при каждом получении (getBean/внедрении). Подходит для объектов с состоянием, которые нельзя разделять, или для короткоживущих сущностей. Нюанс: для prototype контейнер НЕ управляет полным жизненным циклом — не вызывает destroy-колбэки (за очистку отвечает получатель). И осторожно с внедрением prototype в singleton: по умолчанию внедрится ОДИН экземпляр на всё время жизни синглтона (нужен ObjectProvider/@Lookup/scoped proxy, чтобы получать свежий).

  3. #bean_lifecycle_scopes3 / 5
    Что делают @PostConstruct и @PreDestroy?
    A)Создают и удаляют бин: @PostConstruct вызывает конструктор, а @PreDestroy освобождает память объекта
    B)Помечают методы, которые контейнер выполняет на КАЖДЫЙ вызов бина — до и после основной логики метода
    C)Задают порядок создания бинов: @PostConstruct-бины создаются последними, @PreDestroy — первыми
    D)Колбэки жизненного цикла: после внедрения зависимостей и перед уничтожением бина
    показать ответ и разбор
    +D)Колбэки жизненного цикла: после внедрения зависимостей и перед уничтожением бина

    // разбор: @PostConstruct — метод, который контейнер вызывает ОДИН раз ПОСЛЕ создания бина и внедрения всех зависимостей (конструктор уже отработал, но там зависимости ещё не гарантированы) — для инициализации. @PreDestroy — метод, вызываемый перед уничтожением бина при закрытии контекста — для освобождения ресурсов (закрыть пул, остановить поток). Это часть lifecycle-колбэков (наряду с InitializingBean/DisposableBean и init/destroy-методами @Bean). Для prototype @PreDestroy НЕ вызывается.

  4. #bean_lifecycle_scopes4 / 5
    Чем @Bean-метод в @Configuration отличается от @Component?
    A)@Bean регистрирует объект программно в конфиг-классе (для чужих/сложных классов); @Component — сканированием
    B)@Bean и @Component взаимоисключающи: класс с @Bean-методом не получится одновременно помечать @Configuration
    C)@Component создаёт prototype-бины, а @Bean — только singleton, в этом их основное различие
    D)@Bean работает только для интерфейсов, а @Component — только для конкретных реализующих их классов
    показать ответ и разбор
    +A)@Bean регистрирует объект программно в конфиг-классе (для чужих/сложных классов); @Component — сканированием

    // разбор: @Component вешают на СВОЙ класс, и component scan сам регистрирует его как бин. @Bean — метод в @Configuration-классе, который ЯВНО создаёт и возвращает объект-бин; так регистрируют то, что нельзя пометить аннотацией: классы из СТОРОННИХ библиотек, объекты со сложной сборкой/настройкой, несколько бинов одного типа. Важно: @Configuration проксируется (CGLIB), поэтому повторный вызов @Bean-метода изнутри вернёт ТОТ ЖЕ синглтон, а не новый объект. Оба подхода дают бины контейнера.

  5. #bean_lifecycle_scopes5 / 5
    Когда бин со scope singleton создаётся в ApplicationContext по умолчанию?
    A)Лениво, только при первом обращении к нему из кода, чтобы ускорить старт приложения по умолчанию
    B)Заранее (eager), при инициализации контекста на старте приложения
    C)При каждом обращении заново, но результат кэшируется в контейнере до перезапуска приложения
    D)Автоматически не создаётся: singleton-бины нужно создавать вручную вызовом context.createBean()
    показать ответ и разбор
    +B)Заранее (eager), при инициализации контекста на старте приложения

    // разбор: ApplicationContext по умолчанию создаёт все singleton-бины ЗАРАНЕЕ (eager), при старте контекста. Плюс: ошибки конфигурации (нет зависимости, циклы, падение @PostConstruct) обнаруживаются НЕМЕДЛЕННО, а не при первом запросе в проде. Ленивую инициализацию включают точечно @Lazy (или глобально spring.main.lazy-initialization) — она ускоряет старт, но откладывает проявление ошибок. prototype же всегда создаётся по требованию. По умолчанию «fail fast» на старте предпочитают отложенным сюрпризам.

дальше

Теорию прочитали. Навык ставится повторением

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