Skip to content

ThreadLocal and Virtual Threads in Java

Two topics come up again and again in Java interviews focused on concurrency: ThreadLocal for managing per thread state, and virtual threads for handling massive concurrency without drowning in OS resources. Both topics are practical, both have tricky pitfalls that trip up experienced developers, and both show up constantly in real production code. By the end of this article you will understand how each one works at a deep level, when to reach for each tool, and exactly what to say when an interviewer brings them up.


Part One: ThreadLocal

What Problem Does ThreadLocal Solve?

Imagine you are building a web server that handles many user requests at the same time. For every incoming request, you want to know which user sent it so that your business logic can make decisions based on their identity. You could pass the user information as a parameter through every method call in your application, but that quickly becomes a mess. Every method signature bloats up with an extra parameter that most methods do not even care about.

What you really want is a way to say: for the duration of this request, on this thread, the current user is Alice. Any code running on this thread should be able to ask "who is the current user?" and get Alice back, without me having to thread that information through every single method call.

That is precisely the problem ThreadLocal solves.

Each Thread Gets Its Own Copy

The core idea behind ThreadLocal is beautifully simple. When you declare a ThreadLocal<T> variable, every thread that touches that variable gets its own completely independent copy of the value stored inside it. Thread 1 setting its copy does not affect Thread 2's copy at all, even though both threads are using the exact same ThreadLocal object.

Think of it like a hotel with numbered lockers in the lobby. There is one locker number 42 in the building, but every guest gets their own version of locker 42 in their room. When you put something in locker 42, only you can retrieve it. The guest in the next room has their own locker 42 that is completely separate from yours.

Inside Java, every java.lang.Thread object carries a field called threadLocals, which is an instance of ThreadLocal.ThreadLocalMap. This is essentially a small hash map that belongs exclusively to that thread. When you call threadLocal.set(value), Java does the following steps automatically:

  1. It calls Thread.currentThread() to find out which thread is executing right now.
  2. It looks up that thread's internal ThreadLocalMap.
  3. It stores the value in that map, using the ThreadLocal object itself as the key.

When you call threadLocal.get(), Java reverses this process. It finds the current thread, looks up its private map, and retrieves the value stored under that ThreadLocal key. If no value has been set yet, it returns null by default (or whatever initialValue() returns if you override that method).

Using ThreadLocal: get, set, and remove

Here is a simple example showing all three core operations:

java
public class ThreadLocalDemo {

    // One ThreadLocal object shared across the whole application
    // But each thread gets its own independent value inside it
    private static final ThreadLocal<String> currentUser = new ThreadLocal<>();

