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-середовищем.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