JavaRush /Курси /Spring Core /Життєвий цикл біна: від визначення до закриття

Життєвий цикл біна: від визначення до закриття

Spring Core
Рівень 10 , Лекція 0
Відкрита

1. Ментальна модель життєвого циклу біна

Поки Spring для нас був передусім інструментом, який збирає залежності й віддає готовий сервіс, усе здавалося досить лінійним: контейнер стартував, ми діставали ScenarioRunner, запускали сценарій — і щастя. Але щойно граф залежностей зібрано, майже відразу постає другий шар запитань: коли об’єкт вважати готовим, де виконувати підготовку і що робити під час завершення застосунку. Саме це й називають lifecycle.

У життєвого циклу є дуже практична мета: зробити так, щоб ваші біни завжди перебували в коректному стані, коли хтось починає ними користуватися, і щоб ресурси закривалися акуратно, а не «як вийде». Це особливо важливо, коли в об’єкта є підготовка: кеш, індекс, попереднє завантаження даних, перевірка конфігурації, створення каталогів, відкриття файлових ресурсів. Ви можете взагалі не любити слово «lifecycle» (воно звучить як щось із корпоративної презентації), але ви точно любите ідею «не ловити баги через те, що об’єкт використали до того, як він підготувався».

І ще один мотив: розуміння життєвого циклу робить Spring менш містичним. Коли ви точно знаєте, в який момент створюються об’єкти, в який момент викликаються ваші «службові» методи і чому під час закриття контексту щось відбувається, зникає відчуття, що фреймворк живе окремим життям і іноді просто «робить речі».

2. Контейнер: знає, створює, завершує

Є проста думка, яка економить новачкові багато нервів: контейнер може знати про бін, створювати бін і завершувати бін — і це не одне й те саме. Ми вже стикалися з цим, коли обговорювали BeanDefinition: Spring може «бачити» ваш бін як опис, ще не маючи в руках реальний об’єкт. Це як різниця між тим, що в меню ресторану є піца, і гарячою піцою на вашому столі. Меню — це метадані, піца — це екземпляр. І так, іноді піца з’являється лише тоді, коли ви її замовляєте, але сам принцип поділу «опис vs екземпляр» від цього не змінюється.

Щоб закріпити ідею, візьмімо дуже простий приклад, максимально близький до нашого ContextFlow. Нехай у нас є ScenarioRunner — клас, який ми потім будемо діставати з контексту й запускати сценарії.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

// Конфігурація контексту: тут Spring читає метадані й реєструє BeanDefinition для цього біна
@Configuration
class ContextFlowConfig {

    // Фабричний метод біна: сам факт @Bean означає реєстрацію визначення (але не обовʼязково негайне створення)
    @Bean
    ScenarioRunner scenarioRunner() {
        return new ScenarioRunner();
    }
}

class ScenarioRunner {

    ScenarioRunner() {
        // Конструктор викликається на фазі створення екземпляра (це ще не «стан готовності»)
        System.out.println("ScenarioRunner: конструктор");
    }
}

Що тут важливо: наявність @Bean-методу означає, що контейнер зареєструє визначення (BeanDefinition) для ScenarioRunner. Але це ще не означає, що життя об’єкта вже почалося. Воно почнеться, коли контейнер вирішить створити цей бін. У більшості випадків це станеться під час старту контексту, але зараз головне інше — ми розділяємо в голові ці дві стадії.

І ось друга частина картини: бін живе не сам по собі, а всередині контексту. Якщо контекст закрився, для керованих об’єктів настає фінальна сцена, де контейнер дає їм шанс акуратно завершити роботу.

3. Ланцюжок фаз: від registration до destroy

Якщо ви коли-небудь бачили, як люди пояснюють lifecycle лише двома анотаціями (@PostConstruct і @PreDestroy), то у вас могла лишитися дивна думка: «Гаразд, а все інше де?». Усе інше — це і є основна схема. Нам потрібен опорний ланцюжок, на який потім нормально накладаються різні варіанти init/destroy-колбеків.

