🍃 Розділ 5 · Питання #10

Що таке Bean scope

Коли ви оголошуєте клас як @Component або метод як @Bean, Spring повинен знати: створювати єдиний екземпляр на весь застосунок чи породжувати новий об'єкт під час кожного зверне...


🟢 Junior Level

Scope (область видимості біна) — це декларативна стратегія Spring IoC-контейнера, що визначає життєвий цикл, правила створення, кількість екземплярів та контекст зберігання конкретного біна.

Коли ви оголошуєте клас як @Component або метод як @Bean, Spring повинен знати: створювати єдиний екземпляр на весь застосунок чи породжувати новий об’єкт під час кожного звернення.

Базові області видимості

За замовчуванням у Spring усі біни мають область видимості Singleton.

Scope Опис Скільки екземплярів у пам’яті
singleton (за замовчуванням) Один екземпляр на весь ApplicationContext 1 екземпляр на контейнер
prototype Новий екземпляр створюється при кожному запиті з контейнера Безліч екземплярів (N разів)

Базовий приклад

package com.example.demo;

import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Service;

// 1. Singleton (за замовчуванням, анотацію @Scope можна опустити)
@Service
public class OrderService {
    // Єдиний екземпляр для всіх користувачів та потоків застосунку
}

// 2. Prototype (новий об'єкт при кожному впровадженні або виклику getBean)
@Service
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class InvoiceReportTask {
    // Зберігає стан конкретного звіту
}
// Перевірка в коді:
OrderService s1 = context.getBean(OrderService.class);
OrderService s2 = context.getBean(OrderService.class);
System.out.println(s1 == s2); // true: посилання вказують на один і той самий об'єкт у Heap

InvoiceReportTask t1 = context.getBean(InvoiceReportTask.class);
InvoiceReportTask t2 = context.getBean(InvoiceReportTask.class);
System.out.println(t1 == t2); // false: створено два незалежні об'єкти

🟡 Middle Level

Архітектурний інтерфейс org.springframework.beans.factory.config.Scope

В основі механізму областей видимості лежить інтерфейс Scope. Саме через нього Spring абстрагує зберігання та отримання об’єктів:

package org.springframework.beans.factory.config;

public interface Scope {

    // Отримати наявний об'єкт або створити новий через objectFactory
    Object get(String name, ObjectFactory<?> objectFactory);

    // Видалити об'єкт з області видимості (наприклад, при завершенні HTTP-сесії)
    Object remove(String name);

    // Зареєструвати зворотний виклик (callback), що виконається при знищенні області
    void registerDestructionCallback(String name, Runnable callback);

    // Дозволити контекстний об'єкт (наприклад, HttpServletRequest)
    Object resolveContextualObject(String key);

    // Отримати ідентифікатор сесії/розмови (наприклад, Session ID)
    String getConversationId();
}

Коли викликається context.getBean(beanName), метод AbstractBeanFactory.doGetBean() аналізує scope біна:

  • Якщо mbd.isSingleton() $\to$ звертається до DefaultSingletonBeanRegistry.getSingleton().
  • Якщо mbd.isPrototype() $\to$ викликає createBean() без збереження посилання в кеші.
  • Якщо інший scope $\to$ знаходить відповідну реалізацію Scope в мапі this.scopes і викликає scope.get(beanName, () -> createBean(...)).

Проблема невідповідності життєвих циклів (Scoped Mismatch)

Найпідступніша помилка при роботі зі scope: впровадження біна з коротким життєвим циклом (prototype, request) у бін із довгим життєвим циклом (singleton).

@Service // Singleton (живе весь час роботи застосунку)
public class OrderProcessor {

    @Autowired // ПОМИЛКА: Prototype створиться лише ОДИН раз під час старту застосунку!
    private PaymentSession paymentSession;

    public void processOrder() {
        // paymentSession буде одним і тим самим об'єктом для всіх замовлень та потоків!
        paymentSession.pay(); 
    }
}

Чому це відбувається:

Singleton-бін інстанціюється один раз під час старту контексту. У цей самий момент контейнер одноразово впроваджує всі його залежності. У результаті prototype-бін «заморожується» всередині синглтона і перестає оновлюватися під час наступних викликів.


🔴 Senior Level

4 способи розв’язання проблеми Scoped Mismatch

Спосіб 1: ObjectProvider<T> (Ідіоматичний та типобезпечний)

Механізм відкладеного отримання біна без прямого звернення до ApplicationContext:

@Service
public class OrderProcessor {

    private final ObjectProvider<PaymentSession> paymentSessionProvider;

    public OrderProcessor(ObjectProvider<PaymentSession> paymentSessionProvider) {
        this.paymentSessionProvider = paymentSessionProvider;
    }

    public void processOrder() {
        // При кожному виклику запитується новий актуальний екземпляр Prototype/Request
        PaymentSession session = paymentSessionProvider.getObject();
        session.pay();
    }
}

Спосіб 2: Scoped Proxy (ScopedProxyMode.TARGET_CLASS)

Spring впроваджує в singleton-бін не сам цільовий об’єкт, а CGLIB-проксі. Проксі перехоплює виклики методів і на льоту делегує їх актуальному екземпляру:

@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class UserRequestContext {
    private String userId;
    // гетери, сетери
}

@Service
public class BillingService {
    // Впроваджується CGLIB-проксі UserRequestContext
    private final UserRequestContext context;

    public BillingService(UserRequestContext context) {
        this.context = context;
    }

