JavaRush /Курси /Spring Core /Spring Singleton: один екземпляр у контейнері

Spring Singleton: один екземпляр у контейнері

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

1. Звідки взагалі береться слово «singleton» у Spring

У Spring singleton — це поведінка контейнера, а не хитрість усередині класу. Якщо ви прийшли сюди після Java Core, саме слово звучить як сигнал тривоги: «О ні, зараз почнеться історія з приватним конструктором і static final INSTANCE». І так, у світі Java Singleton справді найчастіше означає GoF-патерн. Але у Spring сенс інший, тому це слово важливо буквально на старті курсу «переналаштувати».

Щойно контейнер уже вміє розрізняти bean-и за імʼям і типом, наступне запитання виникає само собою: він видає один і той самий обʼєкт чи щоразу створює новий?

У Spring, коли ви піднімаєте ApplicationContext, контейнер створює керовані обʼєкти (bean-и) і далі видає їх на запит. Для більшості bean-ів за замовчуванням діє просте правило: якщо ви попросите один і той самий bean десять разів, вам повернуть один і той самий екземпляр. Це можна побачити найпростішим, «шкільним» способом — порівняти посилання через ==.

Нижче — короткий приклад на нашому ContextFlow, де ми двічі просимо OrderPlacementService з контексту:

import com.example.contextflow.application.service.OrderPlacementService;
import com.example.contextflow.config.core.AppConfig;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
    // Беремо один і той самий bean двічі
    OrderPlacementService a = context.getBean(OrderPlacementService.class);
    OrderPlacementService b = context.getBean(OrderPlacementService.class);

    // Порівнюємо саме посилання (ідентичність обʼєкта), а не equals()
    System.out.println(a == b); // true
}

Зверніть увагу на важливу деталь: ми порівнюємо посилання, а не equals(). Це не «фішка Spring», а звичайна Java-логіка: == відповідає на запитання «це один і той самий обʼєкт у памʼяті?». І от Spring за замовчуванням каже: «Так, один і той самий».

Корисно сприймати це так: контейнер не просто «вміє створювати обʼєкти», він ще й «тримає їх у себе», як організований склад. Ви не «купуєте новий чайник щоразу, коли хочете чаю»; ви один раз поставили чайник на кухні — і далі користуєтеся ним.

2. Singleton per container

Зараз буде головна теза лекції, яку варто запамʼятати буквально «на пальцях». Spring-singleton означає: один екземпляр bean-а в межах одного контейнера. Не «на всю JVM», не «на весь компʼютер», не «на весь світ», а саме в межах ApplicationContext. Щойно зʼявляється другий контекст, зʼявляються й нові екземпляри.

Щоб це не залишалося абстракцією, уявімо спрощену картинку роботи контейнера. Усередині ApplicationContext є реєстр — за змістом щось на кшталт Map, — де він зберігає створені singleton-обʼєкти. Коли ви запитуєте bean, контейнер дивиться: «У мене вже є такий? Якщо так — тримайте. Якщо ні — створю й покладу».

flowchart TD
    C[ApplicationContext] --> R[Реєстр singleton-ів усередині контексту]
    R -->|імʼя bean-а: orderPlacementService| OPS[Екземпляр OrderPlacementService]
    R -->|імʼя bean-а: scenarioRunner| SR[Екземпляр ScenarioRunner]

    U1["Код: getBean(OrderPlacementService)"] --> C
    U2["Код: getBean(OrderPlacementService)"] --> C
    C --> OPS

Тут важливо, що ключ — це ідентичність bean-а, тобто його імʼя або визначення в контейнері, а не просто «клас як текст». У деталі метаданих сьогодні не заглиблюємося, але загальний зміст такий: один зареєстрований bean → один обʼєкт за замовчуванням.

Іноді новачки намагаються перевіряти, «один це обʼєкт чи ні», через hashCode(). Іноді це спрацьовує, але може й заплутати, тому що hashCode() можна перевизначити. Для діагностики краще використовувати System.identityHashCode(...), який бере хеш ідентичності обʼєкта і не залежить від перевизначень.

import com.example.contextflow.application.service.OrderPlacementService;
import com.example.contextflow.config.core.AppConfig;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
    OrderPlacementService a = context.getBean(OrderPlacementService.class);
    OrderPlacementService b = context.getBean(OrderPlacementService.class);

    // identityHashCode показує "ідентичність" обʼєкта в памʼяті, а не логічну рівність
    System.out.println(System.identityHashCode(a)); // наприклад: 1835217 (число залежить від запуску)
    System.out.println(System.identityHashCode(b)); // наприклад: 1835217 (те саме значення)
}