Давайте зафіксуємо базову послідовність, без рідкісної екзотики й без внутрішніх нетрів Spring (їх ми ще встигнемо не любити пізніше). У спрощеному вигляді життєвий цикл виглядає так:

flowchart TD
    A["BeanDefinition зареєстровано (контейнер «знає», що бін існує)"]
    B["Створення екземпляра (Instantiation)"]
    C["Впровадження залежностей (Dependency injection)"]
    D["Фаза ініціалізації (після впровадження залежностей)"]
    E["Стан готовності (ready state)"]
    F["Закриття ApplicationContext"]
    G["Фаза завершення (destroy callbacks)"]

    A --> B --> C --> D --> E --> F --> G

Для звичайного керованого контейнером біна цієї карти достатньо: окремі нюанси не змінюють головний скелет «контейнер знає → збирає → готує → завершує».

Щоб це не виглядало як абстрактна стрілочна магія, зведімо те саме в таблицю (людськими словами):

Фаза Що робить контейнер Що це означає для вас у коді
Реєстрація Читає @Configuration, сканує @Component, реєструє визначення Контейнер уже знає, що «такий бін має бути», але об’єкт ще не обов’язково створено
Створення екземпляра Створює екземпляр: викликає конструктор / factory method Об’єкт з’являється як екземпляр; якщо залежності приходять через конструктор, контейнер ураховує це вже на цьому кроці
Впровадження залежностей Доводить бін до зібраного стану Після появи екземпляра контейнер впроваджує решту залежностей там, де це потрібно, і збирає об’єкт до робочого стану
Ініціалізація Дає біну виконати «підготовку» Тут є місце для перевірок конфігурації, завантаження кешу, індексу, каталогів тощо
Стан готовності Вважає бін готовим і віддає його іншим Інші біни та сценарії можуть безпечно використовувати цей об’єкт
Завершення Під час закриття контексту викликає destroy-логіку Можна закрити ресурси, дописати буфери, акуратно завершити роботу

Зверніть увагу на два тонкі моменти.

Перший: конструктор не дорівнює готовності. Навіть якщо залежності приходять через конструктор, об’єкту все одно може знадобитися окремий крок підготовки. Причина проста: частина речей має відбуватися вже після того, як контейнер зібрав навколо біна все потрібне. Саме тому init-фаза взагалі існує як окремий крок, а не як «дублювання конструктора».

Другий: lifecycle — це не «анімація для краси», а контракт. Якщо ваш бін потребує підготовки, він має бути підготовлений до того, як його почнуть використовувати. Якщо бін тримає ресурси, він має отримати шанс коректно їх відпустити, коли застосунок завершується.

4. Lifecycle і ApplicationContext

Дуже легко думати так: «ну в мене ж є main, там усе починається, там усе й закінчується». У звичайній Java це часто працює. У Spring-застосунку реальне життя починається трохи раніше — з моменту, коли ви створюєте ApplicationContext, і закінчується не тоді, коли JVM завершує роботу, а коли ви закрили контекст. Контекст у цьому сенсі — як адміністратор сцени: він організовує вихід акторів на сцену (створення бінів), а потім — їх коректний вихід зі сцени (destroy-фазу).

У нашому не веб-застосунку найчесніший і найзрозуміліший спосіб керувати цим — try-with-resources. Він хороший тим, що в одному місці видно: контекст стартував, усередині блоку ми виконуємо роботу, а контекст гарантовано закривається.

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class ContextFlowApp {

    public static void main(String[] args) {
        // Контекст — ресурс: try-with-resources гарантує закриття й запуск destroy-фази для керованих бінів
        try (var ctx = new AnnotationConfigApplicationContext(ContextFlowConfig.class)) {
            System.out.println("Контекст запущено");
            // Запитуємо бін: якщо він eager — уже створений, якщо lazy — створиться тут
            ctx.getBean(ScenarioRunner.class);
        }
        System.out.println("Контекст закрито");
    }
}

