In this article we are going to discuss Virtual Threads, Structured Concurrency and Scoped Values, the three main features of Project Loom and see how we can put them all together, in a web application. We will create a Spring Boot application and then add these features one by one to see how these features fit together nicely enabling us to write readable concurrent programs.
What is Project Loom?
Project Loom is one of the most important projects at Open JDK. The purpose of Project Loom is to provide high throughput, lightweight concurrency, which is also easy to use. It does so by providing three features: virtual threads, structured concurrency and scoped values. Virtual threads are lightweight, high-throughput threads that scale effortlessly. Structured concurrency is a streamlined approach to managing concurrent tasks as a single unit of work and scoped values are a modern alternative to ThreadLocal, designed for virtual threads.
Virtual Threads
What we see in the picture below is an instance of JVM which is running on some operating system. The JVM has a bunch of virtual threads and some platform threads and the operating system has some OS threads or kernel threads

Let’s start with discussing platform threads. They are traditional threads present in Java for long time. The platform threads are actually a thin wrapper over operating system threads. What that means is when JVM wants to create a new thread, it actually asks OS if an OS thread can be created and only when that can be created, a new platform thread instance is created by the JVM. Because of that, the platform threads map to OS threads with a one-to-one mapping. Also creating a platform thread is a resource intensive task, so you don’t want to do that too often. When you are done with a platform thread, you put it in a pool so the next time you need it, you can get it from the pool. Platform threads require thread pools.
Virtual threads on the other hand are lightweight user threads. Lightweight, because they require significantly less memory. And because of that, you can create them in abundance, like thousands or even millions of virtual threads. That’s what makes them highly scalable. There is actually one more thing that makes them highly scalable and we will see that in the next section. Creating virtual threads is a cheap operation. So when you are done with a virtual thread, you can just throw it away and next time when you need it, you can create a new one. You don’t need to pool them.
High Scalability of Virtual Threads
What we saw in the Picture 1 is that some of the virtual threads are connected to platform threads. Why is that? Well, that is because a virtual thread on its own cannot execute anything. It requires a platform thread to execute something. But then there are some virtual threads which are not connected to any of the platform threads. What about these virtual threads, when do they get platform threads? For these we have Virtual Thread Scheduler. This scheduler ensures that a virtual thread that requires platform thread, gets it.

