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 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-средой.

1
Задача
Spring Core, 4 уровень, 2 лекция
Недоступна
Один и тот же bean внутри одного контейнера
Один и тот же bean внутри одного контейнера
1
Задача
Spring Core, 4 уровень, 2 лекция
Недоступна
Два контейнера — два экземпляра
Два контейнера — два экземпляра
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