Чому це важливо саме зараз, навіть до конкретних механізмів ініціалізації та завершення? Тому що destroy-фаза взагалі існує лише тоді, коли контекст закрито. Якщо ви не закриваєте контекст, ви ніби кажете: «Spring, усе, що ти хотів акуратно завершити, забудь, я просто вимкну світло й піду». Іноді це спрацьовує на маленьких прикладах. А потім ви додасте щось, що тримає ресурси (хоча б файловий writer), — і почнуться дивні ефекти. У навчальному проєкті краще відразу формувати правильну звичку: ApplicationContext — це ресурс, його треба закривати.

Так, у Spring є registerShutdownHook() (контекст зареєструє shutdown hook для коректного завершення JVM). Це буває корисно, але як навчальний варіант за замовчуванням для консольного застосунку все одно краще try-with-resources: він робить завершення явним, а не «сподіваємося, що JVM коректно завершиться».

Звідси постають чотири практичні запитання. Де закінчується роль конструктора і починається справжня готовність об’єкта? Як контейнер викликає init-логіку? Що саме дає біну шанс акуратно завершитися? І як змінюється вся картина, якщо бін створюється не відразу, а на вимогу?

5. Стан готовності: після конструктора

Найчастіша плутанина тут така: здається, ніби конструктор автоматично робить бін повністю готовим. Але існування екземпляра і ready state — різні речі. Об’єкт може отримати залежності й усе одно ще потребувати короткої контейнерної підготовки: перевірити конфігурацію, прогріти кеш, створити каталог виведення, підготувати внутрішню структуру. Тому lifecycle і тримає окрему init-фазу: вона знімає з інших сервісів обов’язок гадати, чи готовий об’єкт до роботи.

Фаза завершення

Із destroy-фазою симетрична історія. Якщо бін тримає writer, буфер, каталог, тимчасові дані або будь-який інший ресурс, контейнеру потрібен момент, коли він може сказати: «закругляємося, звільни все акуратно». Цей момент прив’язаний не до абстрактного «кінець main()», а до закриття ApplicationContext. Поки контекст живий, бін вважається робочим; коли контекст закривають, контейнер запускає його фінальну частину lifecycle.

Біни, керовані життєвим циклом, у ContextFlow

На практиці це найпростіше побачити на двох звичайних об’єктах. NotificationTemplateCatalog має один раз підготувати шаблони й потім швидко віддавати їх сервісам, а ReportOutputManager — підготувати вихідні дані й коректно закритися під час shutdown. В обох випадках хочеться одного й того самого: NotificationDispatchService і ReportingService не повинні пам’ятати про loadDefaults(), prepare() або close(). Вони мають отримувати вже готові залежності, а решта — турбота контейнера. На таких бінах особливо добре видно, навіщо lifecycle взагалі потрібен.

6. Типові помилки під час роботи з життєвим циклом

Помилка №1: вважати, що lifecycle біна — це лише конструктор.
Тоді в конструктор швидко починають запихати важку підготовку, перевірки та роботу з ресурсами. Корисніше тримати в голові окрему init-фазу: об’єкт може існувати й ще не бути готовим до нормальної роботи.

Помилка №2: плутати «контейнер знає про бін» і «бін уже створено».
BeanDefinition і реальний екземпляр — не одне й те саме. Поки ця різниця не схлопнулася в голові, помилки запуску й поведінка lazy/eager будуть здаватися випадковою магією.

Помилка №3: не закривати ApplicationContext і дивуватися, що «нічого не завершилося».
Destroy-фаза не зобов’язана відбуватися сама собою. Якщо контекст не закрито, контейнер не отримує шансу коректно завершити керовані біни.

Помилка №4: розмазувати prepare() і close() по бізнес-сервісах.
Щойно сервіс починає пам’ятати про чужий «ритуал готовності», DI перестає бути чесною моделлю залежностей. Підготовка і завершення мають залишатися обов’язком контейнера або одного явного місця складання.

Помилка №5: очікувати поведінки lifecycle від об’єктів, створених через new поза контейнером.
Spring керує лише тими об’єктами, які він сам створює як біни. Якщо об’єкт створено вручну, жоден керований контейнером init/destroy до нього не застосовується — і це нормально.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