Тепер акуратно привʼяжемо це до попередньої лекції про імена та аліаси. Якщо ви реєструєте bean з кількома іменами, то це не два обʼєкти, а дві таблички з різними ярликами на одній і тій самій коробці.

Наприклад, у AppConfig можна дати два імена одному bean-у:

import com.example.contextflow.application.service.OrderPlacementService;
import com.example.contextflow.domain.ports.AuditWriter;
import com.example.contextflow.domain.ports.NotificationSender;
import com.example.contextflow.domain.ports.OrderStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AppConfig {

    @Bean(name = {"orderPlacementService", "mainOrderPlacementService"}) // два імена в одного bean-а
    public OrderPlacementService orderPlacementService(
            OrderStore store, AuditWriter auditWriter, NotificationSender sender) {
        // Повертаємо один обʼєкт, який житиме як singleton у межах контексту
        return new OrderPlacementService(store, auditWriter, sender);
    }
}

Список залежностей тут не є принциповим; для singleton важлива не довжина конструктора, а те, що контейнер тримає один екземпляр саме цього сервісного вузла.

І тоді обидві назви ведуть до одного обʼєкта:

import com.example.contextflow.config.core.AppConfig;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
    // Два різні імена (одне з них — аліас), але екземпляр один
    Object a = context.getBean("orderPlacementService");
    Object b = context.getBean("mainOrderPlacementService");

    System.out.println(a == b); // true
}

Це не «магія аліасів», а прямий наслідок моделі singleton per container: екземпляр один, просто імен два.

3. Два ApplicationContext — два різні singleton-и

На попередньому розділі легко перегнути й почати думати: «Ага, Spring singleton — це взагалі один обʼєкт назавжди». Ось тому нам зараз потрібен другий експеримент: піднімемо два контейнери й подивимося, що станеться. Спойлер: кожен контейнер житиме своїм життям і зберігатиме власний набір singleton-bean-ів, навіть якщо конфігурація однакова і клас той самий.

У коді це виглядає так: в одному методі створюємо два AnnotationConfigApplicationContext з одним і тим самим AppConfig, беремо OrderPlacementService з кожного й порівнюємо.

import com.example.contextflow.application.service.OrderPlacementService;
import com.example.contextflow.config.core.AppConfig;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

try (var c1 = new AnnotationConfigApplicationContext(AppConfig.class);
     var c2 = new AnnotationConfigApplicationContext(AppConfig.class)) {

    // Один і той самий тип bean-а, але з різних контейнерів
    OrderPlacementService s1 = c1.getBean(OrderPlacementService.class);
    OrderPlacementService s2 = c2.getBean(OrderPlacementService.class);

    System.out.println(s1 == s2); // false
}

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

Для початківця це особливо важливо з однієї причини: якщо ви почнете робити «класичний» Singleton через static, то випадково перетворите обʼєкт на глобальний у межах усієї JVM. Отже, він стане спільним і для першого контексту, і для другого. Spring-модель спеціально уникає такої глобальності: контейнер керує життям обʼєктів локально, у межах контексту, а не в масштабі всієї планети.

4. Spring singleton vs GoF: два різні світи

Тепер розберемо плутанину чесно і до кінця. У GoF-патерні Singleton клас сам робить усе, щоб екземпляр був єдиним: закриває конструктор, створює static поле, видає себе через getInstance(). У Spring слово singleton означає зовсім інше: клас може бути звичайним POJO, а «єдиність» забезпечує контейнер — і тільки всередині контексту. Слово схоже, суть інша.

Ось той самий «класичний» GoF Singleton, який багато хто памʼятає ще з перших книжок:

public final class ClassicSingleton {

    private static final ClassicSingleton INSTANCE = new ClassicSingleton();

    private ClassicSingleton() {
    }

    public static ClassicSingleton getInstance() {
        return INSTANCE;
    }
}

Він справді завжди один на JVM, якщо не займатися екзотикою з різними classloader-ами. А Spring-singleton взагалі не зобовʼязаний виглядати так. У Spring ви пишете звичайний клас:

public class OrderPlacementService {
    // звичайний клас, без static INSTANCE і без private constructor
}

І контейнер сам вирішує: «створю його один раз і використовуватиму далі».

Щоб мозок перестав плутатися, зручно зіставити відмінності в таблиці:

Питання Spring singleton GoF Singleton
Хто забезпечує «єдиність»? Контейнер (ApplicationContext) Сам клас
Де діє гарантія «один екземпляр»? У межах одного контексту У межах JVM (зазвичай)
Чи можна мати два екземпляри? Так, якщо є два контексти За задумом — ні
Чи потрібні static поля і приватний конструктор? Ні Так (класичний варіант)
Тестованість (чи можна легко створити «просто новий обʼєкт»)? Зазвичай так: new OrderPlacementService(...) Часто ні або складніше, тому що все завʼязано на глобальний стан
Що буде, якщо ви захочете «дві конфігурації в одному процесі»? Два контексти → два набори singleton-ів Починаються страждання, бо singleton один і для всіх

Найкорисніша практична думка з цієї таблиці: у Spring вам майже ніколи не потрібно писати GoF Singleton вручну. Якщо обʼєкт має бути один «на застосунок», ви робите його bean-ом, і контейнер тримає його як singleton per context. При цьому сам клас залишається нормальним і не перетворюється на «глобальну статичну змінну в костюмі».

5. Спільний обʼєкт не повинен зберігати «тимчасові» дані

Коли новачок уперше чує «один екземпляр», він зазвичай радіє: «О, отже менше обʼєктів — значить швидше!». Іноді це правда, але в singleton-моделі є важливий побічний ефект: обʼєкт розділяється між усіма ділянками застосунку, які його використовують. А отже, якщо покласти всередину такого bean-а «тимчасові дані сценарію», ви швидко створите собі загадки рівня «чому вчора працювало, а сьогодні ні». І так, це може статися навіть у простому консольному застосунку.

Саме тому в першій лекції дня ми провели межу: Order і команди — це звичайні обʼєкти, що народжуються на час сценарію. А OrderPlacementService, ScenarioRunner, AuditWriter — стабільні частини застосунку, і їх зручно тримати в контейнері як singleton-bean-и. Звідси випливає стиль: сервіси мають бути максимально «без памʼяті» про конкретний запуск.

Гарний, навчально правильний сервіс для ContextFlow виглядає так: у полях лише залежності, жодної «історії останнього замовлення».

package com.example.contextflow.application.service;

import com.example.contextflow.domain.ports.AuditWriter;
import com.example.contextflow.domain.ports.NotificationSender;
import com.example.contextflow.domain.ports.OrderStore;

public class OrderPlacementService {

    // У singleton-bean зазвичай залишаємо тільки залежності (стабільний стан)
    private final OrderStore store;
    private final AuditWriter auditWriter;
    private final NotificationSender sender;

    public OrderPlacementService(OrderStore store, AuditWriter auditWriter, NotificationSender sender) {
        this.store = store;
        this.auditWriter = auditWriter;
        this.sender = sender;
    }
}

А ось приклад поганої ідеї. Нижче клас спеціально урізано до одного проблемного поля: роль сервісу не змінюється, нам зараз важливий лише сам симптом розділюваного стану.

public class OrderPlacementService {

    private String lastOrderId; // погано: тимчасовий стан усередині shared bean

    public void placeOrder(String orderId) {
        // Тимчасовий стан "прилипає" до сервісу й починає жити довше за сценарій
        this.lastOrderId = orderId;
    }
}

Навіть в одному потоці ви можете раптово виявити, що lastOrderId «не той»: просто тому, що у вас два сценарії підряд, і другий перезаписав дані першого. А якщо колись зʼявиться паралельність — навіть випадково, якщо хтось запустить два сценарії одночасно, — стане ще веселіше. Тому простіше й здоровіше тримати тимчасові дані в методах, командах і доменних обʼєктах, а не в singleton-сервісах.

Тут важливо не скотитися в догматизм. Singleton-bean цілком може зберігати стабільний стан: наприклад, налаштування, кеш шаблонів або заздалегідь завантажені довідники. Але «дані конкретного замовлення, яке створюється просто зараз» — це майже завжди не там.

6. Маленька діагностика: як «помацати» singleton руками в коді

Розуміння в Spring часто ламається не на теорії, а на практиці: «Я думав, що це один і той самий обʼєкт, а воно чомусь різне… або навпаки». Щоб не гадати, корисно мати кілька простих діагностичних прийомів. Ми не перетворюємо це на окрему систему логування, а просто додаємо «мікроскоп» на рівні навчального коду — рівно настільки, щоб побачити поведінку контейнера.

Найпростіший спосіб — надрукувати ідентичність обʼєкта та його клас. Особливо це допомагає, якщо ви отримуєте bean і за імʼям, і за типом: одразу видно, що це один і той самий екземпляр.

import com.example.contextflow.application.service.OrderPlacementService;
import com.example.contextflow.config.core.AppConfig;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
    OrderPlacementService service = context.getBean(OrderPlacementService.class); // отримали bean із контейнера

    System.out.println(service.getClass().getName()); // фактичний клас (іноді там proxy)
    System.out.println(System.identityHashCode(service)); // наприклад: 109428835
}

