ThreadLocal: dữ liệu riêng của từng thread

5 câu hỏi~25 phútTừ khóa: ThreadLocal, ThreadLocalMap, remove(), memory leak, MDC, TaskDecorator, InheritableThreadLocal, ScopedValue

ThreadLocal là gì? Có phải là vùng nhớ riêng của mỗi thread không?

Đúng, ThreadLocal chính là thứ bạn đang nhớ tới. Nhưng không nên hiểu đơn giản là "memory riêng của thread"; chính xác hơn là mỗi thread có một giá trị riêng ứng với cùng một biến ThreadLocal. Mình áp nó vào wallet-service cho dễ thấy.

1. Bài toán

Mọi thao tác tiền (deposit, transfer) phải ghi audit log gồm userId và traceId. Hai giá trị này đến từ header của request, nhưng chỗ cần dùng lại nằm tận tầng repository / audit:

Controller → WalletService → TransferService → AuditRepository
  (có header)                                    (cần userId, traceId)

2. Vấn đề

Cách thô là truyền tham số qua từng method:

transfer(from, to, amount, userId, traceId)
  → withdraw(from, amount, userId, traceId)
    → audit.save(..., userId, traceId)

Method nào cũng phình ra thêm 2 tham số không liên quan nghiệp vụ. Thêm một field context (tenantId, locale) là sửa hàng chục chữ ký. Dùng biến static chung thì càng sai: mọi request đè lên nhau, đúng lỗi race condition của bài trước.

3. Giải pháp: ThreadLocal

Trong Spring MVC truyền thống, mỗi request được xử lý trọn vẹn trên một thread của Tomcat. Vậy gắn context vào chính thread đó, ở đâu trong luồng xử lý cũng lấy ra được.

ThreadLocal<String> currentUser = new ThreadLocal<>();

Thread-1: currentUser.set("Alice") → currentUser.get() → "Alice"
Thread-2: currentUser.set("Bob")   → currentUser.get() → "Bob"

Code chỉ có một object ThreadLocal, nhưng mỗi thread nhìn thấy value của chính nó. Không ai đè ai, không cần lock.

4. Code từng bước

Bước 1: Holder

public final class RequestContext {
    private static final ThreadLocal<String> USER_ID = new ThreadLocal<>();

    public static void setUserId(String id) { USER_ID.set(id); }
    public static String userId()           { return USER_ID.get(); }
    public static void clear()              { USER_ID.remove(); }
}

Bước 2: Filter set ở đầu request, xóa ở cuối

@Component
public class RequestContextFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        String traceId = Optional.ofNullable(req.getHeader("X-Trace-Id"))
                                 .orElse(UUID.randomUUID().toString());
        try {
            RequestContext.setUserId(req.getHeader("X-User-Id"));
            MDC.put("traceId", traceId);          // MDC của SLF4J cũng là ThreadLocal
            chain.doFilter(req, res);
        } finally {
            RequestContext.clear();               // luôn remove trong finally
            MDC.clear();
        }
    }
}

Bước 3: Dùng ở tầng sâu, không cần tham số

@Service
public class AuditService {
    private static final Logger log = LoggerFactory.getLogger(AuditService.class);
    private final AuditRepository repo;
    public AuditService(AuditRepository repo) { this.repo = repo; }

    public void record(String action, Long walletId, BigDecimal amount) {
        repo.save(new AuditLog(action, walletId, amount, RequestContext.userId()));
        log.info("audit {} wallet={} amount={}", action, walletId, amount);
    }
}
// logback pattern: %d [%X{traceId}] %-5level %logger - %msg%n

Bước 4: Test bằng curl

curl -X POST "localhost:8080/wallets/1/deposit?amount=50" \
     -H "X-User-Id: u42" -H "X-Trace-Id: abc-123"

# log:  2026-10-14 10:00:01 [abc-123] INFO AuditService - audit DEPOSIT wallet=1 amount=50
# DB:   audit_log(action=DEPOSIT, wallet_id=1, user_id=u42)

5. Cách hoạt động

Giá trị không nằm trong object ThreadLocal, mà nằm trong chính thread. Mỗi Thread có một ThreadLocalMap; key là object ThreadLocal (weak reference), value là giá trị bạn set (strong reference):

Thread-1 (http-nio-exec-1)
└── ThreadLocalMap
    ├── RequestContext.USER_ID → "u42"
    └── MDC                    → {traceId=abc-123}
