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

Бин в Spring не появляется готовым в одну секунду. Сначала Spring вызывает конструктор, потом внедряет зависимости, потом запускает методы инициализации — и только после этого бин готов к работе. А при остановке приложения всё проходит в обратном порядке. Понимание этих фаз снимает целый класс «магических» багов: почему в конструкторе всё null, почему @PostConstruct запускается раньше времени, почему ресурсы не освобождаются при остановке. Разберём по шагам.

Зачем вообще знать про фазы

Кажется, что объект — это просто new MyService(...): вызвали конструктор, и он готов. Со Spring так не работает.

Представьте сборку машины на конвейере. Сначала ставят кузов (конструктор), потом подключают двигатель и провода (внедрение зависимостей), потом заливают масло и заводят (инициализация), и только в конце машина выезжает с конвейера (готова к работе). Если попытаться нажать на газ, когда двигатель ещё не подключён, ничего не выйдет.

Spring собирает бин ровно так же — по шагам. И если ваш код пытается воспользоваться чем-то раньше, чем оно установлено, получите NullPointerException или странное поведение. Поэтому важно знать, что на каком шаге уже доступно.

Основные фазы простыми словами

От создания до остановки бин проходит такой путь:

1. Создание           — Spring вызывает конструктор
2. Внедрение           — заполняет @Autowired-поля и сеттеры
3. Инициализация       — вызывает @PostConstruct (и похожие методы)
4. Работа              — бин готов, обслуживает запросы
5. (при остановке)     — вызывает @PreDestroy, освобождает ресурсы

Главная идея: бин становится готовым постепенно. В конструкторе доступно только то, что передано в его параметры. Поля, помеченные @Autowired, заполняются уже после конструктора. Полностью собранный бин, которым можно пользоваться, появляется только после фазы инициализации.

В конструкторе зависимости из полей ещё null

Самая частая боль новичка. Код выглядит логично, а на старте падает с NullPointerException:

@Service
public class OrderService {

    @Autowired
    private MetricsClient metrics;   // внедряется в поле

    public OrderService() {
        metrics.count("started");    // ← NPE! metrics ещё null
    }
}

Почему: Spring сначала вызывает конструктор и только потом заполняет поля с @Autowired. В момент выполнения конструктора metrics ещё не установлен.

Решение — внедрять через конструктор, тогда зависимость доступна сразу:

@Service
public class OrderService {

    private final MetricsClient metrics;

    public OrderService(MetricsClient metrics) {
        this.metrics = metrics;      // здесь metrics уже есть
        metrics.count("started");    // работает
    }
}

При внедрении через конструктор разницы между «фазой создания» и «фазой внедрения» для вашего кода просто нет — к моменту выполнения тела конструктора все зависимости уже на месте. Это одна из причин, почему конструктор предпочитают внедрению в поля.

@PostConstruct — код «после сборки»

Часто нужно сделать что-то один раз, когда бин уже полностью собран: прогреть кеш, проверить настройки, подписаться на события. В конструктор это класть нельзя — там ещё не всё доступно (если внедряете в поля). Для этого есть фаза инициализации.

Помечаете метод аннотацией @PostConstruct — и Spring вызовет его после того, как все зависимости внедрены:

@Component
public class CategoryCache {

    private final CategoryRepository repo;
    private final Map<UUID, Category> cache = new ConcurrentHashMap<>();

    public CategoryCache(CategoryRepository repo) {
        this.repo = repo;
    }

    @PostConstruct
    public void warmup() {
        repo.findAll().forEach(c -> cache.put(c.id(), c));  // repo уже доступен
    }
}

Это современный и рекомендуемый способ. Аннотация @PostConstruct — часть стандарта Java (пакет jakarta.annotation), она не привязывает ваш класс к Spring.

Что обычно делают в @PostConstruct:

  • прогрев кеша — загрузить справочные данные в память заранее;
  • регистрация — добавить себя в реестр обработчиков, подписаться на события;
  • проверка настроек — «если важный параметр не задан, упасть сразу с понятной ошибкой, а не через час в проде».

Три способа инициализации и в каком порядке они идут

Кроме @PostConstruct есть ещё два способа сказать «инициализируй бин». Все три делают одно и то же, но применяются в разных ситуациях. Если на одном бине настроены несколько, Spring вызывает их строго в таком порядке:

1. @PostConstruct — аннотация на методе. Идиоматичный выбор для своего кода.

@PostConstruct
public void init() {
    // все зависимости внедрены, бин готов
}

2. InitializingBean.afterPropertiesSet() — реализуете интерфейс Spring.

@Component
public class MyService implements InitializingBean {
    @Override
    public void afterPropertiesSet() {
        // то же самое, что @PostConstruct
    }
}

Работает, но привязывает класс к фреймворку Spring. В новом коде не рекомендуется — @PostConstruct чище.

3. Custom initMethod — указываете имя метода в @Bean.

@Bean(initMethod = "customInit")
public ThirdPartyService service() {
    return new ThirdPartyService();
}

