Java Internals & Concurrency/ThreadLocal và ScopedValue: per-thread state không leak trên pool
10/75
Bài 10 / 75~14 phútConcurrency cơ bảnMiễn phí lượt xem

ThreadLocal và ScopedValue: per-thread state không leak trên pool

ThreadLocal cho mỗi thread một bản riêng — nhưng trên thread pool, quên remove() là rò cả dữ liệu lẫn bộ nhớ. Vì sao, và ScopedValue đóng lỗ hổng đó ra sao.

TL;DR: Đôi khi ta cần thứ vừa "toàn cục trong tầm với của code" vừa riêng cho mỗi thread — điều ad-hoc và stack confinement (giam trong phạm vi hẹp) không cho được. ThreadLocal là cơ chế chính thức: mỗi thread một bản riêng, lưu trong ThreadLocalMap của chính object Thread, nên get/set đơn luồng không cần đồng bộ hóa. Nhưng trên thread pool — nơi thread không bao giờ chết — quên gọi remove() là rò cả dữ liệu (request sau thấy ngữ cảnh request trước) lẫn bộ nhớ (value bị strong reference giữ vô hạn). Kỷ luật là remove() trong finally. ScopedValue (final Java 25, preview ở Java 21) đóng lỗ hổng đó bằng thiết kế: ràng giá trị vào một phạm vi where(...).run(...), immutable, tự unbound khi khối kết thúc — không có API để quên dọn.

Bài trước giam dữ liệu trong phạm vi hẹp: một thread, một stack frame. Nhưng có nhu cầu ngược: một giá trị cần với tới từ khắp nơi trong code (như biến toàn cục) nhưng vẫn riêng mỗi thread — correlation ID cho log, user đã xác thực, một SimpleDateFormat không thread-safe. Bài này đi nốt trục confinement: ThreadLocal, cái bẫy rò rỉ kinh điển của nó trên thread pool, và ScopedValue — phiên bản hiện đại vá đúng cái bẫy đó.

1. ThreadLocal: mỗi thread một ngăn tủ riêng

ThreadLocal là cơ chế chính thức cho per-thread state: hình dung nó như cái tủ nhiều ngăn giữa văn phòng, mỗi thread một ngăn mang tên mình, và thread A gọi get luôn nhận đúng giá trị chính A đã set.

Tủ nhiều ngănThreadLocal
Cái tủ giữa văn phòngobject ThreadLocal — điểm truy cập chung
Ngăn mang tên từng ngườientry trong ThreadLocalMap của từng Thread
Chìa khóa nhận diện chủ ngănThread.currentThread()
Đồ để quên trong ngăn khi nghỉ việcvalue chưa remove() khi thread quay về pool

Use case kinh điển là per-thread context: thứ không thread-safe nhưng tiện khi dùng như biến toàn cục suốt vòng đời của một thread. Ví dụ sách giáo khoa là SimpleDateFormat — thay vì khóa nó, ta phát cho mỗi thread một bản riêng:

public final class TicketDateFormat {
    private static final ThreadLocal<DateFormat> FORMAT =
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
    public static String format(Date date) {
        return FORMAT.get().format(date);   // moi thread dung SimpleDateFormat cua rieng no
    }
}

withInitial khai báo cách tạo giá trị ban đầu: lần đầu thread gọi get, lambda dựng riêng cho nó một SimpleDateFormat. Hai thread không bao giờ chạm cùng một formatter, nên cái không thread-safe trở nên an toàn mà không tốn khóa. Use case phổ biến khác trên server là giữ ngữ cảnh request — correlation ID cho log, user đã xác thực, hay một JDBC Connection (kết nối database, không thread-safe nên mỗi thread giữ một cái riêng) — cho tầng code sâu đọc mà không phải kéo qua từng tham số method.

2. ThreadLocal.get() tra cứu ở đâu, vì sao không cần đồng bộ hóa?

Trực giác dễ tưởng ThreadLocal là map từ thread sang giá trị, nằm trong object ThreadLocal. Thực tế ngược lại: dữ liệu nằm trong chính object Thread. Mỗi Thread mang field threadLocals kiểu ThreadLocal.ThreadLocalMap — hash map tí hon của riêng nó, key là các object ThreadLocal nó từng dùng. FORMAT.get() thực chất là mở threadLocals của Thread.currentThread() rồi tra bằng key FORMAT. Map đó chỉ thread chủ chạm tới, nên mọi get/set đều đơn luồng — không cần đồng bộ hóa. Dữ liệu đi theo thread.

