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

В чому різниця між singleton та prototype scope

Singleton та Prototype — дві фундаментальні області видимості бінів у Spring. Різниця між ними полягає у кількості створюваних об'єктів, моменті їх інстанціювання та управлінні...


🟢 Junior Level

Singleton та Prototype — дві фундаментальні області видимості бінів у Spring. Різниця між ними полягає у кількості створюваних об’єктів, моменті їх інстанціювання та управлінні їхнім життєвим циклом.

Головні відмінності

Характеристика Singleton (за замовчуванням) Prototype
Кількість об’єктів Один екземпляр на весь ApplicationContext Новий екземпляр під час кожного звернення/ін’єкції
Момент створення Під час старту застосунку (Eager, якщо не @Lazy) На вимогу в рантаймі (On Demand / Lazy)
Де зберігається посилання У кеші синглтонів Spring (singletonObjects) Не зберігається контейнером після видачі клієнту
Виклик @PostConstruct Викликається один раз під час старту Викликається щоразу для нового об’єкта
Виклик @PreDestroy ✅ Викликається при закритті контексту ❌ НЕ викликається контейнером!
Потокобезпечність Повинен бути Stateless (без стану) Може бути Stateful (зберігати внутрішній стан)

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

package com.example.demo;

import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;

@Component
public class CacheManager {
    // Singleton: один спільний об'єкт для всіх потоків
}

@Component
@Scope("prototype")
public class DataImportJob {
    // Prototype: кожен імпорт даних створює ізольований об'єкт
    private String fileName;

    public void setFileName(String fileName) {
        this.fileName = fileName;
    }
}
// Перевірка рівності посилань:
CacheManager c1 = context.getBean(CacheManager.class);
CacheManager c2 = context.getBean(CacheManager.class);
System.out.println(c1 == c2); // true (один і той самий об'єкт у пам'яті)

DataImportJob j1 = context.getBean(DataImportJob.class);
DataImportJob j2 = context.getBean(DataImportJob.class);
System.out.println(j1 == j2); // false (два різні об'єкти в Heap)

🟡 Middle Level

Відмінності в життєвому циклі (Lifecycle)

Життєві цикли singleton та prototype збігаються лише на етапі створення, але кардинально розходяться на етапі знищення:

Фаза                     Singleton                 Prototype
----------------------------------------------------------------------
1. Instantiation         Викликається (1 раз)      Викликається (N разів)
2. Populate Properties   Викликається (1 раз)      Викликається (N разів)
3. Aware Interfaces      Викликається (1 раз)      Викликається (N разів)
4. BPP BeforeInit        Викликається (1 раз)      Викликається (N разів)
5. @PostConstruct        Викликається (1 раз)      Викликається (N разів)
6. BPP AfterInit (Proxy) Викликається (1 раз)      Викликається (N разів)
----------------------------------------------------------------------
7. Утримання посилання   У кеші контейнера         Віддається клієнту (GC-eligible)
----------------------------------------------------------------------
8. @PreDestroy           ✅ Викликається           ❌ НЕ викликається контейнером
9. DisposableBean        ✅ Викликається           ❌ НЕ викликається контейнером
10. destroy-method       ✅ Викликається           ❌ НЕ викликається контейнером

Проблема очищення ресурсів у Prototype

Оскільки контейнер не відстежує деструкцію prototype-бінів, звільняти ресурси зобов’язаний клієнтський код:

package com.example.demo;

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;

@Service
public class ReportOrchestrator {

    private final ObjectProvider<PdfExportTask> taskProvider;

    public ReportOrchestrator(ObjectProvider<PdfExportTask> taskProvider) {
        this.taskProvider = taskProvider;
    }

    public void generate() {
        PdfExportTask task = taskProvider.getObject();
        try {
            task.execute();
        } finally {
            // Ручне звільнення ресурсів! Інакше файли та сокети залишаться відкритими
            task.cleanup(); 
        }
    }
}

Впровадження Prototype в Singleton: пастка «заморожування»

Якщо заінжектити prototype-бін безпосередньо в singleton:

@Service // Singleton
public class OrderService {

    @Autowired // УВАГА: Prototype впровадиться лише ОДИН раз під час старту сервісу!
    private PaymentValidator validator; 
}

Результат: PaymentValidator створюється один раз під час збирання OrderService і назавжди «застигає» в ньому. Щоби при кожному виклику отримувати свіжий екземпляр, використовують ObjectProvider<PaymentValidator> або методну ін’єкцію @Lookup.


🔴 Senior Level

Циклічні залежності: фундаментальна різниця

  • Для Singleton: Контейнер здатний розв’язувати циклічні залежності (при використанні setter/field injection) завдяки трирівневому кешу (singletonObjects, earlySingletonObjects, singletonFactories). Фабрика раннього посилання публікує об’єкт у купі до його повної ініціалізації.
  • Для Prototype: Циклічні залежності нерозв’язні в принципі. Якщо бін ProtoA залежить від ProtoB, а ProtoB залежить від ProtoA:
    1. Створюється ProtoA, йому потрібен ProtoB.
    2. Створюється новий екземпляр ProtoB, йому потрібен новий екземпляр ProtoA.
    3. Створюється новий екземпляр ProtoA…
    4. Процес переходить у нескінченну рекурсію. Spring відстежує стек викликів і негайно викидає: BeanCurrentlyInCreationException: Error creating bean with name 'protoA': Requested bean is currently in creation: Is there an unresolvable circular reference?

