← назад к разделу

Жизненный цикл бина — то место, где встречается 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 — имя бина в контексте.
  • BeanFactoryAwareBeanFactory (родитель 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 и CloseabledestroyMethod подразумевается. Чтобы отключить — 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:

  1. Tomcat перестаёт принимать новые запросы.
  2. Ждёт завершения текущих (до timeout).
  3. Запускает @PreDestroy / DisposableBean.destroy() для всех бинов.
  4. Останавливает 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 и собственные события жизненного цикла.