1. Mental model lifecycle bean-а
Пока Spring был для нас в первую очередь инструментом “собери зависимости и дай готовый сервис”, всё выглядело довольно линейно: контейнер стартанул, мы достали ScenarioRunner, запустили сценарий — счастье. Но как только граф зависимостей уже собран, почти сразу вылезает второй слой вопросов: когда объект считается готовым, где выполнять подготовку, и что делать при завершении приложения. Именно это и называется lifecycle.
У жизненного цикла есть очень практическая цель: сделать так, чтобы ваши beans всегда находились в корректном состоянии, когда кто-то начинает ими пользоваться, и чтобы ресурсы закрывались аккуратно, а не “как получится”. Это особенно важно, когда у объекта есть подготовка: кэш, индекс, предварительная загрузка данных, проверка конфигурации, создание директорий, открытие файловых ресурсов. Вы можете вообще не любить слово “lifecycle” (оно звучит как что-то из корпоративной презентации), но вы точно любите идею “не ловить баги из-за того, что объект был использован до того, как он подготовился”.
И ещё один мотив: понимание жизненного цикла делает Spring менее мистическим. Когда вы точно знаете, в какой момент создаются объекты, в какой момент вызываются ваши “служебные” методы, и почему при закрытии контекста что-то происходит — исчезает ощущение, что фреймворк живёт отдельной жизнью и иногда просто “делает вещи”.
2. Контейнер: знает, создаёт, завершает
Есть простая мысль, которая экономит новичку много нервов: контейнер умеет знать о бине, создавать бин и завершать бин — и это не одно и то же. Мы уже встречались с этим, когда обсуждали BeanDefinition: Spring может “видеть” ваш bean (как описание), ещё не имея в руках реальный объект. Это как разница между “в меню ресторана есть пицца” и “на вашем столе стоит горячая пицца”. Меню — это метаданные, пицца — это instance. И да, иногда пицца появится только когда вы её закажете — но сам принцип разделения “описание vs instance” от этого не меняется.
Чтобы закрепить идею, возьмём микропримитивный пример, максимально близкий к нашему ContextFlow. Пусть у нас есть ScenarioRunner — класс, который мы потом будем доставать из контекста и запускать сценарии.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
// Конфигурация контекста: тут Spring читает метаданные и регистрирует BeanDefinition'ы
@Configuration
class ContextFlowConfig {
// Фабричный метод бина: сам факт @Bean означает регистрацию определения (но не обязательно немедленное создание)
@Bean
ScenarioRunner scenarioRunner() {
return new ScenarioRunner();
}
}
class ScenarioRunner {
ScenarioRunner() {
// Конструктор вызывается на фазе instantiation (это ещё не “ready state”)
System.out.println("ScenarioRunner: constructor");
}
}
Что здесь важно: наличие @Bean-метода означает, что контейнер зарегистрирует определение (BeanDefinition) для ScenarioRunner. Но это ещё не означает, что “жизнь объекта” уже началась. Жизнь начнётся, когда контейнер решит этот bean создать. В большинстве случаев это произойдёт при старте контекста, но самое главное сейчас — мы разделяем в голове эти две стадии.
И вот вторая часть картины: bean живёт не сам по себе, а “внутри контекста”. Если контекст закрылся — для управляемых объектов наступает финальная сцена, где контейнер даёт им шанс аккуратно завершиться.
3. Цепочка фаз: от registration до destroy
Если вы когда-нибудь видели, как люди учат lifecycle “двумя аннотациями” (@PostConstruct и @PreDestroy), то у вас могла остаться странная мысль: “Окей, а всё остальное где?”. Всё остальное — это и есть основная схема. Нам нужна опорная цепочка, на которую потом нормально “навешиваются” разные варианты init/destroy-колбэков.
Давайте зафиксируем базовую последовательность, без редких экзотик и без внутренних дебрей Spring (их мы ещё успеем не любить позже). В упрощённом виде жизненный цикл выглядит так:
flowchart TD
A["BeanDefinition зарегистрирован (контейнер «знает», что бин существует)"]
B["Instantiation (создание экземпляра)"]
C["Dependency injection (сборка зависимостей)"]
D["Initialization phase (инициализация после инъекций)"]
E["Ready state (готов к работе)"]
F["ApplicationContext close (контекст завершается)"]
G["Destruction phase (destroy callbacks)"]
A --> B --> C --> D --> E --> F --> G
Для обычного container-managed bean-а этой карты достаточно: отдельные нюансы не меняют главный скелет “контейнер знает → собирает → готовит → завершает”.
Чтобы это не выглядело как абстрактная стрелочная магия, сведём то же самое в таблицу (в “человеческих” словах):
| Фаза | Что делает контейнер | Что это значит для вас в коде |
|---|---|---|
| Registration | Читает @Configuration, сканирует @Component, регистрирует определения | Контейнер уже знает, что “такой бин должен быть”, но объект ещё не обязательно создан |
| Instantiation | Создаёт экземпляр: вызывает конструктор / factory method | Объект появляется как instance; если зависимости приходят через конструктор, контейнер учитывает это уже на этом шаге |
| Dependency injection | Доводит bean до собранного состояния | После появления instance контейнер внедряет остальные зависимости там, где это нужно, и собирает объект до рабочего состояния |
| Initialization | Даёт бину выполнить “подготовку” | Здесь место для проверок конфигурации, загрузки кэша, индекса, каталогов и т.п. |
| Ready state | Считает бин готовым и отдаёт его другим | Другие beans и сценарии могут безопасно использовать этот объект |
| Destruction | При закрытии контекста вызывает destroy-логику | Можно закрыть ресурсы, дописать буферы, аккуратно завершить работу |
Обратите внимание на два тонких момента.
Первый: конструктор не равен готовности. Даже если зависимости пришли через конструктор, объекту всё равно может понадобиться отдельный шаг подготовки. Причина простая: часть вещей должна происходить уже после того, как контейнер собрал вокруг бина всё нужное. Именно поэтому init-фаза вообще существует как отдельный шаг, а не как “дублирование конструктора”.
Второй: lifecycle — это не “анимация для красоты”, а контракт. Если ваш бин требует подготовки, он должен быть подготовлен до того, как его начнут использовать. Если бин держит ресурсы, он должен иметь шанс эти ресурсы корректно отпустить, когда приложение завершается.
4. Lifecycle и ApplicationContext
Очень легко думать так: “ну у меня же есть main, там всё начинается, там всё и заканчивается”. В plain Java это часто работает. В Spring-приложении реальная жизнь начинается чуть раньше — с момента, когда вы создаёте ApplicationContext, и заканчивается не “когда JVM устала”, а когда вы закрыли контекст. Контекст в этом смысле — как администратор сцены: он организует выход актёров (создание beans), а потом организует их корректный уход со сцены (destroy phase).
В нашем non-web приложении самый честный и понятный способ управлять этим — try-with-resources. Он хорош тем, что в одном месте видно: контекст стартовал, внутри блока мы делаем работу, контекст закрывается гарантированно.
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowApp {
public static void main(String[] args) {
// Контекст — ресурс: try-with-resources гарантирует закрытие и запуск destroy-фазы у managed-beans
try (var ctx = new AnnotationConfigApplicationContext(ContextFlowConfig.class)) {
System.out.println("Context started");
// Запрашиваем бин: если он eager — уже создан, если lazy — будет создан на этом месте
ctx.getBean(ScenarioRunner.class);
}
System.out.println("Context closed");
}
}
Почему это важно прямо сейчас, даже до конкретных init/destroy-механизмов? Потому что destroy-фаза вообще существует только если контекст закрыт. Если вы не закрываете контекст, вы как бы говорите: “Spring, всё, что ты хотел аккуратно завершить — забудь, я просто выключу свет и уйду”. Иногда это “прокатит” на маленьких примерах. А потом вы добавите что-то, что держит ресурсы (хотя бы файловый writer) — и начнутся странные эффекты. В учебном проекте лучше сразу формировать правильную привычку: ApplicationContext — это ресурс, его надо закрывать.
Да, в Spring есть registerShutdownHook() (контекст зарегистрирует hook на завершение JVM). Это бывает полезно, но как teaching default для console-приложения всё равно лучше try-with-resources: он делает завершение явным, а не “надеемся, что JVM корректно завершится”.
Отсюда вырастают четыре практических вопроса. Где заканчивается роль конструктора и начинается реальная готовность объекта? Как контейнер вызывает init-логику? Что именно даёт bean-у шанс аккуратно завершиться? И как меняется вся картина, если bean создаётся не сразу, а по требованию?
5. Ready state: после конструктора
Самая частая путаница здесь такая: кажется, будто конструктор автоматически делает bean полностью готовым. Но существование instance и ready state — разные вещи. Объект может получить зависимости и всё равно ещё нуждаться в короткой контейнерной подготовке: проверить конфигурацию, прогреть кэш, создать output-dir, собрать внутренний каталог. Поэтому lifecycle и держит отдельную init-фазу: она снимает с остальных сервисов обязанность гадать, готов ли объект к работе.
Destroy-фаза
С destroy-фазой симметричная история. Если bean держит writer, буфер, каталог, временные данные или любой другой ресурс, контейнеру нужен момент, когда он может сказать: “закругляемся, освободи всё аккуратно”. Этот момент привязан не к абстрактному “конец main()”, а к закрытию ApplicationContext. Пока контекст жив, bean считается рабочим; когда контекст закрывают, контейнер запускает его финальную часть lifecycle.
Lifecycle-managed beans in ContextFlow
На практике это проще всего увидеть на двух обычных объектах. NotificationTemplateCatalog должен один раз подготовить шаблоны и потом быстро отдавать их сервисам, а ReportOutputManager — подготовить output и корректно закрыться при shutdown. В обоих случаях хочется одного и того же: NotificationDispatchService и ReportingService не должны помнить про loadDefaults(), prepare() или close(). Они должны получать уже готовые зависимости, а остальное — забота контейнера. На таких beans особенно хорошо видно, зачем lifecycle вообще нужен.
6. Типичные ошибки при работе с жизненным циклом
Ошибка №1: считать, что lifecycle bean-а — это только конструктор.
Тогда в конструктор быстро начинают запихивать тяжёлую подготовку, проверки и работу с ресурсами. Полезнее держать в голове отдельную init-фазу: объект может существовать и ещё не быть готовым к нормальной работе.
Ошибка №2: путать “контейнер знает о бине” и “бин уже создан”.
BeanDefinition и реальный instance — не одно и то же. Пока эта разница не схлопнулась в голове, startup-ошибки и поведение lazy/eager будут казаться случайной магией.
Ошибка №3: не закрывать ApplicationContext и удивляться, что “ничего не завершилось”.
Destroy-фаза не обязана происходить сама собой. Если контекст не закрыт, контейнер не получает шанса корректно завершить managed-beans.
Ошибка №4: размазывать prepare() и close() по бизнес-сервисам.
Как только сервис начинает помнить про чужой “ритуал готовности”, DI перестаёт быть честной моделью зависимостей. Подготовка и завершение должны оставаться обязанностью контейнера или одного явного места сборки.
Ошибка №5: ожидать lifecycle-поведения от объектов, созданных через new вне контейнера.
Spring управляет только теми объектами, которые он сам создаёт как beans. Если объект создан вручную, никакой container-managed init/destroy к нему не применяется — и это нормально.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