    public static void main(String[] args) throws InterruptedException {

        Thread thread1 = new Thread(() -> {
            // Thread 1 stores its own value
            currentUser.set("Alice");
            System.out.println("Thread 1 sees: " + currentUser.get()); // Alice

            // Always clean up when done
            currentUser.remove();
        });

        Thread thread2 = new Thread(() -> {
            // Thread 2 stores a completely different value
            currentUser.set("Bob");
            System.out.println("Thread 2 sees: " + currentUser.get()); // Bob

            currentUser.remove();
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
    }
}

When Thread 1 prints its value, it sees "Alice". When Thread 2 prints its value, it sees "Bob". The two threads never interfere with each other even though they are using the exact same currentUser object. This is the power of ThreadLocal isolation.

The three methods you will use constantly are:

  • set(T value) stores a value for the current thread
  • get() retrieves the value the current thread stored
  • remove() deletes the current thread's value from the map

That remove() method deserves much more attention than most developers give it.

The Memory Leak and Data Pollution Problem with Thread Pools

Here is where ThreadLocal gets dangerous and where most interview questions focus. In a real application, you almost never create raw Thread objects. Instead, you use a thread pool via ExecutorService. The whole point of a thread pool is to reuse threads across many tasks so you avoid the overhead of creating and destroying threads constantly.

But this reuse creates a serious problem with ThreadLocal.

Imagine a thread pool with two threads, and your application receives three user requests back to back. Request 1 is from Alice. The thread pool assigns Thread 1 to handle it. Your request handling code calls currentUser.set("Alice"). The request finishes. Thread 1 is returned to the pool, ready to handle the next task.

Now Request 2 arrives, this time from Bob. The thread pool assigns Thread 1 to handle it again (it is available and ready). Your code calls currentUser.get() to find out who the current user is. What does it return?

It returns "Alice".

Thread 1 is still holding Alice's data from the previous request because nobody ever called remove(). Thread 1 does not know it is working on a different request now. Its ThreadLocalMap still has the old value sitting there. Bob's request is now running with Alice's identity. This is a data pollution bug, and in a security sensitive application it is a serious vulnerability.

The problem goes beyond incorrect data. Every ThreadLocal value that is never removed occupies memory. In a long running server application, this adds up into a genuine memory leak.

The fix is non negotiable: always call remove() in a finally block, so it runs no matter what happens (even if an exception is thrown):

java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SafeThreadLocalWithPool {

    private static final ThreadLocal<String> currentUser = new ThreadLocal<>();

    public static void handleRequest(String userId) {
        try {
            // Step 1: Set the thread's context at the start of the task
            currentUser.set(userId);

            // Step 2: Do the actual work, which can call other methods freely
            processRequest();

        } finally {
            // Step 3: MANDATORY cleanup before this thread returns to the pool
            // Without this, the next task on this thread inherits our data
            currentUser.remove();
        }
    }

    private static void processRequest() {
        // Any method can read the current user without needing it as a parameter
        String user = currentUser.get();
        System.out.println(Thread.currentThread().getName() + " is processing request for: " + user);
    }

    public static void main(String[] args) {
        // A pool of only 2 threads handling 5 requests
        ExecutorService pool = Executors.newFixedThreadPool(2);

        String[] users = {"Alice", "Bob", "Charlie", "Diana", "Eve"};

        for (String user : users) {
            pool.submit(() -> handleRequest(user));
        }

        pool.shutdown();
    }
}

Run this and you will see each request correctly identifies its own user. The finally block guarantees that remove() runs before the thread goes back to the pool, so the next request always starts with a clean slate.

The golden rule is this: if you use ThreadLocal in an application that uses thread pools, which is every real Java web application, you must call remove() in a finally block. Every time. No exceptions.

When to Actually Use ThreadLocal

ThreadLocal is the right choice when you need to attach contextual information to a thread for the duration of a task, and you want that information to be accessible anywhere in the call chain without passing it explicitly through every method signature.

The most common real world uses are:

  • Storing the current user's identity during an HTTP request (like Spring's SecurityContextHolder does internally)
  • Storing a database connection or transaction context so all code in one request uses the same connection
  • Storing a request ID for distributed tracing and logging
  • Storing a date format object because SimpleDateFormat is not thread safe and creating one per call is expensive

Part Two: Virtual Threads and Project Loom

The Limits of Traditional Threads

Before Java 21, every java.lang.Thread you created was what we now call a platform thread. A platform thread is simply a thin Java wrapper around an operating system thread. When you call new Thread(runnable).start(), the JVM asks the operating system to create a real OS level thread for you. This is a one to one relationship: one Java thread corresponds to exactly one OS kernel thread.

OS threads are powerful, but they are expensive in two specific ways that matter enormously at scale.

First, they consume a lot of memory. Each OS thread gets its own stack, and that stack is typically around one megabyte in size. If you create 10,000 threads, you are consuming roughly 10 gigabytes of memory just for stack space. In practice, most Java applications cap out between 2,000 and 5,000 threads before running into serious resource problems.

Second, they block the operating system when waiting for I/O. Consider a thread that sends a query to a database and waits for the result. While that thread is waiting, its underlying OS thread is sitting completely idle. The CPU cannot use that OS thread for anything else. In a typical web application, most of your threads are blocked on I/O (network calls, database queries, file reads) most of the time. You are paying for thousands of OS threads but most of them are doing absolutely nothing except waiting.

This is the fundamental scalability wall that traditional Java threading hits.

Introducing Virtual Threads: Java's Answer to the Scalability Wall

Virtual threads, introduced as a stable feature in Java 21 as part of Project Loom, completely rethink the relationship between Java threads and OS threads.

The key insight is this: instead of mapping every Java thread directly to an OS thread, the JVM manages its own internal scheduling layer. You still write normal Java thread code. You create threads, start them, and block on I/O the same way you always have. But underneath, the JVM is orchestrating everything differently.

Here is the model: the JVM maintains a small pool of actual OS threads, called carrier threads. The number of carrier threads is typically set to the number of CPU cores on your machine (though it is configurable). These carrier threads are the only real OS threads in the picture.

On top of those carrier threads, the JVM can create millions of virtual threads. A virtual thread is not an OS thread at all. It is a Java object that lives on the heap. It has its own stack, but that stack starts tiny (just a few hundred bytes) and grows and shrinks dynamically as needed. Creating a virtual thread costs essentially nothing compared to creating a platform thread.

How the Magic Works: Mounting and Unmounting

The really clever part of virtual threads is how they handle blocking I/O. This is where they deliver their performance promise.

When a virtual thread calls a blocking operation like reading from a socket, querying a database, or even calling Thread.sleep(), the JVM does something remarkable instead of blocking the carrier thread:

