Spring Core & Boot/Singleton vs Prototype scope — cơ chế cache và pitfall scope-mismatch
18/41
Bài 18 / 41~12 phútBean Lifecycle & ScopesMiễn phí lượt xem

Singleton vs Prototype scope — cơ chế cache và pitfall scope-mismatch

Singleton scope không phải singleton pattern — nó là một entry trong singletonObjects map của container. Prototype tạo instance mới mỗi lookup. Pitfall nguy hiểm nhất: inject prototype vào singleton field → chỉ tạo 1 lần, prototype semantics mất. Ba cách fix: ObjectProvider, @Lookup, scoped proxy.

TL;DR: singleton (default) = Spring cache 1 instance trong singletonObjects map của container — stateless service chia sẻ giữa mọi thread mà không cần sync. prototype = tạo instance mới mỗi lần getBean() / inject. Pitfall lớn nhất là scope-mismatch: inject prototype vào singleton field thường → Spring chỉ inject 1 lần lúc startup, sau đó singleton giữ mãi instance đó, prototype semantics biến mất. Ba cách fix chuẩn: ObjectProvider<T> (khuyến nghị), @Lookup, hoặc scoped proxy.

Spring có 6 scope built-in. Bài này đào sâu cơ chế bên dưới hai scope cốt lõi nhất: tại sao singleton là default, tại sao prototype không cache, và cơ chế cụ thể khiến scope-mismatch xảy ra + 3 cách fix. Request/session scope sẽ có bài 04 — Request/Session scope riêng.

1. Singleton — một entry trong singletonObjects map

Từ bài BeanFactory vs ApplicationContext, ta đã thấy DefaultListableBeanFactory giữ một ConcurrentHashMap<String, Object> tên singletonObjects. Singleton scope là định nghĩa thao tác trên map đó: mỗi tên bean ánh xạ tới đúng 1 entry.

flowchart TB
  GB["getBean(OrderService.class)"] --> Check{"singletonObjects<br/>co entry?"}
  Check -->|"CO"| Return["return instance da cache<br/>(O(1))"]
  Check -->|"CHUA"| Create["createBean:<br/>chon constructor<br/>resolve dependencies<br/>goi @PostConstruct"]
  Create --> Cache["dat vao singletonObjects[name]"]
  Cache --> Return

Lần đầu getBean: entry chưa có, container chạy createBean, cache kết quả vào map rồi trả về. Lần hai trở đi: tra map tìm thấy ngay, trả về cùng reference. Đây là định nghĩa chính xác của "singleton trong Spring": một entry trong một map của một container cụ thể — không phải singleton pattern static getInstance().

// Minh hoa: 2 lan getBean tra ve CUNG object
var a = ctx.getBean(OrderService.class);
var b = ctx.getBean(OrderService.class);
System.out.println(a == b);   // true -- cung 1 entry trong singletonObjects

1.1 Vì sao singleton là default

Spring chọn singleton vì 90% bean là stateless — không có field thay đổi sau init. Service chỉ chứa logic; repository chỉ wrap DB call; configuration chỉ chứa setting. Object stateless có thể share giữa nghìn thread đồng thời mà không cần synchronize.

@Service
public class OrderService {
    private final OrderRepository repo;      // dependency injected, khong doi sau init
    private final PaymentGateway payment;

    public OrderService(OrderRepository repo, PaymentGateway payment) {
        this.repo = repo;
        this.payment = payment;
    }

    public Order place(OrderRequest req) {   // method stateless -- khong co shared mutable state
        var order = Order.from(req);
        repo.save(order);
        payment.charge(order.total());
        return order;
    }
}

Lợi ích kép: tiết kiệm memory (1 instance thay vì N) và startup nhanh (tạo 1 lần, sau đó O(1) lookup). Đây là lý do container không cần tạo lại bean mỗi request — đó sẽ là overhead vô nghĩa cho stateless object.

Cảnh báo quan trọng: singleton không tự động thread-safe. Nếu bạn đặt field mutable:

@Service
public class BadService {
    private int requestCount = 0;   // mutable -- shared giua moi thread

