Spring Core & Boot
18/41
Bài 18 / 41~12 phútBean Lifecycle & ScopesLộ trình · chặng 13/21Miễn phí… lượt xem

Singleton Và Prototype Scope: Cạm Bẫy Scope Mismatch

So sánh Singleton và Prototype scope trong Spring, phân tích cạm bẫy scope mismatch khi tiêm prototype vào singleton và 3 giải pháp xử lý.

TL;DR: singleton (mặc định) nghĩa là Spring cache đúng 1 instance trong map singletonObjects của container để chia sẻ giữa mọi thread. Trái lại, prototype tạo instance mới mỗi khi gọi getBean() hoặc thực hiện dependency injection. Cạm bẫy nghiêm trọng nhất là scope mismatch: tiêm một prototype bean vào thuộc tính của singleton bean khiến Spring chỉ tiêm đúng 1 lần lúc startup, biến prototype thành singleton vĩnh viễn. Ba giải pháp chuẩn gồm: ObjectProvider<T> (khuyến nghị), @Lookup, hoặc chuyển sang scoped proxy.

Hiểu rõ hai phạm vi cốt lõi này giúp bạn tránh được những lỗi rò rỉ dữ liệu (state corruption) vô hình giữa các request của người dùng trong hệ thống thực tế.

1. Cơ chế Singleton trong Spring lưu trữ đối tượng ở đâu?

Trong DefaultListableBeanFactory, Spring quản lý các singleton bean thông qua một cấu trúc dữ liệu ConcurrentHashMap<String, Object> mang tên singletonObjects. Khái niệm singleton của Spring được định nghĩa là mỗi tên bean ánh xạ tới đúng một entry trong map này:

Java
// Kiem tra tham chieu cua singleton bean
var instanceA = context.getBean(OrderService.class);
var instanceB = context.getBean(OrderService.class);

System.out.println(instanceA == instanceB); // Luon tra ve true

Khác với GoF Singleton Pattern sử dụng biến static trên toàn ClassLoader của JVM, Singleton trong Spring chỉ có phạm vi duy nhất trong một ApplicationContext. Nếu bạn khởi tạo 2 context độc lập trong cùng một ứng dụng, mỗi context sẽ sở hữu một instance riêng biệt.

2. Vì sao Spring chọn Singleton làm phạm vi mặc định?

Spring chọn Singleton làm phạm vi mặc định vì đa số các Service và Repository trong ứng dụng doanh nghiệp là stateless (không duy trì trạng thái riêng biệt theo từng lời gọi):

Java
@Service
public class OrderService {
    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }

    public Order createOrder(OrderRequest request) {
        Order order = Order.from(request);
        return repository.save(order);
    }
}

Việc tái sử dụng một instance duy nhất mang lại hai lợi ích lớn:

  1. Tiết kiệm bộ nhớ: Không phải cấp phát hàng triệu đối tượng ngắn hạn trên heap cho mỗi request, giảm áp lực dọn rác (GC).
  2. Tăng tốc truy xuất: Container chỉ khởi tạo bean một lần lúc khởi động, các lần truy xuất sau đều đạt độ phức tạp $O(1)$ từ map.
Singleton không tự động đảm bảo Thread-Safety

Nếu bạn khai báo một biến mutable (như private int counter = 0;) trong singleton bean, nhiều thread đồng thời sẽ gây ra hiện tượng tranh chấp dữ liệu (Race Condition). Bạn bắt buộc phải đẩy trạng thái ra cơ sở dữ liệu bên ngoài hoặc dùng các kiểu dữ liệu an toàn như AtomicInteger.

3. Prototype Scope hoạt động ra sao và vì sao không được lưu cache?

Khi khai báo @Scope("prototype"), Spring đảo ngược hoàn toàn hành vi lưu trữ:

Hai cột đối chiếu: cột singleton gọi getBean lần 1 rồi đặt vào singletonObjects nên lần 2 tra map ra đúng instance A; cột prototype gọi ba lần ra ba instance khác nhau và container không giữ tham chiếu nào

Bên trong phương thức AbstractBeanFactory.doGetBean(), nhánh xử lý của prototype bỏ qua việc kiểm tra và ghi vào singletonObjects:

Java
// Trich doan logic AbstractBeanFactory
if (mbd.isSingleton()) {
    sharedInstance = getSingleton(beanName, () -> createBean(beanName, mbd, args));
    return adaptBeanInstance(name, sharedInstance, requiredType);
}

if (mbd.isPrototype()) {
    // Tao moi truc tiep va tra ve ngay, khong luu vao cache
    Object prototypeInstance = createBean(beanName, mbd, args);
    return adaptBeanInstance(name, prototypeInstance, requiredType);
}

Mỗi lần gọi getBean(), Spring khởi tạo một instance mới toanh và bàn giao ngay cho caller mà không giữ lại bất kỳ tham chiếu nào.

4. Cạm bẫy Scope Mismatch phát sinh như thế nào trong thực tế?

Lỗi phổ biến và nguy hiểm nhất xuất hiện khi ta tiêm trực tiếp một Prototype Bean vào một thuộc tính của Singleton Bean:

Java
@Component
@Scope("prototype")
public class ReportBuilder {
    private final List<String> lines = new ArrayList<>();
    public void addLine(String line) { lines.add(line); }
    public String build() { return String.join("\n", lines); }
}

@Service // Singleton bean mac dinh
public class ReportService {
    @Autowired
    private ReportBuilder builder; // Sai lam: chi tiem dung 1 lan luc startup

    public String generateReport(List<String> content) {
        content.forEach(builder::addLine);
        return builder.build();
    }
}

Hai pha thời gian đặt cạnh nhau: pha khởi động chạy đúng một lần, tạo ReportService rồi tạo một ReportBuilder B1 và tiêm vào field; pha chạy gọi generateReport nhiều lần nhưng lần nào cũng ghi vào chính B1 nên nội dung hai lần chồng lên nhau

Vì ReportService là singleton nên nó chỉ được khởi tạo một lần duy nhất lúc container khởi động. Tại thời điểm đó, Spring tiêm một instance ReportBuilder (gọi là B1) vào trường builder. Từ thời điểm đó trở đi, mọi request gửi tới generateReport() đều dùng chung instance B1 này. Kết quả là dữ liệu báo cáo của các request sau bị dính chồng chéo lên dữ liệu của các request trước.

Spring hoàn toàn im lặng, không đưa ra cảnh báo hay ngoại lệ nào lúc khởi động vì việc gán trường này hoàn toàn hợp lệ về mặt kỹ thuật.

5. Làm thế nào để khắc phục triệt để lỗi Scope Mismatch?

Có 3 phương pháp chính để giải quyết vấn đề này trong thực tế:

Giải pháp 1: Sử dụng ObjectProvider (Chuẩn khuyến nghị từ Spring 4.3+)

ObjectProvider<T> cung cấp một cơ chế lấy bean theo nhu cầu (on-demand) an toàn kiểu dữ liệu:

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

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

    public String generateReport(List<String> content) {
        // Lay instance prototype moi moi khi method nay duoc goi
        ReportBuilder builder = builderProvider.getObject();
        content.forEach(builder::addLine);
        return builder.build();
    }
}

Mỗi lần phương thức getObject() được gọi bên trong hàm xử lý, Spring Container sẽ thực hiện một lần lookup mới và trả về một instance ReportBuilder độc lập.

Giải pháp 2: Sử dụng Method Injection với @Lookup

Spring sử dụng CGLIB để sinh subclass và ghi đè một phương thức trừu tượng tại runtime:

Java
@Service
public abstract class ReportService {

    public String generateReport(List<String> content) {
        ReportBuilder builder = getReportBuilder(); // Goi method duoc Spring override
        content.forEach(builder::addLine);
        return builder.build();
    }

    @Lookup
    protected abstract ReportBuilder getReportBuilder();
}

Phương pháp này yêu cầu lớp hoặc phương thức không được mang từ khóa final để CGLIB có thể kế thừa và can thiệp.

Giải pháp 3: Sử dụng Scoped Proxy

Bằng cách cấu hình proxyMode = ScopedProxyMode.TARGET_CLASS trên định nghĩa bean:

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