  1. The JVM captures the virtual thread's entire call stack and saves it in heap memory. This is called unmounting.
  2. The carrier thread is now free. The JVM immediately reassigns it to run a different virtual thread that has work to do.
  3. When the I/O operation completes (the OS signals that data is available), the JVM finds an available carrier thread and mounts the virtual thread back onto it. The virtual thread resumes execution exactly where it left off.

From the virtual thread's perspective, nothing unusual happened. It called a blocking method and waited for the result, just like always. The magic is completely transparent to the code running inside the virtual thread.

Visualize it this way. Imagine you have 4 CPU cores, so 4 carrier threads. You submit 10,000 requests to your server, each of which needs to make a database query that takes 100 milliseconds. With platform threads, you could handle maybe 500 concurrent requests before running out of OS thread resources. The other 9,500 requests would queue up waiting for a thread to become free.

With virtual threads, all 10,000 requests get their own virtual thread immediately. Each virtual thread starts running, hits the database call, and unmounts from its carrier thread. Now all 4 carrier threads are free to handle the next batch of virtual threads. As results come back from the database, virtual threads mount back onto carrier threads and process their results. The system keeps all 4 carrier threads busy the entire time, efficiently juggling 10,000 concurrent tasks without ever creating more than 4 OS threads.

Virtual Threads vs Platform Threads: The Side by Side Comparison

Understanding the differences clearly helps you know when to use each:

PLATFORM THREAD
    Created by:      JVM asking the OS to create an OS thread
    Managed by:      The operating system
    Memory cost:     Approximately 1 megabyte of stack per thread
    Creation cost:   Expensive (OS system call, takes time)
    Max practical:   A few thousand per JVM before resource exhaustion
    Blocking I/O:    OS thread sits completely idle while waiting
    Best for:        CPU intensive computation

VIRTUAL THREAD
    Created by:      JVM internally, no OS involvement
    Managed by:      JVM scheduler (ForkJoinPool underneath)
    Memory cost:     A few hundred bytes to start, grows dynamically
    Creation cost:   Nearly free (just allocating a Java object)
    Max practical:   Millions per JVM without issue
    Blocking I/O:    Unmounts from carrier thread, carrier handles other work
    Best for:        I/O intensive tasks (network, database, file operations)

How to Create Virtual Threads in Java 21

The API for virtual threads was designed to be as familiar as possible. You do not need to rewrite your business logic.

The first way is to create a single virtual thread directly:

java
public class SingleVirtualThread {
    public static void main(String[] args) throws InterruptedException {

        // Thread.ofVirtual() creates a builder for virtual threads
        // .start() launches it with the given Runnable
        Thread virtualThread = Thread.ofVirtual().start(() -> {
            System.out.println("Running on: " + Thread.currentThread());
            System.out.println("Is virtual: " + Thread.currentThread().isVirtual());
        });

        // Wait for it to finish, just like with a regular thread
        virtualThread.join();
    }
}

The isVirtual() method is new in Java 21 and returns true for virtual threads and false for platform threads.

The second and more common way is to use an ExecutorService backed by virtual threads:

java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class VirtualThreadExecutor {
    public static void main(String[] args) throws InterruptedException {

        // This executor creates a fresh virtual thread for EVERY submitted task
        // No pool limit. No queuing. Each task gets its own dedicated virtual thread.
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {

            // Submit 100,000 tasks. Each gets its own virtual thread instantly.
            for (int i = 1; i <= 100_000; i++) {
                final int taskId = i;
                executor.submit(() -> {
                    // Simulating a slow I/O operation
                    Thread.sleep(1000);
                    if (taskId % 10_000 == 0) {
                        System.out.println("Task " + taskId + " done on " + Thread.currentThread());
                    }
                    return taskId;
                });
            }

        } // try with resources automatically calls shutdown() and awaits completion

        System.out.println("All 100,000 tasks completed.");
    }
}

Notice that you are submitting 100,000 tasks that each sleep for one second. With a traditional fixed thread pool of say 200 threads, this would take around 500 seconds. With virtual threads, all 100,000 tasks run concurrently (limited only by I/O and carrier threads), so it completes in roughly one second.

You can also name your virtual threads for easier debugging:

java
Thread.ofVirtual()
      .name("requestHandler", 0)  // Names threads requestHandler0, requestHandler1, etc.
      .start(runnable);

What Virtual Threads Are NOT Good For

Virtual threads are specifically designed for I/O bound workloads. They are not a magic performance boost for every situation.

If your threads are doing heavy CPU computation (image processing, cryptography, machine learning inference, complex calculations), then virtual threads do not help you at all. A CPU intensive thread cannot be unmounted while waiting for I/O because it is never waiting for I/O. It is always actively using the CPU. In this case, you still need traditional platform threads matched to your CPU core count.

The rule is simple: if your threads spend most of their time waiting (for network, database, files), virtual threads are a massive win. If your threads spend most of their time computing, virtual threads are neither harmful nor helpful. Stick with a correctly sized platform thread pool for CPU bound work.

There is also a concept called thread pinning that can reduce virtual thread effectiveness. If a virtual thread holds a synchronized monitor lock and then blocks, the JVM cannot unmount it from its carrier thread. The carrier thread stays blocked as long as the virtual thread holds the lock and is waiting. This is called pinning. For code under your control, prefer ReentrantLock over synchronized when using virtual threads, since ReentrantLock allows proper unmounting. Java continues to improve this behavior in newer releases.


Interview Questions and Pitfalls

What is ThreadLocal and when would you use it?

ThreadLocal provides each thread with its own independent copy of a variable. You use it when you want thread scoped state that code anywhere in the call chain can access without parameter passing. The most common uses are storing current user context, database connections for a transaction, and per thread caches of non thread safe objects.

What happens if you don't call remove() in a thread pool application?

The thread returns to the pool carrying stale data from the previous task. The next task that runs on that thread calls get() and receives someone else's data. This causes data corruption, security vulnerabilities if the data is user related, and a memory leak because the heap objects referenced from the ThreadLocalMap are never garbage collected.

How does ThreadLocal work internally?

Every Thread object has a ThreadLocalMap field. When you call threadLocal.set(value), Java finds the current thread, accesses its map, and stores the value using the ThreadLocal instance as the key. When you call get(), it reverses this to retrieve the value. This design means each thread has a completely private storage area that other threads cannot access.

What is a platform thread?

A platform thread is a Java thread that maps one to one to an OS kernel thread. Creating one requires a system call to the operating system. Each consumes roughly one megabyte of stack memory. A JVM can sustain only a few thousand platform threads before exhausting system resources.

What are virtual threads and how do they differ from platform threads?

Virtual threads are JVM managed threads introduced in Java 21. They are plain Java objects stored on the heap, not backed by OS threads. The JVM schedules them onto a small pool of OS threads called carrier threads. When a virtual thread blocks on I/O, the JVM unmounts it from the carrier thread and reuses the carrier for another virtual thread. This allows millions of concurrent virtual threads with a fraction of the OS resources.

What is a carrier thread?

A carrier thread is a platform thread (OS thread) that the JVM uses to actually execute virtual threads. The JVM maintains a pool of carrier threads (typically one per CPU core). Virtual threads mount onto carrier threads to run and unmount when they block, freeing the carrier for other work.

Why can you create millions of virtual threads but only thousands of platform threads?

Platform threads each require one OS kernel thread plus about one megabyte of reserved stack space. Virtual threads are heap objects that start with just a few hundred bytes and grow only as needed. The JVM manages scheduling internally without involving the OS for each thread creation, making virtual thread creation nearly free.

When should you use virtual threads vs platform threads?

Use virtual threads for I/O bound workloads: HTTP servers handling many concurrent requests, database heavy applications, microservices making many network calls. Use platform threads for CPU bound workloads where threads are always computing. Mixing virtual threads with heavy synchronized blocks can cause pinning, reducing their effectiveness.

What is thread pinning?

Thread pinning happens when a virtual thread holds a synchronized monitor and then blocks on I/O. In this situation, the JVM cannot unmount the virtual thread, so the carrier thread stays blocked too. This reduces the parallelism benefit. Using ReentrantLock instead of synchronized avoids this issue.

Does switching from platform threads to virtual threads require rewriting application code?

No. Virtual threads implement the same Thread API. Your business logic does not change. You typically only change the executor or thread factory you use at the top level, for example replacing Executors.newFixedThreadPool(200) with Executors.newVirtualThreadPerTaskExecutor(). All the existing blocking code inside your tasks works correctly with virtual threads without modification.


Putting It All Together

ThreadLocal and virtual threads solve different but related problems in concurrent Java programming.

ThreadLocal is about data isolation: giving each thread its own private storage for contextual information without the need to pass that information explicitly through every method call. It is elegant when used correctly but dangerous if you forget to call remove() in thread pool environments.

Virtual threads are about scalability: allowing you to write simple, straightforward blocking code (the kind Java developers have always written) while the JVM handles the complexity of efficiently scheduling millions of concurrent tasks onto a small number of OS threads. They remove the artificial ceiling on concurrency that platform threads impose by tying Java threads to expensive OS resources.

The practical shift is that with virtual threads, the old advice "use non blocking async code to scale I/O" becomes much less necessary. You can write blocking code that reads naturally and reason about it linearly, and the JVM makes it scale. This is a fundamental improvement to Java's threading model and the reason virtual threads are one of the most significant additions in Java 21.

Both features reflect the same underlying principle: the JVM should absorb complexity so that your application code stays simple and readable.