Một chi tiết cài đặt quyết định câu chuyện rò rỉ: entry trong ThreadLocalMap giữ key qua WeakReference — tham chiếu yếu, không ngăn GC thu hồi object nó trỏ tới — còn value giữ qua strong reference. Nhờ vậy, khi object ThreadLocal không còn ai dùng, key bị GC thu và entry thành "stale entry", được dọn nhân tiện lúc thread gọi set/get/remove lần sau. Nhưng không gì bảo đảm việc dọn xảy ra, và value vẫn bị strong reference giữ chặt chừng nào entry còn trong map.

flowchart LR
    P["Thread pool"] --> T["Worker thread (song rat lau)"]
    T --> M["ThreadLocalMap"]
    M --> E["Entry"]
    E -.->|"weak ref (key)"| K["ThreadLocal"]
    E ==>|"strong ref (value)"| V["Value cua request cu"]
    style V fill:#FCA5A5
    style K fill:#6EE7B7

Thread còn sống thì map còn sống, entry chưa gỡ thì value còn bị trỏ tới — GC bó tay. Thread thường chết đi kéo map chết theo; thread trong pool không bao giờ chết.

3. Pitfall: ThreadLocal gặp thread pool

Thread trong pool tái sử dụng hết request này tới request khác. Xét filter gán user vào ThreadLocalquên dọn:

// SAI: set ma khong remove -- thread quay ve pool van om gia tri cu
public class AuthFilter {
    private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();
    public void doFilter(Request req, Chain chain) {
        CURRENT_USER.set(authenticate(req));   // gan user cua request nay
        chain.proceed(req);                    // tang sau doc CURRENT_USER.get()
        // thieu remove()
    }
}
💡 Thử đoán trước khi đọc tiếp

Request A chạy trên worker thread W, xác thực thành công, đặt user Alice vào CURRENT_USER, rồi kết thúc — W quay về pool. Request B đến, được pool giao đúng thread W, nhưng B đi qua một nhánh không gọi CURRENT_USER.set(...). Ở tầng sâu, B gọi CURRENT_USER.get() — nó trả về ai? Viết ra câu trả lời, và một hậu quả thứ hai (về bộ nhớ) trước khi đọc tiếp.

CURRENT_USER.get() của request B trả về Alice — user của request trước. Thread W quay về pool vẫn ôm nguyên entry cũ; B không set nên đọc trúng giá trị thừa. Đây là lỗi lẫn ngữ cảnh mang màu sắc bảo mật: B hành động dưới danh nghĩa Alice. Hậu quả thứ hai là bộ nhớ: mỗi worker găm một User cùng mọi thứ nó trỏ tới, sống vô hạn vì thread pool không chết — rò rỉ âm thầm, pool vài trăm thread thì tích tụ đáng kể.

Lời giải là kỷ luật remove — đặt gì vào ThreadLocal trong một tác vụ thì gỡ ra khi tác vụ kết thúc, an toàn nhất trong khối finally. Trước khi xem đáp án, tự điền vào chỗ /* ? */:

private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();
public void handle(Request req) {
    REQUEST_ID.set(req.id());
    try {
        process(req);                 // cac tang sau doc REQUEST_ID.get()
    } finally {
        /* ? -- gi o day de thread quay ve pool khong om ngu canh cu? */
    }
}

Chỗ trống là REQUEST_ID.remove();. finally bảo đảm việc gỡ xảy ra kể cả khi process ném exception. Bỏ qua remove không gây lỗi ngay — nó âm thầm tích tụ, và đó chính là điều khiến nó nguy hiểm. ThreadLocal mạnh về cô lập dữ liệu, nhưng đẩy sang ta trách nhiệm vòng đời: ai đặt thì người đó phải dọn.

4. InheritableThreadLocal — kế thừa một lần lúc tạo thread con

ThreadLocal cô lập triệt để: thread con khởi đầu với ngăn trống. InheritableThreadLocal là nhánh phụ cho khi ta muốn con vẫn gắn đúng ngữ cảnh của cha (ví dụ correlation ID): tại thời điểm thread con được tạo, nó sao chép giá trị từ InheritableThreadLocal của cha sang ngăn của mình — một lần chụp ảnh, và vì là sao chép tham chiếu nên chỉ an toàn với giá trị immutable.

Giới hạn nằm ở đúng chỗ ta hay cần nó nhất: kế thừa chỉ xảy ra khi một thread trực tiếp tạo thread khác. Với executor (worker dựng sẵn) không có "lúc tạo con" nào để chụp, nên ngữ cảnh không qua được ranh giới submit; với virtual thread tạo hàng loạt, sao chép cả bản đồ ngữ cảnh mỗi lần spawn là chi phí không nhỏ. Điểm gãy này đặt nền cho ScopedValue.

5. ScopedValue — ràng giá trị vào phạm vi, không vào thread

