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 Registry внутри контекста]
R -->|bean name: orderPlacementService| OPS[OrderPlacementService instance]
R -->|bean name: scenarioRunner| SR[ScenarioRunner instance]
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 (то же число)
}
Теперь аккуратно привяжем это к предыдущей лекции про имена и alias. Если вы регистрируете 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. Shared-объект не должен хранить «временные» данные
Когда новичок впервые слышит «один экземпляр», он обычно радуется: «О, значит меньше объектов — значит быстрее!». Иногда это правда, но у 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()); // фактический класс (иногда там прокси)
System.out.println(System.identityHashCode(service)); // например: 109428835
}
Если вы хотите убедиться, что «разные» способы lookup ведут к одному объекту, можно сравнить ссылки напрямую:
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); // lookup по типу
var byName = context.getBean("orderPlacementService", OrderPlacementService.class); // lookup по имени + типу
// Это должен быть один и тот же экземпляр (singleton в рамках контекста)
System.out.println(byType == byName); // true
}
Обратите внимание на вторую строку: lookup «имя + тип» — это более осторожный способ. Контейнер не просто отдаёт объект по имени, он ещё и проверяет, что его можно привести к ожидаемому типу. Если вы вдруг ошиблись именем или конфигурацией, то быстрее поймёте, что происходит. Но сами ошибки поиска 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 constructor и 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-средой.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