    public void handle() {
        requestCount++;             // race condition: read + increment + write khong atomic
    }
}

Ba thread tăng requestCount đồng thời có thể chỉ cộng thêm 1 thay vì 3. Fix: AtomicInteger, hoặc synchronized, hoặc tốt nhất — đẩy state ra ngoài (vào DB, metric counter, Redis).

2. Prototype — tạo mới mỗi lookup, không cache

Prototype scope đảo ngược hoàn toàn hành vi map:

flowchart TB
  GB1["getBean(Workflow.class) -- lan 1"] --> C1["createBean: new instance A"]
  GB2["getBean(Workflow.class) -- lan 2"] --> C2["createBean: new instance B"]
  GB3["getBean(Workflow.class) -- lan 3"] --> C3["createBean: new instance C"]
  style C1 fill:#fef3c7
  style C2 fill:#fef3c7
  style C3 fill:#fef3c7

Mỗi getBean() tạo instance mới, không đặt vào singletonObjects. Container không track prototype instance sau khi giao cho caller — hệ quả trực tiếp: @PreDestroy không bao giờ chạy với prototype. Caller chịu trách nhiệm cleanup.

@Component
@Scope("prototype")
public class ReportBuilder {
    private final List<Section> sections = new ArrayList<>();

    public ReportBuilder add(Section s) { sections.add(s); return this; }
    public Report build() { return new Report(sections); }
}

Khai báo đúng: mỗi caller tạo một ReportBuilder riêng, state sections không rò rỉ sang caller khác.

2.1 Cơ chế bên dưới: vì sao prototype không cache

Source AbstractBeanFactory.doGetBean (rút gọn):

// File: AbstractBeanFactory.java
protected <T> T doGetBean(String name, ...) {
    // Buoc 1: kiem tra singleton cache
    Object sharedInstance = getSingleton(beanName);
    if (sharedInstance != null) {
        return adaptBeanInstance(name, sharedInstance, requiredType);  // tra cache
    }

    // Buoc 2: singleton hay prototype?
    if (mbd.isSingleton()) {
        // getSingleton(name, factory) -- tao 1 lan va dat vao singletonObjects
        sharedInstance = getSingleton(beanName, () -> createBean(beanName, mbd, args));
        return adaptBeanInstance(name, sharedInstance, requiredType);
    }

    if (mbd.isPrototype()) {
        // Khong co cache -- tao truc tiep, khong goi getSingleton
        Object prototypeInstance = createBean(beanName, mbd, args);
        return adaptBeanInstance(name, prototypeInstance, requiredType);  // tra thang
    }
    // ... other scopes
}

Prototype bỏ qua hoàn toàn bước tra/ghi singletonObjects. Đây là lý do prototype "mới mỗi lần" — không phải magic, chỉ là một nhánh if trong doGetBean không ghi cache.

3. Pitfall scope-mismatch — nguyên nhân gốc và 3 cách fix

Đây là bug phổ biến nhất của prototype scope. Tình huống: bạn có ReportService singleton cần dùng ReportBuilder prototype (stateful, mỗi report cần builder riêng):

@Service                                    // SINGLETON -- tao 1 lan
public class ReportService {
    @Autowired private ReportBuilder builder;   // PROTOTYPE -- nhung...

    public Report generateReport(List<Section> sections) {
        sections.forEach(builder::add);
        return builder.build();             // dung CUNG instance moi lan goi!
    }
}

Vì sao bug xảy ra: khi Spring khởi tạo ReportService (singleton), nó inject ReportBuilder một lần duy nhất. Field builder trỏ vào instance B1. Sau đó mọi call generateReport đều dùng B1. Kết quả: sections từ report lần trước tích lũy vào lần sau — logic sai, data corrupt.

sequenceDiagram
  participant Spring
  participant RS as ReportService (singleton)
  participant B1 as ReportBuilder B1

  Spring->>RS: khoi tao ReportService
  Spring->>B1: tao ReportBuilder (prototype, 1 lan)
  Spring->>RS: inject B1 vao field builder

  Note over RS,B1: Sau do, moi call generateReport dung B1 cu
  RS->>B1: sections.forEach(builder::add)
  RS->>B1: builder.build()
  RS->>B1: sections.forEach(builder::add) -- lan 2 tich luy tren B1!

