ThreadLocal: dữ liệu riêng của từng thread
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ả:
- Lộ dữ liệu: request B (không có header) ghi audit dưới tên u42. Với thông tin phân quyền thì là lỗ hổng bảo mật.
- Memory leak: thread trong pool sống mãi nên value cũng sống mãi. Value lớn hoặc giữ ClassLoader thì redeploy app không giải phóng được. Key là weak nhưng value là strong, nên đừng trông GC; gọi
remove(), không phảiset(null).
6. So sánh
| Truyền tham số | Biến static chung | ThreadLocal | |
|---|---|---|---|
| Thread-safe | Có | Không, request đè nhau | Có, mỗi thread một giá trị |
| Chữ ký method | Phình to | Gọn | Gọn |
| Dễ đọc / test | Rõ ràng nhất | — | Ẩn, phụ thuộc ngầm |
| Rủi ro | Ít | Sai dữ liệu | Quên remove, mất khi đổi thread |
7. Khi nào nên dùng / không nên dùng
- Nên: context cắt ngang của một request (user, traceId, tenant, locale), object không thread-safe đắt để tạo (ví dụ
SimpleDateFormatcũ). - Không nên: thay cho tham số nghiệp vụ thật (amount, walletId); code reactive/WebFlux (một request nhảy nhiều thread); khi không kiểm soát được chỗ
remove(). - Luôn đi theo cặp:
setở điểm vào,removetrongfinallyở cùng chỗ đó.
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ần | Lưu gì trong ThreadLocal |
|---|---|
SecurityContextHolder | User đang đăng nhập (Authentication) |
@Transactional (TransactionSynchronizationManager) | Connection DB của transaction hiện tại |
RequestContextHolder | HttpServletRequest hiện tại |
SLF4J MDC | traceId, userId để in vào mọi dòng log |
LocaleContextHolder | Ngô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.
@Async, CompletableFuture) thì sao?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.
| ThreadLocal | ScopedValue | |
|---|---|---|
| Sửa giá trị | Được, bất cứ lúc nào | Không, bất biến |
| Dọn dẹp | Phải tự remove() | Tự động khi hết scope |
| Truyền sang thread con | Khô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:
ThreadLocalcho phép lưu dữ liệu theo từng thread. Các thread cùng truy cập mộtThreadLocalnhư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ó choSecurityContextHolder,@Transactional, MDC.
🇬🇧 English:
ThreadLocalstores data per thread. Threads access the sameThreadLocalvariable, 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 forSecurityContextHolder,@Transactionaland 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()trongfinallythì 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 afinallyblock, 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ì
@Asyncchạy trên thread khác, mà context nằm trong ThreadLocal của thread gọi. Em dùngTaskDecoratorđể chụp context ở thread gọi, set vào thread chạy và clear sau khi xong.InheritableThreadLocalkhông hợp với pool vì chỉ copy lúc tạo thread. Từ Java 21 cóScopedValuebất biến, tự dọn, hợp với virtual thread hơn.
🇬🇧 English:
Because
@Asyncruns on a different thread, while the context lives in the caller thread's ThreadLocal. I use aTaskDecoratorto capture the context in the caller thread, set it in the worker thread and clear it afterwards.InheritableThreadLocaldoes not fit thread pools because it only copies values when a thread is created. Since Java 21 there isScopedValue, which is immutable, cleans itself up and fits virtual threads better.
Cách nhớ ngắn gọn
- Một biến ThreadLocal, mỗi thread một value; value nằm trong
ThreadLocalMapcủa thread. - Thread pool reuse thread →
setở đầu,removetrongfinally. - Đổi thread là mất context →
TaskDecorator, không dùng InheritableThreadLocal với pool. - Java 21+:
ScopedValuebất biến, tự dọ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.