MediumJava Virtual Threads — A Complete Practical Guide | by Suraj | Medium
Sitemap

Java Virtual Threads — A Complete Practical Guide

12 min readSep 9, 2026

--

Press enter or click to view image in full size

Virtual Threads: The Questions That Matter

Before diving into the internals, let’s start with the practical questions you’ll actually face when using Virtual Threads:

  1. Why do we need Virtual Threads when Java already has thread pools?
  2. What actually happens inside the JVM when a Virtual Thread blocks on I/O?
  3. Platform Threads vs Virtual Threads — what really changes?
  4. Should we still use thread pools, and what does “one Virtual Thread per task” really mean?
  5. Where do Virtual Threads help — I/O-bound vs CPU-bound workloads?
  6. Do Virtual Threads make applications faster, or just more scalable?
  7. Do we still need connection pools, rate limits, and semaphores?
  8. Virtual Threads vs ExecutorService vs CompletableFuture — when should you use each?
  9. Where do Structured Concurrency and Scoped Values fit?
  10. How do Virtual Threads work with Spring Boot, and when should you still consider WebFlux?

If you can confidently answer these questions after reading this guide, you understand Virtual Threads well enough to use them in real applications — not just explain them in an interview.

Java applications have traditionally used thread pools to handle concurrent work.

ExecutorService executor = Executors.newFixedThreadPool(100);

This model works well, but it has an important limitation:

Platform threads are expensive resources, especially when thousands of them spend most of their time waiting for I/O.

Virtual Threads change this.

They let us write familiar synchronous Java code while supporting a very large number of concurrent I/O-bound tasks.

Let’s understand how.

1. The Problem with Traditional Threads

Imagine a Spring Boot service handling thousands of requests.

Each request may perform:

Request
│
├── Database query
│
├── REST API call
│
└── Redis lookup
│
▼
Response

Most of the request isn’t actually using the CPU.

It is waiting.

For example:

CPU work       █
DB waiting ████████████
CPU work █
HTTP waiting ██████████
CPU work █

With traditional Java platform threads, the thread remains occupied while waiting.

Request 1 ──→ Platform Thread 1 ──→ waiting for DB
Request 2 ──→ Platform Thread 2 ──→ waiting for HTTP
Request 3 ──→ Platform Thread 3 ──→ waiting for DB
...

Eventually we run out of available worker threads.

2. Why We Used Thread Pools

Creating unlimited platform threads is expensive.

So Java applications traditionally use:

Executors.newFixedThreadPool(200);

Now:

10,000 Tasks
│
▼
┌───────────────┐
│ Queue │
└───────────────┘
│
▼
┌────────────────────┐
│ 200 Worker Threads │
└────────────────────┘
│
▼
OS

This protects the application from creating too many OS-backed threads.

But it creates another problem.

If all 200 workers are waiting:

Worker 1   → DB waiting
Worker 2 → HTTP waiting
Worker 3 → DB waiting
...
Worker 200 → HTTP waiting

the next request waits in the queue even though the CPU may have plenty of capacity.

3. Enter Virtual Threads

A Virtual Thread is a lightweight Java thread managed primarily by the JVM.

Virtual Threads became a permanent Java feature in Java 21 through Project Loom.

Create one like this:

Thread.startVirtualThread(() -> {
System.out.println("Hello from a virtual thread");
});

Or:

Thread.ofVirtual()
.start(() -> processOrder());

You still write ordinary Java code.

The major difference is how the JVM executes that thread.

4. Platform Thread vs Virtual Thread

Press enter or click to view image in full size

The key difference

With a Platform Thread, a Java thread is closely tied to an OS thread. If that Java thread blocks waiting for I/O, the underlying OS thread remains occupied.

With a Virtual Thread, the relationship is different:

Virtual Thread
↓
JVM Scheduler
↓
Carrier Thread
↓
OS Thread