Thread-2 (http-nio-exec-2)
└── ThreadLocalMap
    └── RequestContext.USER_ID → "u7"

Và đây là lý do finally { remove() } bắt buộc: Tomcat dùng thread pool, thread không chết sau request mà được dùng lại.

Request A → exec-1: userId = u42 → request xong
            exec-1 KHÔNG chết, quay về pool
Request B → exec-1 AGAIN
            └── ThreadLocal: userId = u42   ← vẫn còn nếu không remove!

Quên remove() thì có hai hậu quả:

6. So sánh

Truyền tham sốBiến static chungThreadLocal
Thread-safeCóKhông, request đè nhauCó, mỗi thread một giá trị
Chữ ký methodPhình toGọnGọn
Dễ đọc / testRõ ràng nhất—Ẩn, phụ thuộc ngầm
Rủi roÍtSai dữ liệuQuên remove, mất khi đổi thread

7. Khi nào nên dùng / không nên dùng

Spring dùng ThreadLocal ở đâu?

Rất nhiều chỗ, và các chỗ đó đều quan trọng khi phỏng vấn:

Thành phầnLưu gì trong ThreadLocal
SecurityContextHolderUser đang đăng nhập (Authentication)
@Transactional (TransactionSynchronizationManager)Connection DB của transaction hiện tại
RequestContextHolderHttpServletRequest hiện tại
SLF4J MDCtraceId, userId để in vào mọi dòng log
LocaleContextHolderNgôn ngữ của request

Ví dụ trong wallet-service: nếu dùng Spring Security, RequestContext.userId() có thể thay bằng SecurityContextHolder.getContext().getAuthentication().getName(), cùng một cơ chế.

Nhờ @Transactional dùng ThreadLocal mà WalletRepository và AuditRepository gọi trong cùng method dùng chung một connection, nên trừ tiền và ghi audit commit cùng nhau. Đó cũng là lý do transaction không "theo" sang thread khác.

Mình gọi @Async thì trong đó SecurityContextHolder trả về null, log mất traceId. Tại sao? InheritableThreadLocal có giải quyết được không?

Bạn đoán đúng nguyên nhân nằm ở ThreadLocal. @Async và CompletableFuture.supplyAsync chạy code trên thread khác của pool; ThreadLocal gắn với thread cũ nên thread mới không thấy gì.

RequestContext.setUserId("u42");
CompletableFuture.runAsync(() ->
    log.info("user = {}", RequestContext.userId()));   // null

Còn InheritableThreadLocal thì không giải quyết được với pool. Nó copy giá trị từ thread cha sang thread con lúc thread con được tạo, mà pool tạo thread một lần rồi dùng lại:

Request 1 (u1) → pool tạo thread T → T thừa hưởng u1
Request 2 (u2) → dùng lại thread T → T vẫn mang u1   ← sai

Cách đúng là context propagation: chụp context ở thread gọi, gắn vào thread chạy, chạy xong thì xóa. Spring có TaskDecorator:

public class ContextCopyingDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable task) {
        String userId = RequestContext.userId();
        Map<String, String> mdc = MDC.getCopyOfContextMap();
        SecurityContext security = SecurityContextHolder.getContext();
        return () -> {
            try {
                RequestContext.setUserId(userId);
                if (mdc != null) MDC.setContextMap(mdc);
                SecurityContextHolder.setContext(security);
                task.run();
            } finally {
                RequestContext.clear();
                MDC.clear();
                SecurityContextHolder.clearContext();
            }
        };
    }
}

@Bean
ThreadPoolTaskExecutor taskExecutor() {
    var ex = new ThreadPoolTaskExecutor();
    ex.setTaskDecorator(new ContextCopyingDecorator());
    return ex;
}
http-exec-1 (u42, abc-123)
   │ decorate(): chụp context
   ▼
task-1: set context → chạy task → finally clear

Với CompletableFuture, truyền executor này vào: supplyAsync(task, taskExecutor), đừng dùng ForkJoinPool.commonPool() mặc định. Spring Boot 3 còn có Micrometer Context Propagation làm việc này tự động cho traceId. Riêng @Transactional thì không truyền sang thread khác; code async chạy transaction riêng.

Với virtual thread thì ThreadLocal có vấn đề gì không?