Полезно, когда класс не ваш (из чужой библиотеки) и повесить на него аннотацию нельзя.

Итог: в своём коде — @PostConstruct. Для чужих классов — initMethod. InitializingBean оставьте для старого кода.

Что НЕ стоит делать при инициализации

Тяжёлая работа в @PostConstruct блокирует запуск приложения. Spring не пустит трафик, пока все методы инициализации не отработают. Если внутри @PostConstruct вы делаете долгий запрос или загружаете гигабайты данных, старт растянется на минуты. В Kubernetes это опасно: оркестратор решит, что под завис, и перезапустит его — и так по кругу.

Если работа действительно долгая, вынесите её из фазы инициализации в событие ApplicationReadyEvent — оно срабатывает уже после полного старта и трафик не задерживает:

@EventListener(ApplicationReadyEvent.class)
public void onReady() {
    CompletableFuture.runAsync(this::heavyWarmup);  // в фоне, старт не блокируется
}

Ещё одна ловушка: на этапе @PostConstruct Spring ещё не обернул бин в прокси. Это значит, что аннотации вроде @Transactional или @Async на методах не сработают, если вызвать их из @PostConstruct. Если такая логика нужна на старте — снова используйте ApplicationReadyEvent, к этому моменту прокси уже создан.

@PreDestroy — уборка при остановке

Когда приложение останавливается, бины не просто исчезают. Если бин держит ресурсы — пул потоков, открытое соединение, файл — их надо корректно закрыть. Иначе потоки повиснут, соединения утекут.

Для этого есть зеркальная фаза инициализации — уничтожение. Помечаете метод @PreDestroy, и Spring вызовет его перед остановкой бина:

@Component
public class TaskExecutor {

    private final ExecutorService pool = Executors.newFixedThreadPool(4);

    @PreDestroy
    public void shutdown() {
        pool.shutdown();
        pool.awaitTermination(30, TimeUnit.SECONDS);
    }
}

Как и у инициализации, способов три, в том же порядке: @PreDestroy (рекомендуется) → DisposableBean.destroy() (старый способ) → custom destroyMethod в @Bean. Для классов, реализующих AutoCloseable или Closeable, Spring сам вызывает close() при остановке — отдельно настраивать не нужно.

Важная деталь: @PreDestroy вызывается только при корректной остановке (сигнал SIGTERM, ctx.close()). Если процесс убить жёстко через kill -9 (SIGKILL), JVM умирает мгновенно и Spring не успевает ничего убрать. На это полагаться нельзя.

Prototype-бины не уничтожаются

Тонкость, о которой легко забыть. Spring управляет полным жизненным циклом только у singleton-бинов (один экземпляр на всё приложение — это поведение по умолчанию).

У бинов со scope prototype (новый экземпляр на каждый запрос) Spring создаёт объект, отдаёт его и сразу «забывает». Метод @PreDestroy у такого бина не вызовется никогда. Дальше его судьбой занимается обычный сборщик мусора Java.

Вывод: если prototype-бин держит ресурсы, освобождать их должен тот код, который его запросил, — Spring тут не помощник.

Graceful shutdown — не обрывать запросы

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

Spring умеет останавливаться аккуратно. Включается двумя строками:

server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s

После сигнала остановки Spring:

  1. перестаёт принимать новые запросы;
  2. ждёт, пока завершатся текущие (до указанного таймаута);
  3. вызывает @PreDestroy у всех бинов;
  4. останавливает приложение.

Без этой настройки Spring начинает гасить бины сразу, обрывая запросы на середине.

Коротко

  • Бин собирается по шагам: создание (конструктор) → внедрение зависимостей → инициализация → работа → уничтожение.
  • В конструкторе доступны только параметры; поля с @Autowired заполняются уже после него. Поэтому внедряйте через конструктор — зависимости будут на месте сразу.
  • Код «после сборки» кладут в @PostConstruct — там все зависимости уже внедрены.
  • Способов инициализации три, в порядке вызова: @PostConstructInitializingBean.afterPropertiesSet() → custom initMethod. В своём коде выбирают @PostConstruct.
  • Тяжёлую работу в @PostConstruct не делают — она блокирует старт. Долгое выносят в ApplicationReadyEvent.
  • На этапе @PostConstruct прокси ещё нет, поэтому @Transactional/@Async там не работают — используйте ApplicationReadyEvent.
  • Уборку при остановке делают в @PreDestroy (аналоги: DisposableBean, custom destroyMethod).
  • У prototype-бинов @PreDestroy не вызывается — Spring их не отслеживает.
  • graceful shutdown даёт дождаться текущих запросов перед остановкой; kill -9 не оставляет шанса на уборку.

Что почитать дальше

  • DI/IoC и scopes — как Spring вообще создаёт и связывает бины.
  • Auto-configuration, properties, профили — как Spring Boot собирает контекст автоматически.
  • Spring AOP — как @Transactional и подобное превращают класс в прокси.