Cơ chế chính xác: singleton được tạo đúng một lần trong singletonObjects. Dependency injection xảy ra trong createBean của singleton đó. Spring gọi getBean(ReportBuilder.class) tại thời điểm đó và inject kết quả. Sau này không có cơ chế nào gọi lại getBean cho field đó.

Fix 1 — ObjectProvider (khuyến nghị, Spring 4.3+)

ObjectProvider<T> là wrapper type-safe quanh getBean(). Mỗi lần gọi getObject() thực hiện một getBean() mới → tạo instance prototype mới.

@Service
public class ReportService {
    private final ObjectProvider<ReportBuilder> builderProvider;

    public ReportService(ObjectProvider<ReportBuilder> builderProvider) {
        this.builderProvider = builderProvider;
    }

    public Report generateReport(List<Section> sections) {
        ReportBuilder builder = builderProvider.getObject();   // instance moi moi lan
        sections.forEach(builder::add);
        return builder.build();
    }
}

Tại sao đây là cách chuẩn: ObjectProvider là API chính thức Spring, type-safe, hỗ trợ ifAvailable() / ifUnique() cho optional dependency. Inject thẳng vào constructor → dễ test (inject mock provider). Không cần annotation đặc biệt trên bean.

Fix 2 — @Lookup method injection

Spring override method abstract createX() tại runtime bằng CGLIB subclass, body của override là getBean(X.class):

@Service
public abstract class ReportService {
    public Report generateReport(List<Section> sections) {
        ReportBuilder builder = createBuilder();   // goi method duoc Spring override
        sections.forEach(builder::add);
        return builder.build();
    }

    @Lookup
    protected abstract ReportBuilder createBuilder();   // Spring CGLIB tao instance moi
}

Lưu ý kỹ thuật quan trọng: class phải abstract (Spring tạo subclass concrete). Không dùng final. Cú pháp cũ hơn ObjectProvider, ít gặp trong code mới nhưng vẫn valid.

Fix 3 — Scoped proxy (ít khuyến nghị cho prototype)

Khai báo proxy trên bean prototype:

@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ReportBuilder { ... }

@Service
public class ReportService {
    @Autowired private ReportBuilder builder;   // inject PROXY, khong phai instance that

    public Report generateReport(List<Section> sections) {
        // moi method call qua proxy -> proxy goi getBean() -> instance moi
        sections.forEach(builder::add);
        return builder.build();
    }
}

Cách này inject một CGLIB proxy vào field builder. Mỗi method call qua proxy thực hiện một getBean() mới, trả về instance prototype mới. Tuy nhiên có vấn đề về semantic: mỗi method call là instance khác nhau. Gọi builder.add(s1) trên instance B1, gọi builder.build() trên instance B2B2 không có s1, kết quả rỗng.

Scoped proxy phù hợp hơn cho request/session scope (mỗi method call trong cùng một request dùng cùng instance), không phải prototype. Dùng ObjectProvider thay thế.

So sánh 3 cách fix

CáchCơ chếKhi nào dùng
ObjectProvider<T>Gọi getBean() tường minh khi cầnKhuyến nghị. Rõ ràng, type-safe, testable
@LookupCGLIB override method trả getBean()Bean abstract, muốn ẩn provider trong method
Scoped proxyProxy delegate mỗi method callRequest/session scope — không phải prototype

4. @PreDestroy và prototype — cạm bẫy im lặng

Container không track destroy lifecycle của prototype. @PreDestroy sẽ không bao giờ chạy:

@Component
@Scope("prototype")
public class DatabaseCursor {
    private Connection conn;

    @PostConstruct
    public void init() { conn = openConnection(); }   // CHAY -- Spring tao instance

    @PreDestroy
    public void close() { conn.close(); }             // KHONG CHAY -- Spring khong track destroy
}

