Sticky Notes — ThreadLocal

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

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 ConcurrentHashMap or 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

  • ThreadLocal gives each thread its own copy of a value.
  • Use ThreadLocal.withInitial() for a default value on first get().
  • Never share one SimpleDateFormat across threads — use ThreadLocal or DateTimeFormatter.
  • Use ThreadLocal for per-request ids so you do not pass them through every method.
  • In thread pools, always call remove() in a finally block to avoid wrong data and memory leaks.
  • InheritableThreadLocal passes values to newly created child threads, not to pool threads.
  • Prefer DateTimeFormatter for 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.