Version: ScopedValue preview ở Java 21, final ở Java 25

ScopedValuepreview trong Java 21 LTS mà khoá nhắm tới (JEP 446 — cần cờ --enable-preview), rồi qua ba vòng preview nữa (JEP 464/481/487) trước khi final ở Java 25 (JEP 506). Cú pháp dưới ổn định xuyên các vòng; nếu chạy trên Java 21, nhớ bật preview.

Thay vì gắn giá trị vào một thread rồi tin có ai đó sẽ dọn, ScopedValue ràng giá trị vào một phạm vi có biên giới rõ ràng, chỉ tồn tại trong lúc một khối code đang chạy. Nó immutable trong phạm vi ấy, không hề có set — khác biệt nền tảng so với ThreadLocal.

private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
public void handle(Request req) {
    ScopedValue.where(REQUEST_ID, req.id())
        .run(() -> process(req));    // REQUEST_ID.get() hop le o moi noi BEN TRONG khoi nay
    // ra khoi day, REQUEST_ID khong con bound -- khong can, va khong the, remove()
}

Giá trị bound đúng cho khoảng thời gian khối lambda chạy, kể cả các lời gọi sâu bên trong, rồi tự unbound khi run trả về. Không có bước dọn thủ công nên cũng không có khả năng quên dọn — cửa sổ rò rỉ mà ThreadLocal mở ra trên thread pool bị đóng kín bằng chính cấu trúc API. Bất biến cũng loại bỏ lớp lỗi tầng code sâu lén set lại ngữ cảnh: cần giá trị khác thì bind chồng một phạm vi con, giá trị cũ tự hiện lại khi khối con kết thúc.

Ở đây ScopedValue xuất hiện như công cụ confinement: mỗi phạm vi giam giá trị của nó, immutable và có vòng đời bao đóng. Câu chuyện lớn hơn — ngữ cảnh truyền xuống hàng loạt virtual thread mà không phải sao chép — thuộc về bài ScopedValue ở quy mô virtual thread, sau khi Structured Concurrency dựng bộ khung fork/join.

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

  • Confinement: ad-hoc và stack — hai nấc confinement trước; bài này đi nốt trục sang per-thread state toàn cục-trong-tầm-với.
  • Process và Thread — mỗi thread giữ riêng stack và ngữ cảnh thực thi; nền để hiểu vì sao dữ liệu ThreadLocal đi theo thread, nằm trong ThreadLocalMap của chính object Thread.
  • ScopedValue ở quy mô virtual thread — nơi ScopedValue phát huy trọn vẹn: ngữ cảnh kế thừa theo phạm vi xuống tác vụ con, ở quy mô virtual thread.
  • Immutability — vì sao "immutable" là chìa khóa khiến ScopedValue an toàn khi chia sẻ xuống nhiều tầng.

7. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

  • ThreadLocal Javadoc (Java 21) — contract chính thức, kể cả ghi chú về vòng đời giá trị gắn theo thread.
  • JEP 506 — Scoped Values (final, Java 25) — motivation section giải thích thẳng vì sao ThreadLocal không hợp với hàng triệu virtual thread; liệt kê lịch sử bốn vòng preview (JEP 446 ở Java 21).
  • Java Concurrency in Practice (Goetz et al.), §3.3 Thread Confinement — nguồn của trục ad-hoc → stack → ThreadLocal.

Ghi chú: JEP 506 đáng đọc nhất — phần "Motivation" phân tích súc tích những điểm gãy của ThreadLocal mà bài này đã đi qua, và ghi rõ mốc preview→final.

8. Tóm tắt

Cơ chếAi đứng ra bảo đảmMong manh ở đâu
ThreadLocalRuntime — ThreadLocalMap trong từng ThreadQuên remove() trên thread pool là rò dữ liệu lẫn bộ nhớ
InheritableThreadLocalRuntime — chụp ảnh lúc tạo thread conGãy ở thread pool (không có "lúc tạo con") và virtual thread; chỉ an toàn với immutable
ScopedValueAPI — bound theo phạm vi, immutable, tự dọnPhải cấu trúc code theo scope; preview ở Java 21, final Java 25
  • Dữ liệu ThreadLocal nằm trong ThreadLocalMap của chính Threadget/set không khóa; nhưng trên thread pool (thread không chết) quên remove() rò cả dữ liệu lẫn bộ nhớ (WeakReference chỉ giữ key, không cứu value). Kỷ luật: remove() trong finally.
  • ScopedValue đóng cửa sổ rò rỉ bằng thiết kế — phạm vi bao đóng + immutable + không có API để quên dọn. Là công cụ confinement ở đây; câu chuyện truyền ngữ cảnh ở quy mô virtual thread thuộc bài Structured Concurrency.

