Have you ever been driven insane trying to resolve concurrency problems? Or attempted to work with a tangle of threads that closely resemble a kitten’s attack on knitting wool? Maybe torn your hair out troubleshooting ThreadLocal leaks?
One of the most exciting innovations in recent releases of Java is Project Loom. It’s designed to revolutionize the way we handle concurrency. So far, we’ve seen three modules: virtual threads, structured concurrency and scoped values. With these new tools, multithreaded applications don’t have to be problematic.
In this article, we’ll look closely at scoped values, which are much less bug-prone than ThreadLocals.
Should we migrate from ThreadLocals to scoped values? Why, when and how? Let’s try to answer those questions.
Introduction to Project Loom
Let’s take a brief look at the modules that currently (as of Java 26) make up Project Loom.
Virtual threads were first released as a preview in Java 19, and were included as a stable release in Java 21. Prior to this, every Java thread mapped directly to one operating system thread. This meant that the number of available threads was directly limited by the available threads on the platform. Virtual threads allow many Java threads to be mapped to the same operating system thread, with the sharing operation managed by the JVM. Threads are therefore very lightweight, and no longer need to be pooled. We can have as many threads as we like – millions, if we need them.
This has turned out to be very efficient in practice, with huge performance gains and in many cases memory reductions. You can learn more here: Java Virtual Threads: A Quick Introduction.
Structured Concurrency, first released as an incubator project in Java 19, is still in preview stage as of Java 26. It allows us to work with a group of multithreaded tasks as a single unit, simplifying the ability to have many tasks running concurrently. For more information, see: Structured Concurrency: Improved Reliability in Thread Management.
Scoped Values were introduced as an incubator project in Java 20, and achieved stable release status in Java 26. We’ll discuss these in depth a bit later.
Other improvements to multithreading are still in the development phase.
Advantages of Migrating to Scoped Values: ThreadLocal Leaks and More
ThreadLocal variables were introduced very early in the Java lifecycle, making it simpler to pass information within a thread, rather than including shared variables as a parameter in every relevant method call. Each thread has its own ThreadLocal map, holding a unique copy of the variables. This is often used where a thread is created to process each transaction in a multi-user environment. For example, the transaction ID can be set for the thread, and accessed anywhere within this thread.
In general, ThreadLocals work well, but they do come with some problems if not used extremely carefully:
- ThreadLocal leaks: ThreadLocal variables persist until a thread dies, unless they are explicitly deleted using their remove() method. When they’re used in thread pools, the thread won’t die, so there’s a danger of them remaining, and possibly growing, throughout the application’s lifetime. There may also be program bugs that prevent a thread from terminating. This can result in an obscure memory leak, which is fairly hard to troubleshoot.
- Mutability: ThreadLocals can be changed by any component within the thread. In a complex program, this can make it very hard to trace where the variable is mutated.
- Data Contamination: When used in thread pools, if the developer is not conscientious about calling the remove() method before returning the thread to the pool, data will be carried over to the next thread request.
- Inefficient Inheritance: For ThreadLocals to be inherited by child threads, they have to be created from the InheritableThreadLocal class. This causes the entire thread map to be inherited by every child thread. This can prove very memory-hungry where there are a huge number of threads.
- Known Source of ClassLoader Leaks: In web servers such as Tomcat, if ThreadLocals aren’t removed when an application is redeployed, they can prevent the old version’s class loaders from being unloaded.
What Are Scoped Values and How Do They Work?
Scoped values were developed with three differences in mind when compared to ThreadLocals:
- They should be immutable: once assigned a value, the value can’t be changed.
- They should be inheritable.
- They should have a fixed lifespan.
These design goals eliminated the problems with ThreadLocals that we mentioned earlier.
They are designed to work well with virtual threads and structured concurrency. They are not designed to work with thread pools. Thread pools are not needed when using lightweight virtual threads: a new thread can be created for every task with no loss of performance.
Scoped values are immutable. They’re attached to a scope, and cannot be changed. However, they can be rebound to a different value for an inner scope. Take this logic:
set VAR1 to A for scope { Print VAR1 // This will print ‘A’ set VAR1 to B for scope { Print VAR1 // This will print ‘B’ because it’s within the inner scope } Print VAR1 // This will print ‘A’ because it is within the outer, not the inner scope. }Print VAR1 // This will throw an error because VAR1 is not set
It’s worth noting that scoped values can only be inherited by a child thread if they’re being used together with structured concurrency.
The Java documentation recommends that scoped values should be used instead of ThreadLocals wherever you want to pass data one way without using repetitive method parameters.
Scoped values hold a unique value by scope, rather than by thread. The scope, often coded as a lambda, is attached to an explicit value of the scoped value. This value can be accessed within the scope. It follows that while the scope is still valid, any methods it calls are able to access the contents of the variable, even if they belong to a completely different class. To understand this fully, think of what’s happening in the stack. If the scope attached to the variable is still on the stack, the variable is available. Once the code in this scope completes, however, it’s popped off the stack, and the contents of the variable are no longer available.
Take a scope defined as follows:
{method1();SomeComponent myComponent = new SomeComponent();myComponent.doSomeWork();}
If a scoped value was assigned to this scope, it would be available:
- Anywhere within method1, and any methods it calls;
- Anywhere within myComponent.doSomeWork(), and any method it calls.
It would not be visible anywhere else in the application, even within the current thread.
Let’s see how we would use it.
Scoped values are almost always declared as static variables in a class that is easily accessible by all methods, and the components are referenced within the scope. If we want it to be accessible from multiple classes, it makes sense to declare it in a central class used for defining variables.
// Class for storing shared data eg ScopedValues// ---------------------------------------------import java.lang.ScopedValue;public class SharedValues { // Static scoped value static final ScopedValue<Integer> TRANID = ScopedValue.newInstance(); }
If the values will only be accessed within a single class, of course it would make sense to define it in a static variable within the same class.
Methods are included in the ScopedValue class to run or call a scope with a specific value. This is the only way to assign a value to the ScopedValue variable.
Let’s look at this code:
// Bind TRANID and run scoped logic ScopedValue.where(SharedValues.TRANID, 1234567).run(() -> { // Print scoped value System.out.println("Creating request processor for "+ SharedValues.TRANID.get()); // Create RequestProcessor RequestProcessor processor = new RequestProcessor(userRequestDetails); processor.process(); });
Breaking this down:
- where is a static method of the ScopedValue class. It takes two parameters: the name of the ScopedValue to be used, and the value it must be set to. It returns an instance of the class ScopedValue.Carrier, which is an object containing key/value pairs for scoped values.
- The ScopedValue.Carrier class has methods named run and call, which expect a runnable or a callable respectively as their parameters. The contents of the parameter to this method define what is included in the scope.
- Many developers find it convenient to define the scope as a lambda, as shown in this example.
- The code within the lambda is executed with the ScopedValue set as per the where method. In this case, it’s set to 1234567, but it could equally be set to the contents of another variable.
- This value is available anywhere within the lambda, as seen in the println command, or anywhere within the methods it calls.
This syntax applies to Java 26 onwards. In some earlier Java versions, there was no ScopedValue.Carrier, and the scope was invoked like this:
ScopedValue.runWhere(SharedValues.TRANID, 1234567,() -> {
The runnable object, in this case the lambda, formed the third parameter to the method.
Unlike ThreadLocals, the value of the scoped value can neither be removed nor set: these actions are irrelevant.It’s possible to nest the where method in order to set more that one scoped value for the scope, like this:
ScopedValue.where(variable1, value1).where(variable2, value2).run(() -> { ... });
It’s usually better, if more than one scoped value is needed, to group them into a single record, since the above practice can result in large mappings.
To obtain the value of the scoped variable, we would use its get method:
int localID= SharedValues.TRANID.get();
Other useful methods of the ScopedValue class include:
- orElse(default_value): This either returns the contents of the ScopedValue, or the default value if the ScopedValue hasn’t been bound in the current scope.
- isBound() returns true if the value has been bound in the current scope, or false if it has not.
Let’s summarize the difference between ThreadLocals and scoped values.
| ThreadLocal | ScopedValue | |
| Mutable | Yes | No |
| Lifespan | While thread is alive and remove() has not been called | While scope is still active on the stack |
| Inheritance | InheritableThreadValue variables are inherited by child threads | Can be inheritable by child threads if used with Structured Concurrency |
| Memory Footprint | Fairly high | Low |
For more information about scoped values, it’s worth watching From ThreadLocal to ScopedValue with Loom Full Tutorial on the official Java Youtube channel. Note that this video was created when scoped values were first released as a preview in Java 20, so a few things may have changed before the final release.
How do scoped values improve application performance in high-concurrency environments?
For those of you who like to know what goes on in the JVM’s innards, here’s a brief description of how scoped values are stored compared to ThreadLocals.
ThreadLocals store a global map of threads and any ThreadLocal variables they use. We can visualize it like this:

Fig: ThreadLocal Map
The keys and values are stored in a hash table-like structure with an entry for each thread. To retrieve the value of a ThreadLocal, the JVM uses the thread ID as an index to pick up the correct table. It then uses the variable name as a key to pick up its corresponding value.
On the other hand, scoped variables are stored in a stack structure, which we can visualize like this:

Fig: Scoped Value Frames
Whenever a value is defined for a scope, the JVM pushes a frame onto the top of the stack. When the scope completes, the frame is popped from the stack.
Each frame contains a bitmap that indicates which variables are set within this frame, followed by keys with pointers to the actual variable in the heap. Note that only those variables set for this scope in a WHERE clause are included in the frame.
To retrieve the value of the scoped value, the JVM starts with the topmost frame. It checks the bitmap to see if the variable is contained in the frame. If so, it retrieves and returns it. If not, it goes to the next frame down and repeats the process until all frames are checked. If it’s not found in any, it throws an error.
Scoped values occupy less space and can be retrieved much faster, as the stack is unlikely to have many levels. ThreadLocals need some overhead for hashing, and sometimes it may be necessary to rehash them.
This improvement can make a huge performance difference in high-concurrency applications.
Scoped Values: Sample Program
This sample program simulates an application that processes transactions. Each transaction is handled in a separate thread. It uses virtual threads, and communicates between components via a scoped value for the transaction ID.
It contains the following classes:
- ScopedValueDemo is the main program. It instantiates itself, and then loops to accept simulated transactions and process them. For each transaction, it generates a unique ID. It then submits a lambda to an executor service, which creates a virtual thread for it. This lambda defines the scope, and the where clause binds a scoped value to it. Within the scope, a RequestProcessor object is created and its method invoked.
- SharedValues simulates a central class where static variables can be accessed globally.
- UserRequestDetails stores details of the request.
- IDGenerator generates unique transaction IDs.
RequestProcessor simulates processing the request.
import java.util.concurrent.Executors;import java.util.concurrent.ExecutorService;import java.util.concurrent.TimeUnit;import javax.swing.JOptionPane;public class ScopedValueDemo { ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); public static void main(String[] args) { ScopedValueDemo me = new ScopedValueDemo(); } public ScopedValueDemo() {//Loop requesting data from the user, and handling it as a request String name=""; while(!name.equals("End")) { name = JOptionPane.showInputDialog ("Enter name or 'End' to terminate"); if(!name.equals("End")) { handleRequest(name); } }// Shut down the executor service when done, waiting 60 seconds // for any threads to terminate executor.shutdown(); try { executor.awaitTermination(60, TimeUnit.SECONDS); } catch(Exception e) { System.out.println(e.toString());} } // Method that processes UserRequestDetails public void handleRequest(String userName) { // Generate transaction ID int generatedTranID = IDGenerator.generateID(); UserRequestDetails request = new UserRequestDetails(userName,generatedTranID); // Request the executor service to start a virtual thread executor.submit(() -> { try { // Bind TRANID and run scoped logic ScopedValue.where(SharedValues.REQUEST_CONTEXT, request) .run(() -> { // Print scoped value System.out.println("Creating request processor for " +SharedValues.REQUEST_CONTEXT.get().getTranID()); // Create RequestProcessor RequestProcessor processor = new RequestProcessor(); processor.process(); }); } catch(Exception e) { System.out.println(e.toString()); e.printStackTrace(); } }); }}// ***********************************************// --- Supporting classes ---// Class for storing shared data eg ScopedValues// ---------------------------------------------class SharedValues { // Static scoped value static final ScopedValue<UserRequestDetails> REQUEST_CONTEXT = ScopedValue.newInstance(); }// Store details of request// ----------------------------------------------class UserRequestDetails { private final String userName; private int tranID; public UserRequestDetails(String userName, int tranID) { this.userName = userName; this.tranID = tranID; } public String getUserName() { return userName; } public int getTranID() { return tranID; }}// Generate ID numbers// -------------------class IDGenerator { public static int generateID() { System.out.println("Generating ID"); return (int) (Math.random() * 10000); }}// Class that simulates processing the request// -------------------------------------------class RequestProcessor { private final UserRequestDetails details; public RequestProcessor() { this.details = SharedValues.REQUEST_CONTEXT.get(); System.out.println("In RequestProcessor Constructor" +details.getTranID() + " " +details.getUserName()); } public void process() { System.out.println("Processing request for user: " + details.getUserName() + " with TRANID: " + details.getTranID() + " on thread: " + Thread.currentThread()); }}
Migrating to Scoped Values: Solve ThreadLocal Leaks Permanently
Using scoped values in preference to ThreadLocals is a good choice when developing new applications. They’re more robust, and may be lighter on memory. Should we migrate existing applications to scoped values? There’s no one-size-fits-all answer to that.
The JDK documentation clearly tells us that migration is not compulsory. ThreadLocals are not deprecated and they won’t be discontinued. In some cases, migration can be a big job, and may introduce new bugs. We first need to ask ourselves a few questions so we can decide if it’s really necessary to make the changes.
- How stable is our existing system? Some systems just run beautifully for years, with no troubleshooting needed. If that’s the case, migration may not be a good thing, since the main advantage of changing to scoped values is to reduce instability.
- Do we need the shared variables to be mutable? Why? Would it be a big design change to use immutable values instead? If so, scoped values may not be the answer.
- Would we get significant performance gains by using virtual threads? If so, it may be worth introducing scoped values at the same time as we migrate to virtual threads.
- Would we get greater stability by moving to structured concurrency? If so, it’s worth implementing scoped values as well.
- If we’re experiencing known ThreadLocal issues, such as ThreadLocal leaks, obscure mutations, data contamination or class loader leaks, we should definitely consider migrating.
Let’s now look at how we could tackle the migration.
- Make sure you fully understand the difference between the scope and lifespans of the two types of variables.
- ThreadLocal variables, once set within a thread, remain available for the lifespan of the thread, unless they are removed.
- InheritableThreadLocals are inherited by any child threads.
- ScopedValues are explicitly set for a scope, which may either be a Runnable or a Callable. The scope is often defined as a lambda. They remain available as long as this scope is still on the stack. ScopedValues cannot be removed.
- ScopedValues are inherited by child threads when used together with structured concurrency.
- ThreadLocals are mutable via a set method. ScopedValues are immutable.
- Upgrade to a version of Java where ScopedValues are stable. Java 26 is a good choice. Not only is it a stable release, but also performance improvements were introduced between versions 25 and 26. Lookup efficiency was improved, and also virtual thread performance. Enhancements were made to structured concurrency. Since one of the reasons for migrating to scoped values is to take advantage of the stability of structured concurrency, it makes sense to use Java 26.
- Migrate thread pools to virtual threads before attempting to implement scoped values. If your application would benefit by structured concurrency, migrate to this as well.
- Identify any ThreadLocals within the application using either an IDE tool or grep.
- Examine each to fully understand its scope and lifespan. In modern frameworks, filters and receptors are often used to set context, which may involve setting ThreadLocals. Make sure you are able to trace which thread will be affected.
- Plan how the scope of the scoped value can be coded to exactly retain this same scope and lifespan. Define the scope so that the entire activity of the thread falls within it.
- Check whether the set or remove methods of the ThreadLocal are ever called. If so, take these into account when planning.
- Many third party libraries and frameworks use ThreadLocals internally. For example, logging programs may require a context to be set, so that relevant information is available to the logger. Spring frameworks may also use ThreadLocals internally. Be careful to:
- Set up any context for the library at the beginning of the scope;
- Make sure any calls to the library fall within the scope.
- For large systems, try migrating a small task first, and test it thoroughly.
- Implement robust testing before deploying in production.
Let’s see how this might work in practice. Here’s a code snippet from an application that deals with requests to upload a file, and starts a new thread to do the actual work.
Using ThreadLocals, it looks like this:
import java.util.UUID;import org.apache.logging.log4j.ThreadContext; // Log4j2 MDC public class FileUploadService { // ThreadLocal to hold request context // File request is a custom class storing a unique ID, // a user name and a URL private static final ThreadLocal<FileRequest> REQUEST_CONTEXT = new ThreadLocal<>(); // Simulated HTTP request handler public void handleHttpRequest(String username, String fileUrl) { // Create request context object FileRequest request = new FileRequest( UUID.randomUUID().toString(), // Generate a random ID username, fileUrl ); // Store in ThreadLocal REQUEST_CONTEXT.set(request); // Spawn worker thread Thread thread = new Thread(() -> { try { // Retrieve context inside the new thread FileRequest reqContext = REQUEST_CONTEXT.get(); if (reqContext == null) { throw new IllegalStateException ("No ThreadLocal context available"); } // Set up Log4j MDC // ThreadContext is a class belonging to Log4j ThreadContext.put ("requestId", reqContext.getRequestId()); ThreadContext.put ("username", reqContext.getUsername()); // Process request // This is a custom class that does the actual work FileRequestHandler handler = new FileRequestHandler(); handler.uploadFile(); } finally { // Clean up MDC and ThreadLocal ThreadContext.clearAll(); REQUEST_CONTEXT.remove(); } }); thread.start(); // Clean up in parent thread REQUEST_CONTEXT.remove(); }}
Note that it uses the Log4j class ThreadContext. This means we must take extra care when migrating: the scope must exactly match the lifespan of the original ThreadLocal.
To migrate this snippet, we first identify any usage of ThreadLocal. Next, we carefully analyze the lifespan of this variable, taking into account any set or remove methods. We position the scope for the ScopedValue to exactly match this lifespan. While we’re migrating, it’s worth changing the thread to a virtual thread to gain better performance.
The snippet below is the migrated version, with comments showing exactly where it has been changed.
import java.util.UUID;import org.apache.logging.log4j.ThreadContext; // Log4j2 MDCpublic class FileUploadService {// Changed here// ============ // Scoped value to hold request context // File request is a custom class storing a unique ID, // a user name and a URL private static final ScopedValue<FileRequest> REQUEST_CONTEXT = ScopedValue.newInstance() // Simulated HTTP request handler public void handleHttpRequest(String username, String fileUrl) { // Create request context object FileRequest request = new FileRequest( UUID.randomUUID().toString(), // Generate a random ID username, fileUrl );// Removed the set command here// ============================// Changed this to use a virtual thread// ==================================== // Spawn worker thread Thread.startVirtualThread(() -> {// Changed this to define a scope that includes the entire thread task// =================================================================== ScopedValue.where(REQUEST_CONTEXT,request).run(() -> { try {// Moved the error checking here using the isBound method// ====================================================== if (!REQUEST_CONTEXT.isBound()) { throw new IllegalStateException ("No scoped value context available"); } // Retrieve context inside the new thread FileRequest reqContext = REQUEST_CONTEXT.get(); // Set up Log4j MDC // ThreadContext is a class belonging to Log4j ThreadContext.put("requestId", reqContext.getRequestId()); ThreadContext.put("username", reqContext.getUsername()); // Process request // This is a custom class that does the actual work FileRequestHandler handler = new FileRequestHandler(); handler.uploadFile(); } finally { // Clean up MDC ThreadContext.clearAll();// Changed here: remove method is no longer needed } }); });// Changed here: remove method is no longer needed }}
Conclusion
Project Loom offers a much better concurrency model. It’s more robust, faster and more efficient on memory. With scoped values, we can say goodbye permanently to ThreadLocal leaks.
Migrating to scoped values is not compulsory, so we need to look carefully at the risks and benefits before deciding whether it’s appropriate for our applications.
It’s essential when migrating to fully understand how thread boundaries differ from scope boundaries, and plan our revised code to make sure the variables are still available where they’re needed. We also need to take care that any context for third party libraries is set within the scope, and that all calls to the libraries are within the correct scope.
Migrating is optional, but it’s highly recommended that we use scoped values in place of ThreadLocals for new development.

Share your Thoughts!