    public void bill() {
        // Виклик context.getUserId() через проксі звертається до RequestContextHolder (ThreadLocal)
        // поточного HTTP-потоку і знаходить бін саме поточного запиту!
        System.out.println("Processing user: " + context.getUserId());
    }
}

Спосіб 3: Метод ін’єкції (@Lookup)

Spring за допомогою CGLIB перевизначає метод класу в рантаймі, змушуючи його звертатися до BeanFactory:

@Service
public abstract class ReportService {

    public void generateReport() {
        ReportTask task = getReportTask(); // CGLIB згенерує виклик context.getBean(ReportTask.class)
        task.execute();
    }

    @Lookup
    protected abstract ReportTask getReportTask();
}

Спосіб 4: Фабричний інтерфейс (Provider<T> із JSR-330 / Jakarta Inject)

Стандартний інтерфейс jakarta.inject.Provider<T>, аналогічний ObjectProvider:

@Service
public class OrderProcessor {
    @Autowired
    private Provider<PaymentSession> paymentSessionProvider;
}

Реалізація кастомного Scope: ThreadScope

У Spring можна створити власний scope для ізоляції бінів за будь-яким контекстом (наприклад, орендар у multitenancy, транзакція чи окремий потік):

package com.example.scope;

import org.springframework.beans.factory.ObjectFactory;
import org.springframework.beans.factory.config.Scope;

import java.util.HashMap;
import java.util.Map;

public class CustomThreadScope implements Scope {

    private final ThreadLocal<Map<String, Object>> threadLocal = 
            ThreadLocal.withInitial(HashMap::new);

    @Override
    public Object get(String name, ObjectFactory<?> objectFactory) {
        Map<String, Object> scope = threadLocal.get();
        return scope.computeIfAbsent(name, k -> objectFactory.getObject());
    }

    @Override
    public Object remove(String name) {
        return threadLocal.get().remove(name);
    }

    @Override
    public void registerDestructionCallback(String name, Runnable callback) {
        // Реєстрація зворотних викликів очищення за потреби
    }

    @Override
    public Object resolveContextualObject(String key) {
        return null;
    }

    @Override
    public String getConversationId() {
        return Thread.currentThread().getName();
    }

    public void clear() {
        threadLocal.remove(); // Запобігання витокам пам'яті в пулах потоків!
    }
}

Реєстрація у конфігурації:

@Configuration
public class ScopeConfig {

    @Bean
    public static CustomScopeConfigurer customScopeConfigurer() {
        CustomScopeConfigurer configurer = new CustomScopeConfigurer();
        configurer.addScope("thread", new CustomThreadScope());
        return configurer;
    }
}

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь

Scope біна — це декларативна стратегія IoC-контейнера, що керує життєвим циклом та кількістю екземплярів об’єкта. Базовими є singleton (один екземпляр на ApplicationContext, кешується в singletonObjects) та prototype (новий об’єкт при кожному запиті, контейнер не викликає @PreDestroy). У Web-застосунках додаються request, session, application та websocket. Архітектурно scope реалізується через інтерфейс org.springframework.beans.factory.config.Scope.


4 каверзних питання з відповідями

1. Що станеться, якщо впровадити @Scope("prototype") бін у @Scope("singleton") сервіс через звичайний конструктор або @Autowired?

Відповідь: Prototype-бін створиться рівно один раз у момент ініціалізації синглтона. У результаті він виявиться «замороженим» (scoped mismatch), і при всіх наступних зверненнях до сервісу перевикористовуватиметься один і той самий екземпляр.

2. Як правильно впровадити короткоживучий бін (наприклад, Request або Prototype) у Singleton?

Відповідь: Існує 4 основні рішення:

  1. Використовувати ObjectProvider<T>.getObject().
  2. Налаштувати Scoped Proxy: @Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS).
  3. Використовувати метод ін’єкції @Lookup.
  4. Використовувати Provider<T> із jakarta.inject.

3. Як працює ScopedProxyMode.TARGET_CLASS під капотом?

Відповідь: Spring генерує CGLIB-проксі для цільового класу. Цей проксі впроваджується в синглтон замість реального об’єкта. При кожному виклику будь-якого бізнес-методу проксі звертається до RequestContextHolder (ThreadLocal) або відповідної реалізації Scope, витягує актуальний для поточного потоку/запиту екземпляр біна та делегує йому виклик.

4. Де фізично зберігаються Singleton-біни в пам’яті JVM?

Відповідь: У карті singletonObjects (тип ConcurrentHashMap<String, Object>), розташованій у низькорівневому реєстрі DefaultSingletonBeanRegistry всередині DefaultListableBeanFactory.


Червоні прапорці (чого категорично не можна говорити)

  • ❌ «Singleton у Spring — це те саме, що патерн Singleton GoF» — GoF Singleton гарантує один об’єкт на ClassLoader за рахунок приватного конструктора і static-поля; Spring Singleton гарантує один екземпляр на конкретний ApplicationContext.
  • ❌ «Prototype-біни автоматично знищуються Spring під час збирання сміття» — контейнер взагалі не відстежує їхню деструкцію (@PreDestroy не викликається).
  • ❌ «Впровадити Request-бін у Singleton можна безпосередньо без проксі» — застосунок або впаде з помилкою під час старту, або зафіксує перший контекст у всіх наступних запитах.
  • ❌ «Для отримання Prototype у Singleton потрібно заінжектити ApplicationContext і викликати getBean()» — антипатерн Service Locator, що порушує інверсію управління. Слід використовувати ObjectProvider.

Пов’язані теми