Якщо ви хочете переконатися, що «різні» способи пошуку ведуть до одного обʼєкта, можна порівняти посилання напряму:

import com.example.contextflow.application.service.OrderPlacementService;
import com.example.contextflow.config.core.AppConfig;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
    var byType = context.getBean(OrderPlacementService.class); // пошук за типом
    var byName = context.getBean("orderPlacementService", OrderPlacementService.class); // пошук за імʼям + типом

    // Це має бути один і той самий екземпляр (singleton у межах контексту)
    System.out.println(byType == byName); // true
}

Зверніть увагу на другий рядок: пошук «імʼя + тип» — це обережніший спосіб. Контейнер не просто віддає обʼєкт за імʼям, він ще й перевіряє, що його можна привести до очікуваного типу. Якщо ви раптом помилилися імʼям або конфігурацією, то швидше зрозумієте, що відбувається. Але самі помилки пошуку bean-ів ми докладно розберемо в наступній лекції дня.

І ще один дуже практичний трюк: якщо ви підозрюєте, що випадково створюється більше одного контексту, а отже й singleton-ів, виведіть ідентичність самого контексту та bean-а поруч. Тоді одразу видно, що проблема не в сервісі, а в тому, що ви підняли дві «кухні».

import com.example.contextflow.application.service.OrderPlacementService;
import com.example.contextflow.config.core.AppConfig;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

try (var c1 = new AnnotationConfigApplicationContext(AppConfig.class);
     var c2 = new AnnotationConfigApplicationContext(AppConfig.class)) {

    // Самі контексти — різні обʼєкти
    System.out.println(System.identityHashCode(c1)); // наприклад: 2047329716
    System.out.println(System.identityHashCode(c2)); // наприклад: 1122334455

    // І bean-и, отримані з різних контекстів, теж будуть різними екземплярами
    System.out.println(System.identityHashCode(c1.getBean(OrderPlacementService.class)));
    System.out.println(System.identityHashCode(c2.getBean(OrderPlacementService.class)));
}

Ці чотири числа — як відбитки пальців. Вони не дають «гарантії безпеки», але для навчання й налагодження чудово показують картину: різні контексти → різні екземпляри.

7. Типові помилки під час роботи зі Spring singleton

Помилка № 1: думати, що Spring singleton — це «один на всю JVM назавжди».
Це найчастіша плутанина, і потім вона породжує дивні очікування: «чому під час другого запуску тесту обʼєкт новий?», «чому в іншому контексті інший екземпляр?». Spring-singleton живе в межах конкретного ApplicationContext. Якщо ви підняли другий контекст, у вас буде інший набір singleton-ів. Це нормальна поведінка, а не баг.

Помилка № 2: реалізувати GoF Singleton усередині класу Spring-bean-а.
Іноді студент бере сервіс і за звичкою робить private конструктор і static getInstance(), а потім дивується, що Spring якось «не так» його створює або що тестувати стало незручно. У Spring контейнер і так тримає один екземпляр bean-а, тому ручний GoF Singleton найчастіше просто погіршує ситуацію і додає глобального стану там, де він не потрібен.

Помилка № 3: зберігати «тимчасові дані сценарію» в singleton-сервісі.
Поля на кшталт lastOrder, currentCustomer, lastCommand, wasNotified усередині сервісів майже завжди закінчуються тим, що різні виклики починають впливати один на одного. У ContextFlow це особливо неприємно, тому що проєкт навчальний і ви хочете бачити передбачуваний сценарій: один запуск не повинен «залишати слідів» у сервісі для наступного.

Помилка № 4: перевіряти «однаковість» bean-ів через equals() або hashCode().
Якщо ви хочете зрозуміти, один це обʼєкт чи ні, використовуйте == або System.identityHashCode(...). equals() може бути перевизначено і буде порівнювати змістовну рівність, а не ідентичність. Для контейнерної моделі нас цікавить саме запитання: «це той самий екземпляр чи ні?».

Помилка № 5: випадково створити кілька контекстів і потім дивуватися: «чому singleton не singleton?».
Новачки іноді викликають new AnnotationConfigApplicationContext(AppConfig.class) у кількох місцях, наприклад «про всяк випадок» у різних методах. У результаті виходить кілька контейнерів, а отже й кілька наборів singleton-bean-ів, і застосунок починає поводитися так, ніби в нього кілька паралельних світів. Правильний підхід на нашому поточному етапі курсу — піднімати контекст один раз у точці входу застосунку і працювати з ним як з єдиним runtime-середовищем.

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