Tuy nhiên, với prototype bean, scoped proxy khiến mỗi lần gọi phương thức trên proxy lại lấy một instance mới. Nếu bạn gọi builder.addLine() rồi gọi builder.build(), hai phương thức này sẽ chạy trên hai instance hoàn toàn khác nhau. Do đó, scoped proxy chỉ phù hợp cho Request hoặc Session scope.

6. Tại sao @PreDestroy không bao giờ hoạt động trên Prototype Bean?

Đối với các singleton bean, container duy trì danh sách các disposable beans để kích hoạt phương thức dọn dẹp khi ứng dụng dừng. Nhưng với prototype, Spring giao quyền sở hữu đối tượng hoàn toàn cho phía gọi:

Java
@Component
@Scope("prototype")
public class TempConnection {
    @PostConstruct
    public void init() { /* Chay binh thuong khi tao bean */ }

    @PreDestroy
    public void cleanup() { /* KHONG BAO GIO duoc Spring tu dong goi */ }
}

Nếu prototype bean chiếm dụng tài nguyên hệ thống (như socket, file handle), bạn bắt buộc phải hiện thực AutoCloseable và tự giải phóng tài nguyên trong khối try-with-resources.

7. Giải quyết bài toán rò rỉ dữ liệu PDF Report giữa các người dùng?

Giả sử hệ thống tạo tài liệu PDF xuất hiện lỗi: người dùng A tải báo cáo tài chính thì thấy xuất hiện một số trang tài liệu của người dùng B:

Java
// Giai phap xu ly triet de voi ObjectProvider
@Service
public class PdfExportService {
    private final ObjectProvider<PdfDocumentBuilder> documentProvider;

    public PdfExportService(ObjectProvider<PdfDocumentBuilder> documentProvider) {
        this.documentProvider = documentProvider;
    }

    public byte[] export(List<String> pages) {
        PdfDocumentBuilder builder = documentProvider.getObject(); // Moi request nhan 1 builder sach
        pages.forEach(builder::addPage);
        return builder.render();
    }
}

Việc gọi documentProvider.getObject() trực tiếp bên trong phương thức export() đảm bảo mỗi luồng xử lý nhận một instance builder riêng biệt, loại bỏ hoàn toàn nguy cơ nhiễm chéo dữ liệu.

Tóm tắt

  • Singleton là phạm vi mặc định của Spring, lưu trữ đối tượng duy nhất trong map singletonObjects để chia sẻ giữa các luồng.
  • Prototype tạo đối tượng mới cho mỗi lần yêu cầu; Spring không quản lý vòng đời hủy (@PreDestroy) của prototype bean.
  • Cạm bẫy Scope Mismatch xảy ra khi tiêm prototype bean vào thuộc tính của singleton bean, khiến prototype chỉ khởi tạo một lần duy nhất.
  • ObjectProvider<T>.getObject() là giải pháp chuẩn mực nhất để yêu cầu một instance prototype mới theo nhu cầu thực tế.

Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Sự khác biệt cốt lõi giữa Singleton trong Spring Container và GoF Singleton Pattern là gì?
  2. Q2
    Tại sao phương thức callback @PreDestroy không được Spring Container kích hoạt trên các Prototype Bean?
  3. Q3
    Hiện tượng gì xảy ra khi bạn tiêm trực tiếp một Prototype Bean vào trường của một Singleton Bean thông qua @Autowired?
  4. Q4
    Tại sao việc sử dụng ScopedProxyMode.TARGET_CLASS lại không phù hợp để sửa lỗi Scope Mismatch trên Prototype Bean có lưu trạng thái?
  5. Q5
    Giải pháp nào được khuyến nghị từ Spring 4.3+ để Singleton Bean có thể lấy Prototype Bean mới theo nhu cầu một cách an toàn nhất?

Bài tiếp theo: Request & Session Scope: Cơ Chế Scoped Proxy Cho Web

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

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Lộ trình Junior Java Backend Developer

Chặng 13/21 · Spring IoC, DI & Bean — bài 10/17 của chặng

Ôn phỏng vấn

Bài này trả lời được các câu phỏng vấn sau — tự trả lời thử trước khi mở đáp án.

Thảo luận & góp ý

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

Bài tiếp theo

Request & Session Scope: Cơ Chế Scoped Proxy Cho Web