QuickCart logs every order with a date, a time, and a request id. Priya’s workers run in a thread pool — the same ten threads handle thousands of orders, one after another. Some objects cannot be shared safely. Others are expensive to create on every call. ThreadLocal gives each worker its own private copy, like a sticky note that only they can read.
What ThreadLocal does
Normal shared variable:
private int requestCount = 0; // All threads see the same value
ThreadLocal variable:
private ThreadLocal<String> requestId = ThreadLocal.withInitial(() -> "none");
// Thread A sets "REQ-1" — only Thread A sees it
// Thread B sets "REQ-2" — only Thread B sees it
Each thread gets its own slot in a hidden map inside the thread. No lock needed. No thread stomps on another thread’s data.
ThreadLocal API — three ways to create one
Method 1: withInitial (recommended)
ThreadLocal<String> note = ThreadLocal.withInitial(() -> "empty");
String value = note.get(); // "empty" on first access
note.set("ORDER-42");
value = note.get(); // "ORDER-42" for this thread only
note.remove(); // Clear this thread's copy
Method 2: override initialValue()
Older style, same idea:
ThreadLocal<String> note = new ThreadLocal<String>() {
@Override
protected String initialValue() {
return "empty";
}
};
String value = note.get(); // "empty"
Method 3: no initial value
ThreadLocal<String> note = new ThreadLocal<>();
String value = note.get(); // null on first access
note.set("ORDER-42");
Always declare shared ThreadLocal fields as static final so every thread uses the same ThreadLocal object (which still gives each thread its own value):
private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();
The SimpleDateFormat trap
SimpleDateFormat formats dates like "2026-08-03". It is not thread-safe. If two threads share one instance, output can be wrong or the app can crash.
// DANGEROUS — do not do this in QuickCart
private SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd");
public String format(Date date) {
return formatter.format(date); // Race condition!
}
Fix option 1: create a new formatter every time. Safe, but slow if called often.
Fix option 2: one formatter per thread with ThreadLocal:
private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public String formatOrderDate(Date date) {
return DATE_FORMAT.get().format(date);
}
Each pool thread creates its formatter once, reuses it for every order it handles, and never shares it.
When QuickCart needs more than one date pattern, use one ThreadLocal per pattern:
private static final ThreadLocal<SimpleDateFormat> DISPLAY_FORMAT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("dd-MM-yyyy"));
private static final ThreadLocal<SimpleDateFormat> LOG_FORMAT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
public String formatForDisplay(Date date) {
return DISPLAY_FORMAT.get().format(date);
}
public String formatForLog(Date date) {
return LOG_FORMAT.get().format(date);
}
Modern alternative: DateTimeFormatter is thread-safe. For new QuickCart code, Priya prefers it:
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd");
public String formatOrderDate(LocalDate date) {
return date.format(FORMATTER);
}
Still, ThreadLocal with SimpleDateFormat shows up everywhere in real codebases — and in interviews. Learn it.
Request id on every order
When a customer places an order, Priya wants the same request id in every log line for that request — even deep inside helper methods. Passing the id through every method signature is tedious. ThreadLocal carries it silently for the current worker:
public class RequestContext {
private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();
public static void setRequestId(String id) {
REQUEST_ID.set(id);
}
public static String getRequestId() {
return REQUEST_ID.get();
}
public static void clear() {
REQUEST_ID.remove();
}
}
At the start of handling an order:
public void handleOrder(String orderId) {
String requestId = "REQ-" + orderId;
RequestContext.setRequestId(requestId);
try {
validateOrder(orderId);
chargePayment(orderId);
sendConfirmation(orderId);
} finally {
RequestContext.clear(); // Always clean up
}
}
private void chargePayment(String orderId) {
String id = RequestContext.getRequestId();
System.out.println("[" + id + "] Charging payment for " + orderId);
}
Any method on the same thread can call getRequestId() without extra parameters.
Richer request context with a map
When Priya needs more than one value per request — user id, session id, store id — she stores a map per thread:
public class RequestContext {
private static final ThreadLocal<Map<String, String>> CONTEXT =
ThreadLocal.withInitial(HashMap::new);
public static void put(String key, String value) {
CONTEXT.get().put(key, value);
}
public static String get(String key) {
return CONTEXT.get().get(key);
}
public static void clear() {
CONTEXT.remove();
}
}
// Usage
RequestContext.put("requestId", "REQ-42");
RequestContext.put("userId", "customer-991");
String id = RequestContext.get("requestId");
RequestContext.clear();
User context pattern
Same idea for logged-in customer info during a request:
public class UserContext {
private static final ThreadLocal<String> userId = new ThreadLocal<>();
private static final ThreadLocal<String> sessionId = new ThreadLocal<>();
public static void setUserId(String id) {
userId.set(id);
}
public static String getUserId() {
return userId.get();
}
public static void setSessionId(String id) {
sessionId.set(id);
}
public static String getSessionId() {
return sessionId.get();
}
public static void clear() {
userId.remove();
sessionId.remove();
}
}
Expensive objects — create once per thread
Some objects cost a lot to build. Creating one per call wastes time. ThreadLocal lets each thread build once and reuse:
public class ReportFormatter {
private static final ThreadLocal<ExpensiveFormatter> FORMATTER =
ThreadLocal.withInitial(() -> new ExpensiveFormatter());
public String format(String data) {
return FORMATTER.get().format(data);
}
}
Do not use ThreadLocal for cheap objects like plain strings. The overhead is not worth it.
Why you MUST call remove() in thread pools
Here is the part that bites teams in production.
In a thread pool, threads are reused. A thread finishes order A, then picks up order B. If you forget remove(), order A’s request id can still sit in that thread’s sticky note when order B starts.
That causes:
- Wrong data in logs (order B shows order A’s request id)
- Memory leaks (old objects stay attached to live threads)
How the memory leak happens
Each thread keeps a hidden ThreadLocalMap. When you call threadLocal.get() or set(), an entry is stored in that map. The entry holds a reference to your value. In a thread pool, the thread never dies — it goes back to the pool. The old entry stays forever unless you call remove().
private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
// Thread from pool uses ThreadLocal
executor.submit(() -> {
DATE_FORMAT.get(); // Creates entry in thread's ThreadLocalMap
// Thread returns to pool but entry remains!
});
Over thousands of requests, stale entries pile up. Memory grows. GC cannot collect objects still referenced from live pool threads.
Always clear in a finally block:
executor.submit(() -> {
RequestContext.setRequestId("REQ-99");
try {
doWork();
} finally {
RequestContext.remove(); // Required in pools
}
});
The same rule applies to SimpleDateFormat ThreadLocals and any other ThreadLocal in pooled threads.
Safe wrapper pattern
Priya wraps tasks so cleanup always runs:
public class SafeRequestRunner {
public static void runWithRequestId(String requestId, Runnable task) {
RequestContext.setRequestId(requestId);
try {
task.run();
} finally {
RequestContext.remove();
}
}
}
// Usage
executor.submit(() ->
SafeRequestRunner.runWithRequestId("REQ-100", () -> processOrder("ORDER-100"))
);
InheritableThreadLocal — child threads inherit the value
Normal ThreadLocal does not pass values to child threads. InheritableThreadLocal copies the parent’s value when a new child thread is created:
InheritableThreadLocal<String> parentNote = new InheritableThreadLocal<>();
parentNote.set("parent-value");
new Thread(() -> {
String value = parentNote.get(); // "parent-value" in the new child
}).start();
This only works when you create a fresh thread. It does not help with thread pools — pool threads already exist and do not re-inherit on each task. For pools, set context at the start of each task and remove() in finally.
Per-thread counter example
Each thread tracks its own local count — not shared across threads:
public class ThreadLocalCounter {
private final ThreadLocal<Integer> counter = ThreadLocal.withInitial(() -> 0);
public void increment() {
counter.set(counter.get() + 1);
}
public int getCount() {
return counter.get();
}
public void reset() {
counter.remove();
}
}
When to use ThreadLocal
Use it for:
- Non-thread-safe objects reused per thread (legacy
SimpleDateFormat) - Per-request context (request id, user id)
- Expensive objects you create once per thread
Do not use it for:
- Data that must be shared between threads (use
ConcurrentHashMapor similar) - Cheap throwaway objects (just create a new one)
- Already thread-safe types like
DateTimeFormatter(use them directly) - Passing values to another thread’s work (defeats the purpose)
What to remember
ThreadLocalgives each thread its own copy of a value.- Use
ThreadLocal.withInitial()for a default value on firstget(). - Never share one
SimpleDateFormatacross threads — use ThreadLocal orDateTimeFormatter. - Use ThreadLocal for per-request ids so you do not pass them through every method.
- In thread pools, always call
remove()in afinallyblock to avoid wrong data and memory leaks. InheritableThreadLocalpasses values to newly created child threads, not to pool threads.- Prefer
DateTimeFormatterfor new date formatting code — it is already thread-safe.
What Priya does next: sometimes threads must wait for each other — or limit how many can enter a room at once. She learns three meeting-point tools: CountDownLatch, CyclicBarrier, and Semaphore.