A Virtual Thread uses a carrier thread only while it needs to execute. When it blocks on supported I/O, the JVM can unmount it and let the carrier run another Virtual Thread.

That is why we can have a very large number of Virtual Threads without needing the same number of OS threads.

Think of Platform Threads as expensive workers and Virtual Threads as lightweight tasks that temporarily borrow workers when they need CPU time.

One important clarification: Virtual Threads don’t give us more CPU cores and don’t make individual operations magically faster. Their biggest advantage is allowing many more I/O-bound tasks to be in progress concurrently.

Next, let’s see what actually happens when a Virtual Thread blocks — mounting and unmounting.

5. The Most Important Concept: Mounting and Unmounting

Press enter or click to view image in full size

The important part

A Virtual Thread does not own a platform thread for its entire lifetime.

It only needs a carrier thread while it is actually executing.

When it reaches a supported blocking operation, such as waiting for database or network I/O, the JVM can unmount it from the carrier.

The carrier is then free to execute another Virtual Thread.

When the I/O completes, the original Virtual Thread becomes runnable again and can be mounted on any available carrier thread.

That is why an application can have thousands — or even millions — of Virtual Threads without requiring the same number of platform threads.

Think of it this way:
Virtual Threads are cheap to
wait. Platform Threads are expensive to occupy while waiting.

This is the core scalability advantage of Virtual Threads for I/O-bound applications.

6. Virtual Threads Make Blocking Cheap — Not Work Fast

This distinction is extremely important.

Suppose:

repository.findUser(id);

takes:

500 ms

Virtual Threads will not magically make it:

50 ms

It still takes approximately:

500 ms

The difference is what happens while waiting.

Platform Thread

500 ms DB wait
│
▼
Platform thread occupied

Virtual Thread

500 ms DB wait
│
▼
Virtual thread suspended
│
▼
Carrier available for other work

Therefore Virtual Threads primarily improve:

Throughput
Scalability
Concurrency

not necessarily:

Individual request latency

7. The Recommended Model: One Virtual Thread Per Task

With platform threads we learned:

Threads are expensive — reuse them.

Therefore:

Executors.newFixedThreadPool(100);

Virtual Threads change that assumption.

Virtual Threads are designed to be cheap and short-lived.

Use:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> processOrder());
}

The model becomes:

Task 1 → Virtual Thread 1
Task 2 → Virtual Thread 2
Task 3 → Virtual Thread 3
Task 4 → Virtual Thread 4
...

Instead of:

Tasks
│
▼
Queue
│
▼
Fixed worker pool

Rule

Do not pool Virtual Threads. Create a Virtual Thread per task.

8. Virtual Threads Are Excellent for I/O-Bound Work

Press enter or click to view image in full size

Virtual Threads shine when applications spend significant time waiting.

Examples:

REST APIs
│
├── JDBC / Database
├── HTTP calls
├── File I/O
├── Socket I/O
└── Other network services

Consider:

public Order getOrder(long id) {

Customer customer = customerRepository.find(id);
Payment payment = paymentClient.getPayment(id);

return new Order(customer, payment);
}

This code is easy to:

  • read
  • debug
  • test
  • maintain

Virtual Threads allow us to retain this synchronous programming model while supporting much higher I/O concurrency.

9. Virtual Threads Are NOT for Making CPU Work Faster

Consider:

calculatePrimeNumbers();

or:

compressLargeFile();

These operations consume CPU.

Suppose the machine has:

8 CPU cores

Creating:

100,000 Virtual Threads

doesn’t create:

100,000-way parallelism

You still have roughly:

8 cores
│
▼
~8 CPU operations executing simultaneously

For CPU-bound parallel work, a bounded executor remains appropriate.

Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);

Remember

I/O-bound -> Virtual Threads


CPU-bound -> Bounded parallelism

10. Concurrency ≠ Parallelism

Virtual Threads make this distinction easier to understand.

Concurrency

Many tasks are in progress.

