Бин в Spring не появляется готовым в одну секунду. Сначала Spring вызывает конструктор, потом внедряет зависимости, потом запускает методы инициализации — и только после этого бин готов к работе. А при остановке приложения всё проходит в обратном порядке. Понимание этих фаз снимает целый класс «магических» багов: почему в конструкторе всё null, почему @PostConstruct запускается раньше времени, почему ресурсы не освобождаются при остановке.
Бин готовится по шагам, и на каждом появляется то, чего на предыдущем ещё не было: сначала параметры конструктора, потом поля, потом прокси. Уборка идёт последней и только при корректной остановке.
Зачем вообще знать про фазы
Кажется, что объект — это просто 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 есть ещё два способа сказать «инициализируй бин». Все три делают одно и то же, но применяются в разных ситуациях. Если на одном бине настроены несколько, 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: к этому моменту прокси уже создан. С одной оговоркой: вызов 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 вызовет его перед остановкой бина:
@Component
public class TaskExecutor {
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.
Важная деталь: @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:
- перестаёт принимать новые запросы;
- ждёт, пока завершатся текущие (до указанного таймаута);
- вызывает
@PreDestroyу всех бинов; - останавливает приложение.
Без этой настройки Spring начинает гасить бины сразу, обрывая запросы на середине.
Коротко
- Бин собирается по шагам: создание (конструктор) → внедрение зависимостей → инициализация → работа → уничтожение.
- В конструкторе доступны только параметры; поля с
@Autowiredзаполняются уже после него — поэтому внедряют через конструктор. - Код «после сборки» кладут в
@PostConstruct; способов три, в порядке вызова:@PostConstruct→InitializingBean.afterPropertiesSet()→ custominitMethod. - Тяжёлое в
@PostConstructблокирует старт — выносят вApplicationReadyEvent; там же работают@Transactional/@Async, которых на этапе@PostConstructещё нет. - Уборку при остановке делают в
@PreDestroy(аналоги:DisposableBean, customdestroyMethod); у prototype-бинов он не вызывается вовсе. graceful shutdownдаёт дождаться текущих запросов перед остановкой;kill -9не оставляет шанса на уборку.
Что почитать дальше
- DI/IoC и scopes — как Spring вообще создаёт и связывает бины.
- Auto-configuration, properties, профили — как Spring Boot собирает контекст автоматически.
- Spring AOP — как
@Transactionalи подобное превращают класс в прокси. - Spring Events — про
ApplicationReadyEventи другие события контекста.