☀️ 🌙

Java 21 Virtual Threads: Yay or Nay!

Oct 2025 · 10 min read
TL;DR: I spent years building non-blocking, asynchronous apps with Spring Reactor. But what if there was an easier way? Imagine a humble Spring Boot app that wasn't built to scale. This guide walks through a familiar scaling problem and shows how virtual threads solve it with minimal code changes. 25x performance boost, way less complexity.

1. Scenario

In an ecommerce platform selling Black Friday deals, the order processing API has been showing degraded performance due to a large number of users scaling on the platform. Panic ensues and the platform breaks for everyone!

Datadog reveals the root cause, and we spot that every request has been doing three blocking calls: check inventory (50ms), process payment (100ms), send email (80ms). That's 230ms of just waiting and wasting resources. With platform threads, each request needs its own OS thread. Quick math: 10,000 requests = 10,000 threads = 10GB RAM. But wait—our server has only 8GB!

Traditional Platform Threads (1:1 Mapping)
10K Requests
10K Threads
~10GB RAM
💥 Crash
// Simple blocking code - but doesn't scale @GetMapping("/order/{id}") public Order processOrder(String id) { Inventory inv = inventoryService.check(id); // 50ms Payment pay = paymentService.process(id); // 100ms emailService.send(id); // 80ms return new Order(inv, pay); }

2. The Fix: Tactical vs. Strategic

Post-mortem confirmed it: our "one thread per request" model was broken at scale. Each waiting request held an expensive OS thread hostage for nothing.

Tactical fix? Scale hardware for now. It does work, but the cloud bill is simply not sustainable.

Strategic options? Two paths:

  • Go Reactive: Rewrite everything to async/non-blocking. Powerful but complex—would take months.
  • Fix Concurrency: Keep our simple blocking code, just make it scale better.

Enter Java 21 Virtual Threads.

3. Strategic Solution: Virtual Threads

Virtual threads are definitely an option on the table. They're not real OS threads—the JVM just pretends they are. You can create millions of them because each one costs a few KB, not a few MB. They all share a small pool of actual OS threads underneath (this is called M:N mapping, if you care about the terminology).

Think of it like this: instead of hiring 10,000 workers, you hire 10 really good workers who can juggle 1,000 tasks each. Same work done, way less overhead.

Virtual Threads (M:N Mapping)
1M V-Threads
Few OS Threads
~500MB RAM
✅ Scales

The fix? Change one word:

// BEFORE: Thread.ofPlatform().start(() -> processOrder(id)); // AFTER: Thread.ofVirtual().start(() -> processOrder(id));

Seriously. That's the whole migration.

Performance Comparison

Metric Platform Threads Virtual Threads
Max Concurrent Users ~5,000 1,000,000+
Memory Usage ~5GB ~500MB
Throughput 2,000 req/s 50,000 req/s

How It Works

Here's where it gets cool. When your code hits a blocking I/O call (like waiting for a database), the virtual thread unmounts from its carrier thread. That carrier thread? It immediately picks up another virtual thread that's ready to run. When your I/O finishes, your virtual thread hops back onto whatever carrier is free.

You still write normal blocking code—no callbacks, no reactive nonsense. But under the hood, you're getting async performance. It's honestly kind of brilliant.

4. Getting Started

Basic Example

Simplest possible version:

// Create and start a virtual thread Thread vThread = Thread.ofVirtual().start(() -> { System.out.println("Running in virtual thread"); }); vThread.join();

With Executors

More realistic example with an executor (this is what you'll actually use):

// Virtual thread executor ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); executor.submit(() -> { // Your blocking I/O code here String data = httpClient.get(url); database.save(data); });

Spring Boot Integration

Using Spring Boot? Even easier. Add this line to your config:

# application.properties (Spring Boot 3.2+) spring.threads.virtual.enabled=true

That's literally it. Spring handles everything else.

5. Conclusion

Virtual threads are fantastic, but not universal. Here's when to migrate and when to hold off:

✅ Migrate If... ❌ Don't Migrate If...
You're on Java 21+ You're stuck on Java 17 or older
Your workload is I/O-bound (APIs, databases, HTTP calls) Your workload is CPU-bound (data processing, cryptography)
You're starting a new project (greenfield) You have a legacy codebase full of synchronized blocks
You want to simplify reactive code You can't audit/refactor ThreadLocal usage
You're using Spring Boot 3.2+ Your dependencies don't support virtual threads yet

Final verdict: For new I/O-heavy projects on Java 21, this is one of the easiest wins you'll get. For existing systems, it's a clear path away from reactive complexity—but audit first.

Have a look at some examples on GitHub if you want to copy-paste and experiment.

6. Try It Yourself

Hit 'Run' below and watch platform threads struggle while virtual threads cruise through 1,000 concurrent tasks:

💡 Modify the code above or paste any example from this tutorial to experiment.

💻 Try It Yourself