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

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

конструктор конструктор @Autowired @Autowired @PostConstruct @PostConstruct прокси прокси остановка остановка что уже доступно бину на каждом шаге конструктор: поле metrics ещё null внедрение: metrics на месте, методы инициализации ещё не звали @PostConstruct: зависимости есть, прокси ещё нет — @Transactional молчит обёртка в прокси: бин отдан контексту, @Transactional работает остановка: @PreDestroy закрывает пул — при kill -9 не случится

Бин готовится по шагам, и на каждом появляется то, чего на предыдущем ещё не было: сначала параметры конструктора, потом поля, потом прокси. Уборка идёт последней и только при корректной остановке.

Обязательно

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

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

Нажмёте на газ раньше, чем подключён двигатель, — получите NullPointerException или странное поведение. Поэтому важно знать, что на каком шаге уже доступно.

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

От создания до остановки бин проходит пять шагов, они нарисованы выше: создание (конструктор), внедрение зависимостей, инициализация, работа и — при остановке приложения — уничтожение.

Главная идея: бин становится готовым постепенно. В конструкторе доступно только то, что передано в его параметры. Поля, помеченные @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");    // работает
    }
}

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

Порядок фаз видно и без Spring: контейнер здесь заменяют три строки.

живой пример

import java.lang.reflect.Field;

public class LifecyclePhases {

    static class MetricsClient {
        void count(String name) {
            System.out.println("   метрика " + name + " отправлена");
        }
    }

    static class OrderService {
        private MetricsClient metrics;              // «поле под @Autowired»

        OrderService() {
            System.out.println("1. конструктор: metrics = " + metrics);
        }

        void init() {                               // «метод под @PostConstruct»
            System.out.println("3. инициализация: metrics = " + (metrics == null ? "null" : "внедрён"));
            metrics.count("started");
        }
    }