Навантаження на продуктивність та GC Overhead

Використання Prototype-бінів у критичних за продуктивністю ділянках коду (High-Throughput Hotpaths) — серйозна помилка проєктування.

Порівняння вартості:

  1. Singleton:
    • Витрати на рефлексію, пошук анотацій, впровадження залежностей та генерацію проксі відбуваються один раз під час прогріву застосунку.
    • У рантаймі виклик методу біна — це прямий виклик посилання зі швидкістю процесора (JIT inlining).
  2. Prototype:
    • Під час кожного запиту getBean() контейнер виконує:
      1. Рефлексивний виклик конструктора (Constructor.newInstance).
      2. Повний прогін через ланцюжок BeanPostProcessor (postProcessProperties, ін’єкція всіх полів).
      3. Виклик @PostConstruct та Aware-методів.
      4. Потенційну генерацію/обгортання в AOP-проксі (ProxyFactory).
    • Це створює мільйони короткоживучих об’єктів за секунду, призводячи до високої частоти спрацьовувань Young GC (GC Pauses) та різкого зростання навантаження на CPU.

Коли дійсно потрібен Prototype, а коли звичайний new?

Чи потрібен новий екземпляр?
       │
       ├── Ні → Singleton (Stateless)
       │
       └── Так → Чи потрібні всередині об'єкта залежності Spring (@Autowired, @Transactional, @Value)?
                  │
                  ├── Ні → Використовуйте звичайний `new MyObject()`! (У сотні разів швидше за Prototype)
                  │
                  └── Так → Prototype через ObjectProvider<T>

Правило Senior: Якщо класу не потрібні залежності від Spring-контейнера, створення його як @Scope("prototype") є антипатерном. Звичайний оператор new в Java не створює накладних витрат контейнера і дозволяє збирачу сміття застосувати скалярну заміну (Escape Analysis / Scalar Replacement).


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

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

Основні відмінності Singleton та Prototype:

  1. Кількість: Singleton — один екземпляр на ApplicationContext, Prototype — новий об’єкт під час кожного запиту.
  2. Управління життєвим циклом: Для Singleton Spring керує повним циклом, включаючи знищення (@PreDestroy). Для Prototype Spring виконує лише створення та ініціалізацію, а фазу знищення повністю ігнорує.
  3. Зберігання в пам’яті: Singleton утримується в кеші singletonObjects; Prototype не зберігається контейнером.
  4. Циклічні залежності: Розв’язні для Singleton через 3-рівневий кеш, але призводять до BeanCurrentlyInCreationException для Prototype.

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

1. Чому Spring не викликає @PreDestroy для бінів зі scope prototype?

Відповідь: Spring не зберігає посилань на створені prototype-біни. Зберігання посилань на всі створені prototype-екземпляри призвело б до катастрофічного витоку пам’яті, оскільки збирач сміття не міг би їх видалити. Тому контейнер передає екземпляр клієнту і повністю знімає з себе відповідальність за його утилізацію.

2. Що станеться, якщо два Prototype-біни мають циклічну залежність один від одного?

Відповідь: Застосунок викине BeanCurrentlyInCreationException. 3-рівневий кеш працює виключно для синглтонів. Для прототипів немає механізму ранньої публікації екземпляра, тому контейнер потрапляє в нескінченну взаємну рекурсію інстанціювання.

3. Чому пряме впровадження Prototype в Singleton не працює належним чином?

Відповідь: Singleton-бін створюється і наповнюється залежностями один раз під час старту застосунку. Впроваджений prototype-бін закріплюється в полі синглтона і перестає оновлюватися («заморожується»). Рішення: використовувати ObjectProvider<T>.getObject() або @Lookup.

4. Що ефективніше: Prototype-бін чи створення об’єкта через оператор new?

Відповідь: Оператор new незрівнянно швидший та ефективніший. Запит Prototype-біна запускає важкий конвеєр контейнера: сканування метаданих, рефлексію, впровадження залежностей, прогін через усі BeanPostProcessor та можливе створення CGLIB/JDK-проксі. Prototype виправданий лише тоді, коли створюваному об’єкту дійсно потрібні впроваджувані залежності Spring.


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

  • ❌ «@PreDestroy викликається для Prototype перед тим, як збирач сміття видалить об’єкт» — груба помилка. Garbage Collector нічого не знає про анотації Spring, а контейнер посилання не зберігає.
  • ❌ «Циклічні залежності в Prototype можна вирішити анотацією @Lazy» — ні, взаємні цикли прототипів нерозв’язні.
  • ❌ «Prototype біни завжди працюють швидше за Singleton, тому що не блокуються потоками» — створення Prototype пов’язане з колосальним оверхедом BPP і рефлексії.
  • ❌ «Singleton бін безпечний для багатопотокової модифікації полів» — Singleton використовується всіма потоками одночасно і зобов’язаний бути потокобезпечним (stateless).

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