Vẫn chạy được, nhưng có hai điểm bất tiện: mỗi virtual thread có bản sao riêng nên hàng triệu thread là hàng triệu bản sao, và ThreadLocal sửa được bất cứ lúc nào, khó kiểm soát.

Java 21 giới thiệu ScopedValue (preview ở 21, chính thức từ Java 25): giá trị bất biến, chỉ tồn tại trong một phạm vi, ra khỏi phạm vi là tự mất, không cần remove().

static final ScopedValue<String> USER_ID = ScopedValue.newInstance();

ScopedValue.where(USER_ID, "u42").run(() -> {
    walletService.transfer(...);   // bên trong gọi USER_ID.get() → "u42"
});
// ra ngoài: USER_ID không còn giá trị

Kết hợp với StructuredTaskScope, các subtask con tự thấy giá trị của scope cha. Hiện tại code Spring vẫn chủ yếu dựa trên ThreadLocal, nhưng nên biết hướng đi này.

ThreadLocalScopedValue
Sửa giá trịĐược, bất cứ lúc nàoKhông, bất biến
Dọn dẹpPhải tự remove()Tự động khi hết scope
Truyền sang thread conKhông (trừ Inheritable)Có, qua StructuredTaskScope

Phỏng vấn hỏi ThreadLocal thì trả lời sao?

Q1. ThreadLocal là gì?

🇻🇳 Tiếng Việt:

ThreadLocal cho phép lưu dữ liệu theo từng thread. Các thread cùng truy cập một ThreadLocal nhưng mỗi thread có một value độc lập, nên dữ liệu không bị chia sẻ. Nó thường dùng cho context gắn với một request như user context, transaction context hoặc logging context; Spring dùng nó cho SecurityContextHolder, @Transactional, MDC.

🇬🇧 English:

ThreadLocal stores data per thread. Threads access the same ThreadLocal variable, but each thread sees its own independent value, so the data is not shared. It is commonly used for request-scoped context such as user, transaction or logging context; Spring uses it for SecurityContextHolder, @Transactional and MDC.

Q2. Vì sao phải gọi remove()?

🇻🇳 Tiếng Việt:

Vì server dùng thread pool, thread không chết sau request mà được tái sử dụng. Không remove() trong finally thì request sau có thể đọc nhầm context của request trước, và value không bao giờ được giải phóng, gây memory leak.

🇬🇧 English:

Because servers use thread pools: a thread does not die after a request, it is reused. If we don't call remove() in a finally block, the next request may read stale context from the previous one, and the value is never released, causing a memory leak.

Q3. Vì sao trong @Async mất SecurityContext, xử lý thế nào?

🇻🇳 Tiếng Việt:

Vì @Async chạy trên thread khác, mà context nằm trong ThreadLocal của thread gọi. Em dùng TaskDecorator để chụp context ở thread gọi, set vào thread chạy và clear sau khi xong. InheritableThreadLocal không hợp với pool vì chỉ copy lúc tạo thread. Từ Java 21 có ScopedValue bất biến, tự dọn, hợp với virtual thread hơn.

🇬🇧 English:

Because @Async runs on a different thread, while the context lives in the caller thread's ThreadLocal. I use a TaskDecorator to capture the context in the caller thread, set it in the worker thread and clear it afterwards. InheritableThreadLocal does not fit thread pools because it only copies values when a thread is created. Since Java 21 there is ScopedValue, which is immutable, cleans itself up and fits virtual threads better.

Cách nhớ ngắn gọn


Tự kiểm tra

Vì sao phải gọi remove() khi dùng ThreadLocal trong web app?

Thread được pool tái sử dụng; không xóa thì request sau đọc được dữ liệu cũ và value không bao giờ được giải phóng.

Giá trị của ThreadLocal thực sự nằm ở đâu?

Trong ThreadLocalMap của từng Thread; key là object ThreadLocal (weak reference), value là strong reference.

Vì sao trong @Async mất SecurityContext?

Code chạy trên thread khác, mà SecurityContext lưu trong ThreadLocal của thread gọi.

Vì sao InheritableThreadLocal không hợp với thread pool?

Nó chỉ copy khi tạo thread; pool dùng lại thread nên giá trị cũ bị mang sang request khác.

ScopedValue khác ThreadLocal ở điểm nào?

Bất biến, chỉ sống trong một scope và tự dọn khi ra khỏi scope.