VT 1 → waiting for DB
VT 2 → waiting for HTTP
VT 3 → running
VT 4 → waiting for file
VT 5 → waiting for Redis

Parallelism

Multiple tasks are actually executing at the same instant.

Core 1 → VT 3
Core 2 → VT 8
Core 3 → VT 11
Core 4 → VT 17

Virtual Threads dramatically increase practical concurrency.

CPU cores still determine parallelism.

11. Virtual Threads vs Fixed Thread Pool

Traditional Executor

Executors.newFixedThreadPool(100);

Architecture:

10,000 Tasks
│
▼
Queue
│
▼
100 Platform Threads
│
▼
OS

A fixed thread pool intentionally limits how many tasks run at the same time.

This is useful when concurrency itself should be bounded.

For example, with CPU-bound work:

CPU-intensive tasks
│
▼
Bounded Thread Pool
│
▼
Limited Parallelism
│
▼
CPU Cores

Creating thousands of threads does not make CPU-intensive work faster. The CPU still has a limited number of cores.

Virtual Thread Executor

Executors.newVirtualThreadPerTaskExecutor();

Architecture:

10,000 Tasks
│
▼
10,000 Virtual Threads
│
▼
JVM Scheduler
│
▼
Carrier Threads
│
▼
OS

Virtual Threads make threads cheap enough that each task can have its own thread.

This works especially well for blocking I/O:

Request
│
├── DB call
├── HTTP call
└── File / Network I/O
│
▼
WAITING

While a Virtual Thread waits, the JVM can unmount it and allow the carrier thread to execute another Virtual Thread.

So Is the Fixed Thread Pool Obsolete?

No.

The two models solve different problems.

              What kind of work?
│
┌───────────┴───────────┐
│ │
▼ ▼
I/O-bound CPU-bound
│ │
▼ ▼
Virtual Threads Bounded Thread Pool
│ │
High concurrency Controlled parallelism

A good rule of thumb:

Blocking I/O -> Virtual Thread per task

CPU-intensive work -> Bounded ExecutorService ≈ number of available CPU cores

Virtual Threads reduce the cost of concurrency. They do not remove the need to control parallelism.

12. Don’t Use Thread Pools to Protect Scarce Resources

This is one of the most important design changes.

Get Suraj’s stories in your inbox

Join Medium for free to get updates from this writer.

Previously you might write:

Executors.newFixedThreadPool(10);

because you wanted:

Maximum 10 calls to external API

But you’re mixing two concerns:

Thread management
+
Resource limiting

With Virtual Threads, separate them.

Semaphore permits = new Semaphore(10);

permits.acquire();
try {
callExternalService();
} finally {
permits.release();
}

Now:

10,000 Virtual Threads
│
▼
Semaphore(10)
│
▼
External API

This explicitly says:

Only 10 operations may access this resource concurrently.

Much cleaner.

13. Virtual Threads Don’t Remove Database Limits

Imagine:

50,000 Virtual Threads
│
▼
PostgreSQL

Your connection pool may still have:

50 connections

Therefore:

50,000 Virtual Threads
│
▼
┌─────────────────────┐
│ Connection Pool: 50 │
└─────────────────────┘
│
▼
PostgreSQL

Virtual Threads don’t remove:

  • DB connection limits
  • HTTP connection limits
  • API rate limits
  • CPU limits
  • memory limits
  • downstream capacity

You still need:

Connection pools
Semaphores
Rate limiters
Timeouts
Bulkheads
Backpressure

A good rule:

Threads may be abundant. Resources are still scarce.

14. Virtual Threads vs CompletableFuture

Before Virtual Threads, asynchronous programming was often used to avoid blocking platform threads.

For example:

return CompletableFuture
.supplyAsync(() -> getUser(id))
.thenCompose(this::getOrders)
.thenApply(this::buildResponse);

This can scale well, but control flow becomes asynchronous.

With Virtual Threads:

User user = getUser(id);
Orders orders = getOrders(user);

return buildResponse(user, orders);

The second version gives us:

Simple control flow
Natural exceptions
Normal stack traces
Easy debugging
Easy testing

while Virtual Threads provide scalable blocking.

But CompletableFuture is not obsolete.

Use it when your problem naturally involves:

pipelines
Completion stages
Combining futures
Callbacks
Async composition

Quick decision

Need simple blocking code with huge I/O concurrency? -> Virtual Threads


Need async composition/pipeline? -> CompletableFuture

15. Virtual Threads Don’t Automatically Make Calls Concurrent

This code:

User user = getUser();

Orders orders = getOrders();

Recommendations recommendations = getRecommendations();

is still sequential.

getUser()
│
▼
getOrders()
│
▼
getRecommendations()

Virtual Threads don’t automatically detect independent operations.

If you want concurrency, you must explicitly create it.

For example:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {

Future<Orders> orders = executor.submit(this::getOrders);

Future<Recommendations> recommendations =
executor.submit(this::getRecommendations);

return new Response(
orders.get(),
recommendations.get()
);

}

Now:

             Request
│
┌───────┴───────┐
▼ ▼
getOrders() getRecommendations()
│ │
└───────┬───────┘
▼
Response

16. Structured Concurrency + Virtual Threads + Scoped Values

Press enter or click to view image in full size

When several concurrent tasks belong to one operation, Structured Concurrency provides a better mental model.

Imagine:

              HTTP Request
│
▼
Structured Scope
/ | \
/ | \
▼ ▼ ▼
Get User Get Orders Get Offers
\ | /
\ | /
└───────┼───────┘
▼
Response

The child tasks belong to the parent request.

This makes it easier to reason about:

Cancellation
Failures
Timeouts
Task lifetime
Observability

For example:

Get User   → SUCCESS
Get Orders → FAILURE
Get Offers → still running
│
▼
CANCEL

If the overall request cannot succeed, unnecessary work can be cancelled.

Mental model

Virtual Threads -> How cheaply can tasks run?

Structured Concurrency -> How should related tasks be organized?

The Structured Concurrency API has evolved across Java releases, so check the exact API/status for the JDK version you’re targeting.

17. Scoped Values

Virtual Threads also change how we should think about contextual information.

Traditionally Java often uses:

ThreadLocal<RequestContext>

for things such as:

Request ID
Tenant ID
User context
Tracing information

But ThreadLocal is mutable and requires careful lifecycle management.

Scoped Values provide a scoped, read-only context model.

Conceptually:

ScopedValue.where(REQUEST_CONTEXT, context)
.run(() -> processRequest());

Think:

Request
│
│ RequestContext = X
│
├── Service
│ │
│ └── Repository
│
└── scope ends
│
▼
context no longer bound

The combination is powerful:

                  Request
│
Virtual Thread
│
Scoped Values
│
▼
Structured Concurrency
/ | \
VT VT VT
│ │ │
DB HTTP Redis

Three different responsibilities:

Virtual Threads -> cheap concurrency

Scoped Values -> contextual data

Structured Concurrency -> task lifecycle

18. Be Careful with ThreadLocal

Virtual Threads support ThreadLocal.

This works:

private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();

But remember:

Platform-thread application -> maybe hundreds of threads

Virtual-thread application -> potentially hundreds of thousands
of short-lived threads

Large per-thread state multiplied across huge numbers of Virtual Threads can become expensive.

Good rule

Avoid using ThreadLocal as a hidden per-thread cache.

For read-only contextual information, consider whether ScopedValue is a better model.

19. Virtual Threads in Spring Boot

Modern Spring Boot applications can enable Virtual Thread support with:

spring.threads.virtual.enabled=true

This allows Spring’s supported task execution infrastructure to use Virtual Threads.

The conceptual request model becomes:

Request 1 → Virtual Thread
Request 2 → Virtual Thread
Request 3 → Virtual Thread
Request 4 → Virtual Thread
...

which fits traditional Spring MVC code extremely well.

For example:

@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable long id) {

Customer customer = customerRepository.find(id);

Payment payment = paymentClient.getPayment(id);

return new Order(customer, payment);
}

You can retain normal blocking-style code.

That’s one of the biggest benefits of Virtual Threads:

Scalable concurrency without forcing the entire application into an asynchronous programming model.

20. Virtual Threads vs Spring WebFlux

WebFlux solves scalability differently.

Spring WebFlux

Request
│
▼
Event Loop
│
▼
Non-blocking I/O
│
▼
Reactive Pipeline

Code often looks like:

return getUser(id)
.flatMap(user ->
getOrders(user)
.map(orders ->
buildResponse(user, orders)));

Virtual Threads

Request
│
▼
Virtual Thread
│
▼
Blocking-style I/O

Code can remain:

User user = getUser(id);
Orders orders = getOrders(user);
return buildResponse(user, orders);

For many traditional CRUD/microservice workloads, this is extremely attractive.

But WebFlux remains useful for:

Streaming
Reactive systems
Backpressure
Reactive libraries
Event-driven pipelines

So:

Simple request/response + blocking APIs -> Virtual Threads


Reactive streaming/backpressure -> WebFlux

21. Virtual Threads Don’t Mean “No Thread Pools Anywhere”

This is another common misconception.

A modern application may reasonably contain both:

I/O Work -> Virtual Threads

CPU-heavy Work -> Bounded Executor

Example:

ExecutorService cpuExecutor =
Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);

Virtual Threads are not a universal replacement for every executor.

Choose the execution model based on the workload.

22. Little’s Law Explains Why Virtual Threads Matter

Press enter or click to view image in full size

A useful systems relationship is:

Concurrency ≈ Throughput × Latency

Suppose your service needs:

10,000 requests / second

and each request takes:

1 second

Then roughly:

Concurrency = 10,000 × 1 ≈ 10,000 concurrent requests

With a traditional thread-per-request model:

10,000 requests
│
▼
10,000 Platform Threads

That can become expensive.

Virtual Threads allow:

10,000 requests
│
▼
10,000 Virtual Threads
│
▼
small number of carrier threads

This is the real scalability argument behind Virtual Threads.

23. Common Mistakes When Using Virtual Threads

Press enter or click to view image in full size

24. When Should I Use Virtual Threads?

Great fit

✓ Spring Boot REST APIs
✓ JDBC
✓ HTTP clients
✓ File I/O
✓ Socket I/O
✓ Microservices
✓ High-concurrency request/response systems
✓ Applications using blocking libraries

Usually not the main solution

✗ Image processing
✗ Video encoding
✗ Compression
✗ Cryptography
✗ Heavy calculations
✗ CPU-intensive transformations

The simplest decision tree is:

              What kind of work?
│
┌────────┴────────┐
│ │
I/O-bound CPU-bound
│ │
▼ ▼
Virtual Threads Bounded Pool
│
▼
Lots of concurrent
blocking tasks?
│
YES
│
▼
Excellent VT fit

25. Practical Cheat Sheet

Press enter or click to view image in full size

26. Virtual Threads vs Other Java Concurrency Tools

Press enter or click to view image in full size

Final Takeaway

Virtual Threads don’t make your database faster.

They don’t give you more CPU cores.

They don’t eliminate connection pools, rate limits, or backpressure.

What they change is much simpler:

Waiting no longer needs to consume an expensive platform thread.

That allows Java applications to return to one of the simplest concurrency models:

One task
↓
One thread

except now that thread can be a lightweight Virtual Thread.

For I/O-heavy Java and Spring Boot applications, that gives us an extremely attractive combination:

Simple synchronous code
+
High concurrency
+
Normal debugging
+
Natural exception handling
+
Better scalability

And that is the real value of Project Loom’s Virtual Threads.

--

--

Suraj
Suraj

Written by Suraj

Technical Lead | 14+ yrs specializing in Java, Distributed Systems & System Design.