Nếu DatabaseCursor cần cleanup, caller phải chịu trách nhiệm:

public void processData() {
    DatabaseCursor cursor = cursorProvider.getObject();
    try {
        cursor.process();
    } finally {
        beanFactory.destroyBean(cursor);   // explicit destroy -- chay @PreDestroy
    }
}

Hoặc implement AutoCloseable và dùng try-with-resources (pattern sạch hơn). Đây là trade-off của prototype: linh hoạt tạo mới, nhưng caller phải quản lý lifecycle.

5. Cơ chế bên dưới — tại sao scope-mismatch im lặng mà không throw exception

Câu hỏi hợp lý: vì sao Spring không phát hiện và cảnh báo scope-mismatch lúc startup?

Spring ghi cảnh báo log level DEBUG khi phát hiện bean singleton inject bean prototype:

WARN DefaultListableBeanFactory - Bean 'reportService' of type ... 
depends on scoped bean 'reportBuilder' -- prototype injected as singleton dependency

Nhưng không throw exception vì Spring không thể biết ý định của bạn: bạn có thể chủ ý inject prototype như singleton (vd: "tôi chỉ cần 1 instance, tạo bằng prototype factory"). Spring không đủ context để phán xét.

Đây là lý do bug này khó phát hiện: code chạy không lỗi, chỉ sai logic khi state tích lũy qua nhiều call.

Liên hệ các bài khác

  • BeanFactory vs ApplicationContext: singletonObjects map nơi singleton sống — bài này cho thấy cơ chế getBean tra map đó; singleton scope chính là định nghĩa "một entry trong map đó".
  • Request/Session scope: hai web scope còn lại (request/session) và scoped proxy — phần còn lại của bức tranh scope.
  • Request/Session scope: scope web-tier dùng ThreadLocal và proxy — cùng cơ chế proxy như Fix 3 nhưng semantic đúng hơn với request/session.
  • Bean lifecycle: giai đoạn createBean mà bài này tham chiếu được mổ đầy đủ ở đó — @PostConstruct, @PreDestroy, BeanPostProcessor xử lý tại pha nào.

Tóm tắt

  • Singleton = 1 entry trong singletonObjects (ConcurrentHashMap) của container. Không phải static getInstance(). Scope singleton = "phạm vi một container".
  • Singleton là default vì 90% bean stateless — share an toàn giữa thread. Singleton không tự thread-safe: field mutable cần AtomicXxx / synchronized.
  • Prototype = không cachedoGetBean bỏ qua singletonObjects, gọi createBean trực tiếp mỗi lần.
  • @PreDestroy không chạy với prototype — container không track destroy. Caller cleanup qua destroyBean() hoặc AutoCloseable.
  • Scope-mismatch: inject prototype vào singleton field → Spring inject 1 lần lúc startup → field giữ mãi instance đó → prototype semantics mất.
  • Ba cách fix: ObjectProvider<T>.getObject() (chuẩn), @Lookup abstract method (legacy), scoped proxy (dành cho request/session scope, không phải prototype).
  • Spring cảnh báo scope-mismatch qua log DEBUG — không throw exception vì không đủ context phán xét ý định.

Tự kiểm tra

Tự kiểm tra
Q1
Định nghĩa chính xác "singleton scope trong Spring" là gì? Tại sao nó khác với singleton pattern (static getInstance()) trong GoF?

Singleton scope trong Spring nghĩa là một bean name ánh xạ tới đúng 1 instance trong singletonObjects map của một container cụ thể. Không phải "1 instance toàn JVM".

Singleton GoF dùng static field + getInstance() — đảm bảo 1 instance trên toàn ClassLoader / JVM. Spring không làm vậy: nếu bạn tạo 2 AnnotationConfigApplicationContext, mỗi context có singletonObjects map riêng → 2 instance của cùng bean class. Không có gì static trong cơ chế Spring.

Hệ quả thực tế: tạo 2 context trong cùng process (vô tình hoặc trong test) → 2 bộ singleton độc lập → state không đồng bộ. Mỗi process nên có 1 root context.