Virtual Thread Scheduler is a new scheduler, introduced along with virtual threads. This scheduler mounts a virtual thread onto a platform thread when a virtual thread requires a platform thread and it unmounts the virtual thread when it does not require the platform thread (as shown in picture 2).
When would a virtual thread not require platform thread? It would be when a virtual thread is waiting for something. Let’s say it is waiting for a network resource to be available or database to return something. In all these cases, virtual thread is not doing anything. It does not require CPU. So it gets unmounted from the platform thread.
In a typical IO bound application, threads spend a lot of time waiting and all of this waiting can be avoided if we use virtual threads. Meaning, only virtual threads would be waiting but not the platform threads and CPU would not be wasted. So if we use virtual threads, CPU would be highly efficiently utilized and that is what makes virtual threads highly scalable.
Also, in this entire endeavour of mounting and unmouting, the platform threads are carrying virtual threads. Therefore, they are also known as carrier threads. Let’s see virtual threads in action.
Virtual Threads in a Web Application
Now we are going to use virtual threads in a SpringBoot application. For that we are going to use SpringBoot 3.4 and JDK 25 (specifically a Project Loom early-access version of JDK 25 which you can find here: https://jdk.java.net/loom/).
The domain of our application is going to be banking. And I am really good with names, so I am going call it: A Bank.
You can find the project source here: https://github.com/balkrishnarawool/SpringBootLoom. What we have here is actually a Maven project which has two modules: abank and services.abank is where all the business rules are implemented and it uses services which are present in services modules. Both of these are SpringBoot applications.
We are now going to create a simple controller called HelloController as shown below:
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello";
}
}HelloController
As we can see here, it exposes an endpoint /hello which we can test by starting the application and sending a GET request:
abank % http localhost:8082/hello
HTTP/1.1 200
Connection: keep-alive
Content-Length: 5
Content-Type: text/plain;charset=UTF-8
Date: Thu, 21 Aug 2025 22:24:40 GMT
Keep-Alive: timeout=60
HelloSending a request /hello endpoint
Now let’s check which thread this ‘hello’ is coming from. For that let’s modify the hello() method like this:
<code>public String hello() {
return "Hello from "+Thread.currentThread();
}</code>
And then restart the app, send a new request:
abank % http localhost:8082/hello
Hello from Thread[#44,http-nio-8082-exec-1,5,main]Sending a request /hello endpoint
We are getting hello from a platform thread. Let’s see how we can change this to virtual thread. Because this is SpringBoot application, it is very easy to enable virtual threads. We can do that simply by adding this property to application.properties:
<code>spring.threads.virtual.enabled=true</code>
Restart the application and send in a new request:
abank % http localhost:8082/hello
Hello from VirtualThread[#56,tomcat-handler-0]/runnable@ForkJoinPool-1-worker-1
We get back that the response is coming from a virtual thread. So what the server is doing now is whenever it receives a request, it creates a new virtual thread and that virtual thread is handling our request.
And now we have transformed the application to use virtual threads. What can we do with this power? Well, one of the things we can do is to use structured concurrency.
Structured Concurrency
With platform threads we have the limitation that we cannot create a lot of them. Therefore, we create a limited number of platform threads and we use them very efficiently. In fact, we view a platform thread as a representation of a process and we submit multiple tasks to it. This limitation is not there with virtual threads. We can create virtual threads in large number. In fact, even if we have large number of tasks, we can actually have each task run in its own virtual thread. The advantage here is that we can then arrange these tasks in a structure such that the structure represents business requirements in the most logical way. That is what structured concurrency is. And that’s how virtual threads enable structured concurrency.
The principle of structured concurrency is that when the flow of execution splits into multiple concurrent flows, they rejoin in the same code block. Take a look at the picture below.

Here, the main task creates 3 sub-tasks and only when all of the sub-tasks are finished, the main task can continue. We can easily implement this using structured concurrency API from Java and if we do that we will see that the forking and the joining of these tasks happen in the same code block. Although these 3 subtasks are otherwise independent, due to structured concurrency, they are considered to be part of a unit of work which either finishes successfully or unsuccessfully.
Structured concurrency also brings in other benefits:
- Error handling with short circuiting: So here we have 3 subtasks and we are waiting for all of them to be finished to continue with the main task. If one of the tasks fails, there is no point in continuing with the other two because we are interested in the results from all of them. So if that is the case, the other two are immediately cancelled and that is what short circuiting is.
- Cancellation propagation: In case if the main task is cancelled then that cancellation is propagated to the child threads and the child tasks are also cancelled. That is what cancellation propagation is.
- Clarity: When you implement this example using structured concurrency, the resulting code resembles this structure. We can clearly see the boundaries of concurrency because the forking and joining happens in the same code block and that brings in a lot of clarity.
- Observability: In the example above, we have a task which is creating a few sub tasks. These subtasks can have their own subtasks. And those can have their own subtasks. If this keeps happening for a few more times, we would soon end up with a big hierarchy of tasks, in fact, a big tree of tasks. Because we are creating a virtual thread for each task, this would mean that we would have a big tree of virtual threads. If at any point in time there is a problem and we want to analyze the thread dump, because of structured concurrency, we can also get the thread dump in a format that retains this tree of virtual threads. So it becomes easy to navigate and pinpoint a particular virtual thread that has a problem and then fix that problem. This is the observability benefit of using structured concurrency.
Structured Concurrency in a Web Application
We are now going to see how structured concurrency API can be used in a web application. For that we are going to add a new feature to our banking application. This feature will be ‘loan application’.
When a customer applies for a loan, we should get customer details. Then using the customer details, we should get their accounts, their loans and their external credit scores. There are two services that provide external credit score and we should use response from one of them. Once we have the accounts, loans and credit score data, we should send that data to offer-calculation-service to finally get an offer and send it back to the customer.
This is the entire feature. If we represent this feature with tasks, it would look like this:

We will implement this using structured concurrency API. But before we do that let’s take a look at various other parts that are necessary for making the implementation complete.
Services App
Before we do that, let’s take a quick look at the services app. Here we have CustomerController which provides a number of services to do various tasks related to customer, e.g. to get customer details, to get credit scores, accounts and loans for the customer and also to calculate the offer. See code below:
@RestController
@RequestMapping("/customer")
public class CustomerController {
@GetMapping("/{id}")
public Customer getCustomer(@PathVariable("id") String customerId) {
logAndWait("getCustomer");
return new Customer(customerId);
}
@GetMapping("/{id}/credit-score1")
public CreditScore getCreditScore1(@PathVariable("id") String customerId) {
logAndWait("getCreditScore1");
return new CreditScore("Score1");
}
@GetMapping("/{id}/credit-score2")
public CreditScore getCreditScore2(@PathVariable("id") String customerId) {
logAndWait("getCreditScore2");
return new CreditScore("Score2");
}
@GetMapping("/{id}/accounts")
public List<Account> getAccountsInfo(@PathVariable("id") String customerId) {
logAndWait("getAccountsInfo");
return List.of(new Account("123", "1000.00"), new Account("456", "2000.00"));
}
@GetMapping("/{id}/loans")
public List<Loan> getLoansInfo(@PathVariable("id") String customerId) {
logAndWait("getLoansInfo");
return List.of(new Loan("TL123", "10000.00"), new Loan("CL456", "20000.00"));
}
@PostMapping("/{id}/loans/offer")
public Offer calculateOffer(@PathVariable("id") String customerId, @RequestBody LoanOfferRequest request) {
logAndWait("calculateOffer");
return new Offer("LMN123", request.amount(), request.purpose(), "4.00","An offer for your loan application");
}
}
We are going to create various tasks in abank app and we are going to call these services from the corresponding tasks.
Data Model and ABank-Application
Now let’s create the data model for abank app as shown below:
public interface Model {
record LoanApplicationRequest(String customerId, String amount, String purpose) { }
record Customer(String id) { }
record Account(String number, String balance) { }
record Loan(String number, String amount) { }
record CreditScore(String score) { }
record LoanOfferRequest(List<Account> accounts, List<Loan> loans, String creditScore, String amount, String purpose) { }
record Offer(String id, String amount, String purpose, String interest, String offerText) { }
class ABankException extends RuntimeException {
public ABankException(String message) {
super(message);
}
public ABankException(Throwable th) {
super(th);
}
}
}
Here we have a number of records which will be used as DTOs (Data Transfer Objects) between various layers of the app. Also there is a class ABankException which is a generic exception that takes care of all kinds of errors in the app.
Now let’s take a look at main class of the app: ABankApplication
@SpringBootApplication
public class ABankApplication {
@Value("${abank.services.base-url}")
private String servicesBaseUrl;
public static void main(String[] args) {
SpringApplication.run(ABankApplication.class, args);
}
@Bean
public RestClient restClient(RestClient.Builder restClientBuilder) {
return restClientBuilder.baseUrl(servicesBaseUrl).build();
}
}
Among other things, this class has a bean restClient which is a RestClient that connects to services app.
Customer-Service, Account-Service and Loan-Service
Now we will create CustomerService, AccountService and LoanService which will get customer details, account details and loan details respectively. CustomerService will look like this:
@Service
public class CustomerService {
private static final Logger logger = LoggerFactory.getLogger(CustomerService.class);
private RestClient restClient;
public CustomerService(RestClient restClient) {
this.restClient = restClient;
}
public Customer getCustomer(String customerId) {
logger.info("CustomerService.getCustomer(): Start");
var customer = restClient.get().uri("/customer/{id}", customerId).retrieve().body(Customer.class);
logger.info("CustomerService.getCustomer(): Done");
return customer;
}
}
It has a method getCustomer() which makes use of restClient to get details of a customer using customerId.
AccountService will look like this:
@Service
public class AccountService {
private static final Logger logger = LoggerFactory.getLogger(AccountService.class);
private RestClient restClient;
public AccountService(RestClient restClient) {
this.restClient = restClient;
}
public List<Account> getAccountsInfo(Customer customer) {
logger.info("AccountService.getAccountsInfo(): Start");
var accounts = restClient
.get()
.uri("/customer/{id}/accounts", customer.id())
.retrieve()
.body(new ParameterizedTypeReference<List<Account>>() {
});
logger.info("AccountService.getAccountsInfo(): Done");
return accounts;
}
}
It has a method getAccountsInfo() which makes use of restClient to get details of a customer’s accounts using customer ID.
And LoanService will look like this:
@Service
public class LoanService {
private static final Logger logger = LoggerFactory.getLogger(LoanService.class);
private RestClient restClient;
public LoanService(RestClient restClient) {
this.restClient = restClient;
}
public List<Loan> getLoansInfo(Customer customer) {
logger.info("LoanService.getLoansInfo(): Start");
var loans = restClient
.get()
.uri("/customer/{id}/loans", customer.id())
.retrieve()
.body(new ParameterizedTypeReference<List<Loan>>() { });
logger.info("LoanService.getLoansInfo(): Done");
return loans;
}
}
It has a method getLoansInfo() which makes use of restClient to get details of a customer’s loans using customerId.
Loan-Controller
Let’s use these services in a controller and this is where we will use structured concurrency API. For that let’s create LoanController, which will look like this:
@RestController
public class LoanController {
private CustomerService customerService;
private AccountService accountService;
private LoanService loanService;
public LoanController(CustomerService customerService,
AccountService accountService,
LoanService loanService) {
this.customerService = customerService;
this.accountService = accountService;
this.loanService = loanService;
}
@PostMapping("/loan-application")
public Offer applyForLoan(@RequestBody LoanApplicationRequest request) {
var currentCustomer = customerService.getCustomer(request.customerId());
try (var scope = StructuredTaskScope.open()) {
var task1 = scope.fork(() -> accountService.getAccountsInfo(currentCustomer));
var task2 = scope.fork(() -> loanService.getLoansInfo(currentCustomer));
scope.join();
var accountsInfo = task1.get();
var loansInfo = task2.get();
//TODO: Implementation to be completed
return null;
} catch (InterruptedException e) {
throw new ABankException(e);
}
}
}
This controller has 3 dependencies injected via constructor: CustomerService, AccountService and LoanService. It also has a method applyForLoan() which exposes a POST endpoint /loan-application. It takes LoanApplicationRequest as input and uses CustomerService.getCustomer() to get current customer. It also makes use of AccountService.getAcountsInfo() and LoanService.getLoansInfo() to get accounts and loans info for the customer. These tasks should be done concurrently and therefore it uses structured concurrency API. For that, it calls StructuredTaskScope.open() to create a new scope. This is done inside a try-with-resources block, so it ensures that scope.close() is called just before exiting the try block. This scope is then used to fork() subtasks, task1 and task2, which call AccountService.getAcountsInfo() and LoanService.getLoansInfo() respectively. Each of these subtasks are executed on their own virtual thread concurrently. Then scope.join() is called which waits until both these subtasks are finished. And because both the subtasks are finished, we can call task1.get() and task2.get() to get the results of these tasks, accounts and loans info respectively.
When we use structured concurrency API, we often take these sequence of steps:
StructuredTaskScope.open()to create a new scopeStructuredTaskScope.fork()to fork subtasksStructuredTaskScope.join()to ensure all necessary tasks are finishedSubtask.get()to get the result of the task
We still need to determine the credit-score of the customer, for that we will create CreditScoreService.
Credit-Score-Service
Let’s create CreditScoreService as shown below:
@Service
public class CreditScoreService {
private static final Logger logger = LoggerFactory.getLogger(CreditScoreService.class);
private RestClient restClient;
public CreditScoreService(RestClient restClient) {
this.restClient = restClient;
}
public CreditScore getCreditScore(Customer customer) {
try (var scope = StructuredTaskScope.open(Joiner.<CreditScore>anySuccessfulResultOrThrow())) {
scope.fork(() -> getCreditScoreFrom("/credit-score1", customer));
scope.fork(() -> getCreditScoreFrom("/credit-score2", customer));
var score = scope.join();
return score;
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
private CreditScore getCreditScoreFrom(String endpoint, Customer customer) {
logger.info("CreditScoreService.getCreditScore() with {}: Start", endpoint);
var score = restClient.get().uri("/customer/{id}"+endpoint, customer.id()).retrieve().body(CreditScore.class);
logger.info("CreditScoreService.getCreditScore() with {}: Done", endpoint);
return score;
}
}
This service looks like any other service class we just created. It has a method getCreditScoreFrom() makes use of restClient to fetch the credit score of the given customer.
There is also a method getCreditScore() which makes use of structured concurrency API to get 2 credit scores of the customer and uses the one that it receives first. Because it uses structured concurrency API, it follows the same pattern (sequence of 4 steps) as we discussed in the previous section. But because it has to select one of the multiple credit scores, there are some differences. It starts with using StructuredTaskScope.open() to create a scope, but here it uses an overloaded method that takes Joiner as argument. Specifically, it uses Joiner.anySuccessfulResultOrThrow() which configures the scope to use the first successful response and not wait for others to finish. Then it uses scope.fork() to fork 2 subtasks, each of which fetches the credit score for the customer from /credit-score1 and /credit-score2 respectively. Then it does scope.join() which waits only until the first successful response and it also returns this response. This response is a credit score which is then returned from this method.
So far we have seen two ways of using structured concurrency API. Specifically, we have seen two ways of creating StructuredTaskScope. The default configuration, where we call StructuredTaskScope.open() and it waits for all subtasks to be finished. And another one where it takes a Joiner instance. We used a Joiner.anySuccessfulResultOrThrow() which waits only until the first successful response from one of the subtasks.
Complete Loan Application Feature
In LoanController.applyForLoan(), we still have to calculate offer for the customer, and for that we will add a new method in the LoanService: calculateOffer() like this:
public Offer calculateOffer(Customer customer,
List<Account> accountsInfo,
List<Loan> loansInfo,
CreditScore creditScore,
String amount,
String purpose) {
logger.info("LoanService.calculateOffer(): Start");
var loanOfferRequest = new LoanOfferRequest(accountsInfo, loansInfo, creditScore.score(), amount, purpose);
var offer = restClient
.post()
.uri("/customer/{id}/loans/offer", customer.id())
.body(loanOfferRequest)
.retrieve()
.body(Offer.class);
logger.info("LoanService.calculateOffer(): Done");
return offer;
}
This makes use of restClient to create an Offer which can be sent to the customer.
Let’s call this method from LoanController.applyForLoan() like this:
public Offer applyForLoan(@RequestBody LoanApplicationRequest request) {
var currentCustomer = customerService.getCustomer(request.customerId());
try (var scope = StructuredTaskScope.open()) {
var task1 = scope.fork(() -> accountService.getAccountsInfo(currentCustomer));
var task2 = scope.fork(() -> loanService.getLoansInfo(currentCustomer));
var task3 = scope.fork(() -> creditScoreService.getCreditScore(currentCustomer));
scope.join();
var accountsInfo = task1.get();
var loansInfo = task2.get();
var creditScore = task3.get();
var offer = loanService.calculateOffer(
currentCustomer, accountsInfo, loansInfo, creditScore, request.amount(), request.purpose()
);
return offer;
} catch (InterruptedException e) {
throw new ABankException(e);
}
}
This way, we get an offer which can be returned from applyForLoan(). Make sure to add CreditScoreService as a constructor dependency for LoanController.
To test this, let’s restart both the apps, abank and services. Then send a request as shown below:
abank % http localhost:8082/loan-application customerId=1234 amount=10000 purpose=Auto</a>
{
"amount": "10000",
"id": "LMN123",
"interest": "4.00",
"offerText": "An offer for your loan application",
"purpose": "Auto"
}
We get successful response back.
Now let’s test what happens when one of the services fail. If the task that gets account details fails, according to the behaviour of structured concurrency, the governing scope should fail immediately. Because here we are waiting for all subtasks to complete successfully. So if one fails, there is no point in waiting for others to complete. So the scope fails immediately and we should see a failure response. To test this, let’s change the CustomerController.getAccountsInfo() in the services app as shown below:
public List<Account> getAccountsInfo(@PathVariable("id") String customerId) {
logAndWait("getAccountsInfo");
throw new RuntimeException("Error!");
}
And restart services app and send a request as shown below:
abank % http localhost:8082/loan-application customerId=1234 amount=10000 purpose=Auto
{
"error": "Internal Server Error",
"path": "/loan-application",
"status": 500,
"timestamp": "2025-08-22T13:17:47.677+00:00"
}
And it does give us an error back. Also you can check in the logs of abank app that it is indeed the AccountService that fails.
INFO 33345 --- [ABank] [omcat-handler-3] c.b.b.a.s.CustomerService : CustomerService.getCustomer(): Start
INFO 33345 --- [ABank] [omcat-handler-3] c.b.b.a.s.CustomerService : CustomerService.getCustomer(): Done
INFO 33345 --- [ABank] [ virtual-78] c.b.b.a.s.AccountService : AccountService.getAccountsInfo(): Start
INFO 33345 --- [ABank] [ virtual-86] c.b.b.a.s.CreditScoreService : CreditScoreService.getCreditScore() with /credit-score2: Start
INFO 33345 --- [ABank] [ virtual-83] c.b.b.a.s.CreditScoreService : CreditScoreService.getCreditScore() with /credit-score1: Start
INFO 33345 --- [ABank] [ virtual-79] c.b.b.a.s.LoanService : LoanService.getLoansInfo(): Start
ERROR 33345 --- [ABank] [omcat-handler-3]
o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.util.concurrent.StructuredTaskScope$FailedException: org.springframework.web.client.HttpServerErrorException$InternalServerError: 500 Internal Server Error: "{"timestamp":"2025-08-22T13:17:47.660+00:00","status":500,"error":"Internal Server Error","path":"/customer/1234/accounts"}"] with root cause
org.springframework.web.client.HttpServerErrorException$InternalServerError: 500 Internal Server Error
The logs are truncated for brevity. But we can see that call to /customer/1234/accounts fails.
Now let’s test a scenario where one of the credit score services fails. For this let’s first change CustomerService.getAccountsInfo() back to its original implementation, as shown below:
public List<Account> getAccountsInfo(@PathVariable("id") String customerId) {
logAndWait("getAccountsInfo");
return List.of(new Account("123", "1000.00"), new Account("456", "2000.00"));
}
And then change the CustomerService.getCreditScore1() in the services app as shown below:
public CreditScore getCreditScore1(@PathVariable("id") String customerId) {
logAndWait("getCreditScore1");
throw new RuntimeException("Error!");
}
And restart services app and send a request as shown below:
abank % http localhost:8082/loan-application customerId=1234 amount=10000 purpose=Auto
{
"amount": "10000",
"id": "LMN123",
"interest": "4.00",
"offerText": "An offer for your loan application",
"purpose": "Auto"
}
It gives a successful response back. Surprising! Why do we get successful response even though a service is responding with an error? This is because the StructuredTaskScope in CreditScoreService sends two requests, viz., for /credit-score1 and /credit-score2. And it waits until one successful response. Although /credit-score1 responds with an error, it waits for /credit-score2 for a successful response and uses this response.
What happens when /credit-score2 also responds with an error? This is left as an exercise to the reader. Once tested, restore the CustomerController to original implementation with successful responses.
Scoped Values
So far, we have seen how virtual threads and structured concurrency work together in a web application. Now let’s see what scoped values are and how they can be added to the equation. For that we first have to take a look at ThreadLocal.
A thread local object as the name suggests is local to a thread. If we define an object of type ThreadLocal as shown below:
ThreadLocal<Customer> customer = ThreadLocal.withInitial(() -> new Customer(1234));
then each thread gets its own copy of Customer object.
Thread local objects provide certain benefits:
They can be used to store request or session specific data. For example, if a server creates a new thread every time a new request is received, we can have a ThreadLocal object hold request specific data because this object would be tied to this thread and a new request would create a new thread and get its own copy of this object.
They are also useful when dealing with non thread-safe classes. If you have a class T that is not thread-safe, you can safely create ThreadLocal<T> objects. Because then each thread would have their own T object and it is not a problem that class T is not thread-safe.
ThreadLocal objects can also be used to hold cache. Because the same object is used by all the code executed in a thread and this cache can be easily invalidated from anywhere in the thread.
Also, because the same object is used by all the code being executed in a thread, thread locals provide a specific observability benefit. They can be used to hold IDs such as correlation IDs which get passed in the entire call chain.
You can, obviously, use thread locals with platform threads, but you can also use them with virtual threads. But there are certain disadvantages to using thread locals:
Unconstrained mutability: There are 2 public methods available on ThreadLocal, get() and set(). These can be called from anywhere within the thread. This creates shared mutability issues, which means we need to keep track of who is updating the object and who is reading it so as to ensure consistent state.
Unbounded lifetime: ThreadLocal object stays as long as you do not call remove() on it. And developers sometimes forget to call that method and therefore the object stays in the thread much longer than you need it there. Sometimes, you might get a thread from a thread pool and get surprised to see that it has a thread local object which you don’t expect!
Expensive inheritance, especially with InheritableThreadLocal-s: When a thread creates a child thread, all the InheritableThreadLocal-s are copied to that child thread. If you are dealing with virtual threads where you can have thousands or millions of threads, you can imagine that copying all these inheritable thread locals can occupy a lot of memory.
All these issues are solved with scoped values! Scoped values are immutable, so shared mutability issues are not there. All allowed threads use the same copy and because of that memory issues are not there. They are inherited through StructuredTaskScope to the child virtual threads. We will see this in action when we will look at ScopedValue API.
Scoped vales are bounded by this StructuredTaskScope, so no issues of unbounded lifetimes. For example, take a look at the code below
ScopedValue VALUE = ScopedValue.newInstance();
ScopedValue.where(VALUE, someValue).run(() -> {...});
Here we are creating a ScopedValue called VALUE and we set the value to be someValue and this is only available to us within the bounds of those curly brackets and nowhere else.
Let’s make use of scoped values in our web application. Take a look at LoanController.applyForLoan():
public Offer applyForLoan(@RequestBody LoanApplicationRequest request) {
var currentCustomer = customerService.getCustomer(request.customerId());
try (var scope = StructuredTaskScope.open()) {
var task1 = scope.fork(() -> accountService.getAccountsInfo(currentCustomer));
var task2 = scope.fork(() -> loanService.getLoansInfo(currentCustomer));
var task3 = scope.fork(() -> creditScoreService.getCreditScore(currentCustomer));
scope.join();
var accountsInfo = task1.get();
var loansInfo = task2.get();
var creditScore = task3.get();
var offer = loanService.calculateOffer(
currentCustomer, accountsInfo, loansInfo, creditScore, request.amount(), request.purpose()
);
return offer;
} catch (InterruptedException e) {
throw new ABankException(e);
}
}
What we see here is that the currentCustomer is getting passed to all tasks accountService.getAccountsInfo(), loanService.getLoansInfo() and creditScoreService.getCreditScore(). This can be avoided if we put the current customer in a scoped value and retrieve the current customer from that scoped value in all these tasks. We will do exactly that.
The idea with scoped-values is that they are bound to a certain scope. This scope could be a request and in that case the scoped-value would be available in that request. Normally, you put such objects in scoped-value that are needed in the scope but are not relevant for the “domain”. For example, authentication/authorization info, request metadata etc. But objects such as currentCustomer are very much relevant for the domain and you’d want to keep them in the method signature to be explicit about their need in the corresponding methods. So the above example is not the best one for scoped-value. But it shows how ScopedValue API can be used and it builds up on the loan-application feature built so far, so we would go ahead with this.
Let’s first do a small refactor so our implementation remains readable even after our changes. Let’s refactor applyForLoan() to create another method getCustomerInfo() and a record CustomerInfo, as shown below:
public Offer applyForLoan(@RequestBody LoanApplicationRequest request) {
var currentCustomer = customerService.getCustomer(request.customerId());
var customerInfo = getCustomerInfo(currentCustomer);
var offer = loanService.calculateOffer(
currentCustomer, customerInfo.accounts, customerInfo.loans, customerInfo.creditScore, request.amount(), request.purpose()
);
return offer;
}
private record CustomerInfo(List<Account> accounts, List<Loan> loans, CreditScore creditScore) { }
private CustomerInfo getCustomerInfo(Customer currentCustomer) {
try (var scope = StructuredTaskScope.open()) {
var task1 = scope.fork(() -> accountService.getAccountsInfo(currentCustomer));
var task2 = scope.fork(() -> loanService.getLoansInfo(currentCustomer));
var task3 = scope.fork(() -> creditScoreService.getCreditScore(currentCustomer));
scope.join();
var accountsInfo = task1.get();
var loansInfo = task2.get();
var creditScore = task3.get();
return new CustomerInfo(accountsInfo, loansInfo, creditScore);
} catch (InterruptedException e) {
throw new ABankException(e);
}
}
Let’s create a new class RequestContext and add a scoped value CURRENT_CUSTOMER as shown below:
public class RequestContext {
public static final ScopedValue<Customer> CURRENT_CUSTOMER = ScopedValue.newInstance();
}
So, we can now set the current customer like this:
ScopedValue.where(CURRENT_CUSTOMER, currentCustomer).call(() -> {…});
And get the current customer like this:
var currentCustomer = CURRENT_CUSTOMER.getOrThrow(new ABankException(“No customer found”));
This is fine, but this would also mean that ScopedValue API would be used all over place (including the exception for getOrThrow()). We can improve this by defining a new interface (public methods) for RequestContext, as shown below:
public class RequestContext {
private static final ScopedValue<Customer> CURRENT_CUSTOMER = ScopedValue.newInstance();
public static Request withCustomer(Customer customer) {
return new Request(ScopedValue.where(CURRENT_CUSTOMER, customer));
}
public static Customer getCurrentCustomer() {
return CURRENT_CUSTOMER.orElseThrow(() -> new ABankException("No customer available"));
}
public static class Request {
private ScopedValue.Carrier carrier;
private Request(ScopedValue.Carrier carrier) {
this.carrier = carrier;
}
public <T, X extends Throwable> T call(CallableOp<T, X> callableOp) throws X {
return carrier.call(callableOp);
}
}
}
This means, we can now set the current customer like this:
RequestContext.withCustomer(currentCustomer).call(() -> {…});
And get the current customer like this:
var currentCustomer = RequestContext.getCurrentCustomer();
Using this API, we can change the applyForLoan() to set the current customer as shown below:
public Offer applyForLoan(@RequestBody LoanApplicationRequest request) {
var currentCustomer = customerService.getCustomer(request.customerId());
return RequestContext.withCustomer(currentCustomer)
.call(() -> {
var customerInfo = getCustomerInfo();
var offer = loanService.calculateOffer(
customerInfo.accounts(), customerInfo.loans(), customerInfo.creditScore(), request.amount(), request.purpose()
);
return offer;
});
}
We can change the @Service classes, to get the current customer, starting with AccountService.getAccountsInfo():
public List<Account> getAccountsInfo() {
logger.info("AccountService.getAccountsInfo(): Start");
var customer = RequestContext.getCurrentCustomer();
var accounts = restClient
.get()
.uri("/customer/{id}/accounts", customer.id())
.retrieve()
.body(new ParameterizedTypeReference<List<Account>>() { });
logger.info("AccountService.getAccountsInfo(): Done");
return accounts;
}
And LoanService methods, as shown below:
public List<Loan> getLoansInfo() {
logger.info("LoanService.getLoansInfo(): Start");
var customer = RequestContext.getCurrentCustomer();
var loans = restClient
.get()
.uri("/customer/{id}/loans", customer.id())
.retrieve()
.body(new ParameterizedTypeReference<List<Loan>>() { });
logger.info("LoanService.getLoansInfo(): Done");
return loans;
}
public Offer calculateOffer(List<Account> accountsInfo,
List<Loan> loansInfo,
CreditScore creditScore,
String amount,
String purpose) {
logger.info("LoanService.calculateOffer(): Start");
var customer = RequestContext.getCurrentCustomer();
var loanOfferRequest = new LoanOfferRequest(accountsInfo, loansInfo, creditScore.score(), amount, purpose);
var offer = restClient
.post()
.uri("/customer/{id}/loans/offer", customer.id())
.body(loanOfferRequest)
.retrieve()
.body(Offer.class);
logger.info("LoanService.calculateOffer(): Done");
return offer;
}
And CreditScoreService methods
public CreditScore getCreditScore() {
try (var scope = StructuredTaskScope.open(Joiner.<CreditScore>anySuccessfulResultOrThrow())) {
scope.fork(() -> getCreditScoreFrom("/credit-score1"));
scope.fork(() -> getCreditScoreFrom("/credit-score2"));
var score = scope.join();
return score;
} catch (InterruptedException e) {
throw new ABankException(e);
}
}
private CreditScore getCreditScoreFrom(String endpoint) {
logger.info("CreditScoreService.getCreditScore() with {}: Start", endpoint);
var customer = RequestContext.getCurrentCustomer();
var score = restClient.get().uri("/customer/{id}"+endpoint, customer.id()).retrieve().body(CreditScore.class);
logger.info("CreditScoreService.getCreditScore() with {}: Done", endpoint);
return score;
}
And finally we can update LoanController.getCustomerInfo() as shown below:
private CustomerInfo getCustomerInfo() {
try (var scope = StructuredTaskScope.open()) {
var task1 = scope.fork(accountService::getAccountsInfo);
var task2 = scope.fork(loanService::getLoansInfo);
var task3 = scope.fork(creditScoreService::getCreditScore);
scope.join();
var accountsInfo = task1.get();
var loansInfo = task2.get();
var creditScore = task3.get();
return new CustomerInfo(accountsInfo, loansInfo, creditScore);
} catch (InterruptedException e) {
throw new ABankException(e);
}
}
We can test to verify that a loan request is still served properly with
abank % http localhost:8082/loan-application customerId=1234 amount=10000 purpose=Auto
{
"amount": "10000",
"id": "LMN123",
"interest": "4.00",
"offerText": "An offer for your loan application",
"purpose": "Auto"
}
If you feel curious, you can test various error scenarios.
This exercise shows how scoped values can be used together with structured concurrency API and also shows how they are inherited from parent thread to child-virtual-threads when used together with StructuredTaskScope.
Conclusion
Project Loom brings these powerful features: virtual threads, structured concurrency and scoped values. Virtual threads are lightweight user threads that provide high scalability. Structured concurrency is a streamlined approach to managing concurrent tasks as a single unit of work and scoped values are a modern alternative to ThreadLocal, designed for virtual threads. The exercise in this article exemplifies how these features can be used together to write readable concurrent programs in Java.

This article is part of the magazine issue ’Java 25 – Part 1′.
You can read the complete issue with all contributions here.