9. Tự kiểm tra

Tự kiểm tra
Q1
ThreadLocal.get() tra cứu dữ liệu ở đâu? Vì sao thao tác đó không cần một chút đồng bộ hóa nào?

Dữ liệu không nằm trong object ThreadLocal mà nằm trong chính object Thread: mỗi thread mang một field threadLocals kiểu ThreadLocal.ThreadLocalMap, trong đó key là các object ThreadLocal thread từng dùng, value là giá trị của riêng thread đó. get() thực chất là: lấy Thread.currentThread(), mở map của thread ấy, tra bằng key là chính object ThreadLocal.

Không cần đồng bộ hóa vì map đó chỉ đúng một thread — thread chủ — đọc và ghi. Không có truy cập từ thread thứ hai thì không có data race; đây là confinement theo đúng nghĩa đen, dữ liệu đi theo thread.

Q2
Giải thích chuỗi tham chiếu gây memory leak khi dùng ThreadLocal với thread pool. Key được giữ bằng WeakReference — vì sao điều đó vẫn không cứu được value?
Chuỗi giữ sống là: thread pool giữ worker thread, thread giữ ThreadLocalMap, map giữ entry, entry giữ value bằng strong reference. Thread trong pool không bao giờ chết nên cả chuỗi sống vô hạn — GC không thể thu hồi value dù request đã xong từ lâu. WeakReference chỉ áp cho key (object ThreadLocal): khi không ai trỏ tới ThreadLocal nữa, key bị thu và entry thành stale entry, được dọn nhân tiện khi thread gọi set/get/remove lần sau — nhưng không có gì bảo đảm việc dọn xảy ra, và value vẫn bị strong reference giữ chặt cho tới lúc đó. Cách duy nhất chắc chắn là tự gỡ: remove() trong finally.
Q3
Một request đi qua nhánh không gọi CURRENT_USER.set() nhưng ở tầng sâu vẫn đọc được một User. Vì sao, và đây là loại lỗi gì?

Vì thread thực thi request này được lấy ra từ pool và đã phục vụ một request trước đó có gọi set() nhưng không remove(). Entry trong ThreadLocalMap của thread vẫn còn nguyên khi thread quay về pool, nên request mới — chạy trên đúng thread đó — đọc trúng User của request cũ.

Đây là lỗi lẫn ngữ cảnh (context bleed) mang màu sắc bảo mật: code hành động dưới danh nghĩa một user không phải chủ của request hiện tại. Nó không ném exception, không xuất hiện trong test đơn luồng — chỉ lộ dưới tải khi thread bị tái dùng. Fix: remove() trong finally để mỗi tác vụ trả thread về pool ở trạng thái sạch.

Q4
Vì sao InheritableThreadLocal không truyền được ngữ cảnh qua thread pool, và vì sao mô hình của nó không hợp với virtual thread?
Sự kế thừa của InheritableThreadLocal chỉ xảy ra tại đúng một thời điểm: lúc thread con được tạo, giá trị của cha được chụp sang map của con. Với thread pool, các worker đã được dựng từ trước, độc lập với thread đang submit tác vụ — không tồn tại khoảnh khắc cha-tạo-con nào để chụp, nên ngữ cảnh không qua được ranh giới submit. Với virtual thread, vấn đề là quy mô: chúng được tạo hàng loạt với số lượng khổng lồ, và việc mỗi lần spawn lại sao chép nguyên một bản đồ ngữ cảnh vừa tốn kém vừa mơ hồ về vòng đời. Đó chính là khoảng trống mà ScopedValue được thiết kế để lấp.
Q5
ScopedValue đóng những lỗ hổng nào của ThreadLocal, và đóng bằng cơ chế gì? Nêu rõ trạng thái version của nó.

Ba lỗ hổng. Quên dọn: ThreadLocal cần remove() thủ công, còn ScopedValue ràng giá trị vào phạm vi where(...).run(...) tự unbound khi khối kết thúc — không có API để quên. Mutability không kiểm soát: ScopedValue immutable trong suốt phạm vi (cần giá trị khác thì bind chồng phạm vi con), loại lớp lỗi tầng sâu lén đổi ngữ cảnh. Chi phí kế thừa: tác vụ con kế thừa scoped value của cha rõ ràng và rẻ, không sao chép cả bản đồ ngữ cảnh mỗi lần spawn.

Version: preview ở Java 21 (JEP 446, cần --enable-preview), final ở Java 25 (JEP 506). Đổi lại, nó đòi cấu trúc code theo phạm vi.

Bài tiếp theo: Immutability — thread safety bằng cách không thay đổi

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.

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

Bài tiếp theo

Immutability: Thread safety bằng cách không thay đổi