    public static void main(String[] args) throws Exception {
        OrderService bean = new OrderService();
        Field field = OrderService.class.getDeclaredField("metrics");
        field.setAccessible(true);
        field.set(bean, new MetricsClient());
        System.out.println("2. внедрение: поле заполнено");
        bean.init();
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

В первой строке вывода metrics = null: поле заполнилось только на втором шаге. Spring делает то же самое, только поле находит по @Autowired, а метод — по @PostConstruct.

@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; интерфейс InitializingBean встречается в классах самого Spring и библиотек; а имя метода в @Bean нужно, когда класс чужой и аннотацию в него не поставить. Все три делают одно и то же, и если на одном бине настроены несколько, 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 оставьте для старого кода.

CommandLineRunner и ApplicationRunner

«Сделать что-то один раз при старте» решается не только @PostConstruct. У Spring Boot есть два интерфейса, которые выполняются после сборки контекста, когда прокси на месте и приложение почти готово.

@Component
class ImportOnStart implements ApplicationRunner {
    private final ImportService service;

    ImportOnStart(ImportService service) { this.service = service; }

    @Override
    public void run(ApplicationArguments args) {
        if (args.containsOption("import")) {
            service.importAll(args.getOptionValues("import"));
        }
    }
}

Разница между ними одна: CommandLineRunner получает аргументы запуска строками как есть, ApplicationRunner — разобранными на опции (--import=orders) и обычные значения; в новом коде берут второй. Оба вызываются после ApplicationStartedEvent и до ApplicationReadyEvent, то есть проверка готовности в Kubernetes подождёт, пока они отработают, а исключение из раннера остановит запуск.

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

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

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

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

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

В @PostConstruct прокси ещё нет

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

Прокси — обычный объект-обёртка, и это видно на чистой Java.

живой пример

import java.lang.reflect.Proxy;

public class ProxyAfterInit {

    interface Reports {
        void build();
    }

    static class ReportService implements Reports {
        public void build() {
            System.out.println("   строим отчёт");
        }

        void warmup() {                       // «метод под @PostConstruct»
            System.out.println("@PostConstruct — прокси ещё нет:");
            build();
        }
    }

    public static void main(String[] args) {
        ReportService raw = new ReportService();
        raw.warmup();

        Reports bean = (Reports) Proxy.newProxyInstance(
                Reports.class.getClassLoader(),
                new Class<?>[]{Reports.class},
                (proxy, method, args2) -> {
                    System.out.println("   открыли транзакцию");
                    Object result = method.invoke(raw, args2);
                    System.out.println("   закоммитили");
                    return result;
                });

        System.out.println("контекст собран — вызов идёт через прокси:");
        bean.build();
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

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

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

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

Писать такое стоит только для своего пула. Пулы, которыми управляет Spring (ThreadPoolTaskExecutor под @Async, планировщик под @Scheduled), закрываются сами, и ждать задачи их заставляют настройкой spring.task.execution.shutdown.await-termination: true, а не кодом.

@Component
public class ReportPool {

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

    @PreDestroy
    public void shutdown() {
        pool.shutdown();                                   // новые задачи не принимаем
        try {
            if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
                pool.shutdownNow();                        // не дождались — прерываем
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            pool.shutdownNow();
        }
    }
}

awaitTermination объявляет InterruptedException — голый вызов не соберётся, ждать надо в try.

Как и у инициализации, способов три, в том же порядке: @PreDestroy (рекомендуется) → DisposableBean.destroy() (старый способ) → custom destroyMethod в @Bean. У бинов из @Bean-методов Spring сам вызывает close() или shutdown() при остановке; у классов под @Component такого вывода нет — там метод уборки надо назвать явно: @PreDestroy или DisposableBean.

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

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

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

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

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

Второе правило уничтожения касается уже singleton-бинов: они гасятся в порядке, обратном созданию. Если OrderService зависит от ConnectionPool, то создан пул был раньше, а @PreDestroy у сервиса вызовут раньше, чем у пула, — то есть на момент уборки сервиса его зависимости ещё живы. Это и есть ответ на частый испуг «а не окажется ли ресурс уже закрытым»: для своих зависимостей не окажется. Ломается правило там, где зависимость не объявлена: бин достаёт что-то через ApplicationContext.getBean() или через статическое поле, контейнер про эту связь не знает и порядок не гарантирует.

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

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

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

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

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

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

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

Стоит знать, чем это сделано, потому что имя настройки говорит про «фазы», а никаких фаз выше не было. Бины, которым важен момент старта и остановки относительно других, реализуют интерфейс SmartLifecycle: у него есть start(), stop(Runnable) и getPhase(). Контейнер запускает такие бины по возрастанию фазы после сборки контекста и останавливает по убыванию, дожидаясь каждой фазы не дольше spring.lifecycle.timeout-per-shutdown-phase. Веб-сервер — такой же бин с очень большой фазой, поэтому он останавливается первым и перестаёт принимать запросы раньше всех остальных.

@Component
class QueueConsumer implements SmartLifecycle {
    private volatile boolean running;

    @Override public void start() { running = true; /* подписаться на очередь */ }
    @Override public void stop() { running = false; /* отписаться и дождаться текущих */ }
    @Override public boolean isRunning() { return running; }
    @Override public int getPhase() { return Integer.MAX_VALUE - 1024; }   // раньше веб-сервера
}

Так останавливают потребителей очередей, планировщики и всё, что само порождает работу: их гасят до того, как начнут исчезать бины, от которых они зависят. @PreDestroy для этого не годится: он вызывается позже, уже на этапе разрушения бинов, и никакого порядка относительно веб-сервера не даёт. Фоновые задачи @Async и @Scheduled останавливаются отдельными настройками (spring.task.execution.shutdown.await-termination), о чём статья про Scheduled и Async.

Дополнительно: при первом чтении можно пропустить

Глубже: кто на самом деле делает работу: BeanPostProcessor и другие расширениярасширенное

@PostConstruct, @Autowired, прокси для @Transactional, автоконфигурация: контейнер это всё «делает сам», но у каждого «сам» есть исполнитель, и все они одного вида.

BeanPostProcessor это бин, которому контейнер показывает каждый созданный бин дважды: до инициализации и после. AutowiredAnnotationBeanPostProcessor в первый проход заполняет поля с @Autowired, CommonAnnotationBeanPostProcessor вызывает @PostConstruct, а AnnotationAwareAspectJAutoProxyCreator во второй проход подменяет бин прокси, если на нём висит @Transactional или аспект. Отсюда и правило из раздела выше: в @PostConstruct прокси ещё нет, потому что его создаст следующий обработчик.

@Component
class TimingPostProcessor implements BeanPostProcessor {
    @Override
    public Object postProcessAfterInitialization(Object bean, String name) {
        return bean instanceof Repository ? proxyWithTiming(bean) : bean;   // вернуть можно другой объект
    }
}

BeanFactoryPostProcessor работает раньше, когда есть только описания бинов, а самих бинов ещё нет: так подставляются ${...} в определения. @Import подключает конфигурацию по имени класса, а ImportSelector выбирает, что подключить, по условиям: на этом стоит @EnableAutoConfiguration. Condition (тот самый @ConditionalOnMissingBean) отвечает «создавать ли этот бин» до его создания. FactoryBean<T> это бин, который производит другой объект: попросили T, получили результат getObject(), так создаются прокси репозиториев Spring Data.

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

Коротко

  • Бин собирается по шагам: создание (конструктор) → внедрение зависимостей → инициализация → работа → уничтожение.
  • В конструкторе доступны только параметры; поля с @Autowired заполняются уже после него — поэтому внедряют через конструктор.
  • Код «после сборки» кладут в @PostConstruct; способов три, в порядке вызова: @PostConstruct → InitializingBean.afterPropertiesSet() → custom initMethod.
  • Тяжёлое в @PostConstruct блокирует старт — выносят в ApplicationReadyEvent; там же работают @Transactional/@Async, которых на этапе @PostConstruct ещё нет.
  • Уборку при остановке делают в @PreDestroy (аналоги: DisposableBean, custom destroyMethod); у prototype-бинов он не вызывается вовсе.
  • graceful shutdown даёт дождаться текущих запросов перед остановкой; kill -9 не оставляет шанса на уборку.
  • Всё «само» в контейнере делают BeanPostProcessor (внедрение, @PostConstruct, прокси), BeanFactoryPostProcessor (описания бинов), @Import, Condition и FactoryBean.
  • Разовая работа при старте: @PostConstruct для самого бина, ApplicationRunner для работы уровня приложения (прокси уже есть, исключение роняет старт), ApplicationReadyEvent для того, что не должно задерживать готовность.
  • Singleton-бины уничтожаются в порядке, обратном созданию: зависимости бина на момент его @PreDestroy ещё живы.
  • Порядок старта и остановки относительно веб-сервера задаёт SmartLifecycle с фазами (отсюда timeout-per-shutdown-phase); свой ExecutorService закрывают в @PreDestroy, пулы Spring — настройкой.

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

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