Жизненный цикл бина — то место, где встречается 80% «магических» багов Spring: NullPointerException в конструкторе, «почему @Transactional не сработал», «почему мой @PostConstruct запустился раньше, чем БД готова». Эта статья — полный проход по фазам с рабочим демо-кодом.
Восемь фаз
1. Instantiation — вызов конструктора
2. Dependency injection — внедрение @Autowired-полей и сеттеров
3. *Aware-колбэки — BeanNameAware, ApplicationContextAware, EnvironmentAware
4. BeanPostProcessor.before — обёртки ДО initialization-методов
5. Initialization — @PostConstruct → afterPropertiesSet → init-method
6. BeanPostProcessor.after — обёртки ПОСЛЕ initialization (здесь создаётся AOP-прокси)
7. Bean ready — бин в контексте, используется
8. (при остановке) Destruction — @PreDestroy → destroy → destroy-method
Ключевая идея: между фазами 1 и 5 бин постепенно становится готовым. Конструктор видит только то, что передано через параметры. @Autowired-поля доступны после фазы 2. Полный сервис, готовый к работе — только после фазы 5.
Демо-код
Собираем один класс, который пишет в лог при каждой фазе своего жизненного цикла. Запустим и посмотрим вывод.
package com.example.lifecycle;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.BeanNameAware;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class DemoBean implements
BeanNameAware,
ApplicationContextAware,
InitializingBean,
DisposableBean {
private final DependencyA depA;
@Autowired private DependencyB depB;
private String beanName;
private ApplicationContext context;
// ─── 1. Instantiation ────────────────────────────────────────────────
public DemoBean(DependencyA depA) {
this.depA = depA;
log.info("1. Constructor: depA={}, depB={}", depA != null, depB != null);
}
// ─── 2. Dependency injection (поле depB) ─────────────────────────────
// Spring инжектит @Autowired-поля прямо здесь, между конструктором и
// следующими фазами. Видимого callback'а нет, но depB теперь не null.
// ─── 3. *Aware-колбэки ───────────────────────────────────────────────
@Override
public void setBeanName(String name) {
this.beanName = name;
log.info("3a. BeanNameAware.setBeanName: {}", name);
}
@Override
public void setApplicationContext(ApplicationContext ctx) {
this.context = ctx;
log.info("3b. ApplicationContextAware.setApplicationContext");
}
// ─── 5. Initialization ───────────────────────────────────────────────
@PostConstruct
public void postConstruct() {
log.info("5a. @PostConstruct: depB={} (now non-null), name={}", depB != null, beanName);
}
@Override
public void afterPropertiesSet() {
log.info("5b. InitializingBean.afterPropertiesSet");
}
public void customInit() {
log.info("5c. custom init-method (из @Bean)");
}
// ─── 8. Destruction ──────────────────────────────────────────────────
@PreDestroy
public void preDestroy() {
log.info("8a. @PreDestroy");
}
@Override
public void destroy() {
log.info("8b. DisposableBean.destroy");
}
public void customDestroy() {
log.info("8c. custom destroy-method");
}
}
@Component
class DependencyA { }
@Component
class DependencyB { }
Конфигурация с custom init/destroy:
@Configuration
public class LifecycleConfig {
@Bean(initMethod = "customInit", destroyMethod = "customDestroy")
public DemoBean demoBeanViaConfig(DependencyA depA) {
return new DemoBean(depA);
}
}
И запускаем:
@SpringBootApplication
public class Main {
public static void main(String[] args) {
var ctx = SpringApplication.run(Main.class, args);
// ... приложение работает
ctx.close();
}
}
Вывод в консоль
1. Constructor: depA=true, depB=false
3a. BeanNameAware.setBeanName: demoBean
3b. ApplicationContextAware.setApplicationContext
5a. @PostConstruct: depB=true (now non-null), name=demoBean
5b. InitializingBean.afterPropertiesSet
5c. custom init-method (из @Bean)
... приложение работает ...
8a. @PreDestroy
8b. DisposableBean.destroy
8c. custom destroy-method
Из этого вывода видно:
- В конструкторе доступны только параметры.
@Autowired private DependencyB depBещёnull. - После Aware-колбэков и инициализации — все зависимости внедрены, бин готов.
- При остановке — обратный порядок: сначала
@PreDestroy, потомDisposableBean, потом custom destroy.
Фаза 1: Instantiation
Spring вызывает конструктор. Доступно: только то, что передаётся через параметры конструктора. Недоступно: @Autowired-поля, сеттер-инжектированные поля, ApplicationContext (если не передан в конструктор).
Когда использовать
- Внедрять зависимости через конструктор (
finalполя). - Делать компактную инициализацию: присвоения, validation параметров.
Чего НЕ делать
- Не вызывать методы зависимостей в конструкторе, если они тоже Spring-бины — порядок инициализации непредсказуем.
- Не делать тяжёлой работы (запросы в БД, HTTP). Это блокирует старт контекста.
@Service
public class OrderService {
private final OrderRepository repo;
private final PriceCalculator calc;
// ОК
public OrderService(OrderRepository repo, PriceCalculator calc) {
this.repo = repo;
this.calc = calc;
// Не вызывать repo.findAll() или calc.warmupCache() здесь
}
}
Фаза 2: Dependency injection
Spring внедряет:
@Autowiredна полях (не рекомендуется, но работает).@Autowiredна сеттерах (редко используется).@Inject/@Resource(Jakarta-эквиваленты).
Этой фазы нет видимого callback'а — поля просто становятся ненулевыми.
Что важно знать
Если у вас в классе:
private final OrderRepository repo; // через конструктор
@Autowired private MetricsClient metrics; // через поле
…то repo доступен в конструкторе, а metrics — только после фазы 2. В конструкторе нельзя обращаться к metrics.count(...) — будет NPE.
Правило: всегда внедрять через конструктор. Это убирает разницу между фазами 1 и 2 для вашего кода.
Фаза 3: Aware-интерфейсы
Spring дёргает callback'и, если бин реализует Aware-интерфейс:
BeanNameAware— имя бина в контексте.BeanFactoryAware—BeanFactory(родительApplicationContext).ApplicationContextAware— весьApplicationContext.EnvironmentAware— доступ к свойствам черезEnvironment.ResourceLoaderAware— загрузка ресурсов.MessageSourceAware— i18n.
В современном Spring используются редко — обычно достаточно конструктор-injection. Иногда нужны для динамической работы с контекстом (получение бина по имени в runtime).
@Component
public class DynamicHandlerRegistry implements ApplicationContextAware {
private ApplicationContext context;
@Override
public void setApplicationContext(ApplicationContext ctx) {
this.context = ctx;
}
public Handler getHandler(String type) {
return context.getBean(type + "Handler", Handler.class);
}
}
Фаза 4: BeanPostProcessor.postProcessBeforeInitialization
Глобальная точка расширения. Spring вызывает у каждого зарегистрированного BeanPostProcessor метод postProcessBeforeInitialization(bean, name) для каждого бина перед initialization-фазой.
Используется Spring внутри для обработки аннотаций (например, @PostConstruct — это CommonAnnotationBeanPostProcessor под капотом). Своими руками — редко.
Фаза 5: Initialization
Три способа объявить «полностью инициализировать бин», вызываются в этом порядке:
5a. @PostConstruct
@PostConstruct
public void init() {
// здесь все зависимости внедрены
log.info("Service ready");
}
Идиоматичный современный способ. Аннотация из jakarta.annotation (раньше из javax.annotation).
5b. InitializingBean.afterPropertiesSet()
@Component
public class MyService implements InitializingBean {
@Override
public void afterPropertiesSet() {
// эквивалент @PostConstruct
}
}
Спецификация Spring. Привязывает класс к фреймворку — не рекомендуется в новом коде.
5c. Custom initMethod
@Bean(initMethod = "customInit")
public MyService myService() { return new MyService(); }
public class MyService {
public void customInit() {
// эквивалент @PostConstruct, но через @Bean-конфигурацию
}
}
Полезно, когда класс — не ваш и его нельзя пометить аннотацией.
Когда какой использовать
- В вашем коде —
@PostConstructна методе. Чисто, не привязывает к Spring. - Сторонние классы —
initMethodв@Bean. InitializingBean— не в новом коде.
Что делать в init-методе
- Warmup кэша: преgrenерировать данные, прогреть кешируемые запросы.
- Регистрация в реестрах: добавить себя в
HandlerRegistry, подписаться на события. - Проверка инвариантов: «если эта конфигурация не выставлена, упасть с понятной ошибкой».
@Component
@RequiredArgsConstructor
public class CategoryCache {
private final CategoryRepository repo;
private final ConcurrentHashMap<UUID, Category> cache = new ConcurrentHashMap<>();
@PostConstruct
public void warmup() {
repo.findAll().forEach(c -> cache.put(c.id(), c));
log.info("Loaded {} categories into cache", cache.size());
}
}
Что НЕ делать в init-методе
- Тяжёлая I/O работа — старт сервиса замедляется. Если на это уйдут минуты, k8s посчитает pod зависшим и перезапустит. Лучше выделить отдельный сервис для warmup, или использовать
ApplicationReadyEventдля асинхронной работы:
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
// выполняется ПОСЛЕ полного старта контекста, не блокирует его
CompletableFuture.runAsync(this::heavyWarmup);
}
Фаза 6: BeanPostProcessor.postProcessAfterInitialization
Зеркало фазы 4, но после initialization. Здесь Spring создаёт AOP-прокси.
Это значит: ваш @PostConstruct выполняется на оригинальном объекте, не на прокси. Если внутри @PostConstruct вы вызываете свой же @Transactional-метод — транзакция не открывается, потому что прокси ещё не создан.
@Component
@Slf4j
public class OrderInitializer {
@PostConstruct
public void init() {
log.info("this class: {}", this.getClass().getName());
// → "com.example.OrderInitializer" (НЕ прокси!)
loadInitialData(); // self-invocation, через this, прокси не задействован
}
@Transactional
public void loadInitialData() {
// транзакция НЕ откроется
}
}
Решение — ApplicationReadyEvent:
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
log.info("this class: {}", this.getClass().getName());
// → "com.example.OrderInitializer$$SpringCGLIB$$0" (теперь прокси)
loadInitialData(); // теперь через прокси, @Transactional работает
}
Фаза 7: Bean ready
Бин в контексте, готов к использованию. Между фазой 6 (для всех бинов) и реальным ApplicationReadyEvent срабатывает ContextRefreshedEvent.
Спектр событий жизненного цикла приложения:
ApplicationStartingEvent — самое начало, до загрузки конфигурации
ApplicationEnvironmentPreparedEvent — Environment готов
ApplicationContextInitializedEvent
ApplicationPreparedEvent — контекст создан, но не refreshed
ContextRefreshedEvent — все бины инициализированы
ApplicationStartedEvent — приложение запущено
ApplicationReadyEvent — готово принимать трафик
ApplicationReadyEvent — самое позднее место «всё уже точно готово». Если делаете асинхронный warmup, инициализацию integrations — здесь.
Фаза 8: Destruction
При остановке контекста (ctx.close() или JVM shutdown через SIGTERM) Spring останавливает singleton-бины в обратном порядке.
8a. @PreDestroy
@PreDestroy
public void shutdown() {
threadPool.shutdown();
threadPool.awaitTermination(30, TimeUnit.SECONDS);
}
Идиоматичный современный способ.
8b. DisposableBean.destroy()
@Component
public class MyService implements DisposableBean {
@Override
public void destroy() { ... }
}
Старый способ, не рекомендуется.
8c. Custom destroyMethod
@Bean(destroyMethod = "close")
public AutoCloseable myResource() { ... }
С Spring это работает «по умолчанию» для всех AutoCloseable и Closeable — destroyMethod подразумевается. Чтобы отключить — destroyMethod = "".
Прототипы НЕ уничтожаются
Spring управляет жизненным циклом только singleton-бинов. У prototype-бинов @PreDestroy не вызывается — Spring потеряет ссылку сразу после getBean(), дальше — JVM GC.
Если prototype-бин держит ресурсы — отвечает за их освобождение вызывающий код.
Graceful shutdown
В production важен graceful shutdown: дождаться завершения текущих запросов перед остановкой.
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
После SIGTERM:
- Tomcat перестаёт принимать новые запросы.
- Ждёт завершения текущих (до timeout).
- Запускает
@PreDestroy/DisposableBean.destroy()для всех бинов. - Останавливает JVM.
Без graceful Spring останавливает в момент SIGTERM, обрывая текущие запросы.
Graceful Shutdown Style Guide — детально.
Ловушки, которые ловятся знанием жизненного цикла
1. NPE в конструкторе
public MyService(OrderRepository repo) {
this.repo = repo;
metrics.count("started"); // ← metrics ещё null, NPE
}
@Autowired private MetricsClient metrics;
Решение: всё через конструктор.
2. @Transactional в @PostConstruct
Прокси ещё не создан. Решение — ApplicationReadyEvent или внешний бин.
3. Self-invocation @PostConstruct → метод этого же класса
this.method() → прямой вызов, обходит прокси. Те же ловушки, что у @Transactional/@Async.
4. Тяжёлая работа в init блокирует старт
K8s liveness probe сработает раньше, чем @PostConstruct завершится → pod перезапуск в цикле. Решение — ApplicationReadyEvent + CompletableFuture.runAsync.
5. @PreDestroy не вызывается при kill -9
Только SIGTERM и graceful shutdown дают Spring время на cleanup. SIGKILL обрывает JVM мгновенно.
Что почитать дальше
- DI/IoC и scopes — общий контекст про создание и связывание бинов.
- Spring AOP — почему
BeanPostProcessor.postProcessAfterInitializationсоздаёт прокси и что это значит. @Transactionalглубоко — частный случай AOP и связанные ловушки.- Spring Events —
ApplicationReadyEvent,ContextRefreshedEventи собственные события жизненного цикла.