Q2
Đoạn code sau in gì? Giải thích theo cơ chế inject xảy ra lúc nào.
@Component
@Scope("prototype")
public class Counter {
  private int n = 0;
  public int next() { return ++n; }
}

@Service
public class App {
  @Autowired Counter c;

  public void run() {
      System.out.println(c.next());
      System.out.println(c.next());
      System.out.println(c.next());
  }
}

In 1, 2, 3không phải 1, 1, 1.

Counter là prototype, nó được inject vào singleton App một lần duy nhất lúc Spring khởi tạo App. Sau đó field c trỏ mãi vào instance đó. Mỗi next() tăng n trên cùng instance.

Đây chính là scope-mismatch: prototype inject vào singleton field thường → mất prototype semantics. Để in 1, 1, 1 (mỗi lần instance mới), cần ObjectProvider<Counter> và gọi provider.getObject().next() mỗi lần.

Q3
Trong source AbstractBeanFactory.doGetBean, sự khác biệt cụ thể giữa nhánh xử lý singleton và prototype là gì? Tại sao prototype không được đặt vào singletonObjects?

Nhánh singleton gọi getSingleton(beanName, factory) — method này tra singletonObjects trước, nếu chưa có thì gọi factory (tức createBean) rồi đặt kết quả vào singletonObjects trước khi trả về.

Nhánh prototype gọi createBean(beanName, mbd, args) trực tiếp rồi trả về ngay, không ghi cache. Không có lời gọi getSingleton, không ghi singletonObjects.

Lý do design: prototype tồn tại để tạo object mới mỗi lần — đặt vào cache sẽ vi phạm ngữ nghĩa đó. Ngoài ra, container không biết khi nào caller xong với instance → không thể biết khi nào xoá khỏi cache → memory leak nếu cache prototype. Đó cũng là lý do @PreDestroy không chạy.

Q4
Bạn có bean EmailTemplate (prototype) và NotificationService (singleton) inject EmailTemplate qua field. Scope-mismatch đang xảy ra. Hãy viết lại NotificationService dùng ObjectProvider để fix.

Trước (bug):

@Service
public class NotificationService {
  @Autowired EmailTemplate template;   // inject 1 lan, dung mai

  public void send(String msg) {
      template.setBody(msg);
      template.deliver();
  }
}

Sau (fix):

@Service
public class NotificationService {
  private final ObjectProvider<EmailTemplate> templateProvider;

  public NotificationService(ObjectProvider<EmailTemplate> templateProvider) {
      this.templateProvider = templateProvider;
  }

  public void send(String msg) {
      EmailTemplate template = templateProvider.getObject();  // instance moi
      template.setBody(msg);
      template.deliver();
  }
}

Điểm quan trọng: inject ObjectProvider qua constructor (không phải field injection) → dễ test bằng cách truyền mock provider. Gọi getObject() bên trong method — không phải trong constructor (nếu gọi trong constructor lại rơi vào bẫy cũ: 1 instance duy nhất).

Q5
Vì sao scoped proxy (proxyMode = TARGET_CLASS) không phù hợp để fix scope-mismatch khi bean là prototype, dù nó hoạt động đúng cho request scope?

Scoped proxy delegate mỗi method call về getBean() để lấy instance "hiện tại" của scope đó. Với request scope, "instance hiện tại" là 1 instance duy nhất cho request này — tất cả method call trong cùng request đều dùng chung instance → semantic đúng (accumulate state trong request).

Với prototype, mỗi getBean() tạo instance mới. Nghĩa là mỗi method call qua proxy dùng một instance khác nhau. Ví dụ: builder.add(section1) trên instance B1, builder.build() trên instance B2B2 không biết section1 nên trả về report rỗng. State không tích lũy được.

Vì vậy prototype cần caller kiểm soát thời điểm tạo instance (ObjectProvider.getObject() tường minh) — không phải proxy tự động. Proxy prototype chạy không lỗi nhưng logic sai.

Bài tiếp theo: Request/Session scope & scoped proxy

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên