Thread Wars: Episode 3 – Rise of the Virtual Threads

HarshavardhananHarshavardhanan
10 min read

We started with chaos.
Platform threads choking under load. Reactive code spiraling out of control. Concurrency that scaled — but only if you rewrote your entire app and sacrificed your stack traces.

Then came virtual threads — and the war turned.

You could write simple, readable, blocking code again — and it scaled.
You didn’t need to ration threads. You didn’t need flatMap().
You just... wrote code.

But here’s the truth:
Virtual threads are powerful. But power without structure is just another thread leak waiting to happen.

In this final chapter, we move beyond the “wow” and into the how:

  • What real-world performance looks like

  • How structured concurrency keeps things sane

  • Where virtual threads shine — and where they still fail

  • What changes in production when you adopt them

This isn’t a victory lap.
It’s the rise of a new default — and the discipline needed to wield it.


1> Real-World Benchmarks – What to Expect

Let’s get something straight:
Virtual threads won’t make your code faster — they make concurrency cheaper.

That means:

  • Higher throughput under blocking workloads

  • Lower memory usage per thread

  • Reduced complexity in orchestration

Here’s what shifts when you switch.


1. Memory Footprint

Platform threads:

  • ~1MB stack pre-allocated per thread

  • Multiply that by 10K requests? Good luck

Virtual threads:

  • Stack lives on the heap, not pre-allocated

  • Starts small (~few KB), grows as needed

  • JVM garbage collects unused parts

📉 Result: 10x–100x reduction in memory usage under high concurrency


2. Startup & Scheduling Cost

Platform threads:

  • Costly to start

  • Context switching hits performance under load

Virtual threads:

  • JVM reuses lightweight carrier threads

  • Scheduling is cooperative

  • You can start millions of virtual threads in milliseconds


3. Throughput Under Blocking I/O

In I/O-bound workloads (JDBC, file access, HTTP):

  • Virtual threads don’t block carrier threads

  • JVM can suspend and remount without OS-level context switches

  • Threads spend less time idling, more time doing real work

📈 Expect smoother scaling under load with fewer rejections and timeouts


4. Latency & Responsiveness

Virtual threads aren’t inherently faster — but:

  • No thread pool contention

  • No async queuing

  • Lower GC pressure (if stack memory stays lean)

This leads to:

  • More consistent latencies under load

  • Fewer edge-case slowdowns due to queue overflow or pool saturation


5. Benchmarks

Use CaseThroughput GainLatency ImprovementMemory / CPU EfficiencyNotes
CPU-heavy tasks~2× speed (at scale)Ali Behzadian benchmark (Medium, Medium)
I/O-heavy workloads+60% throughput–28.8% latency–36% memory, –14% CPUMaster’s thesis (NORMA@NCI Library)
Sleep/I/O-bound tasksFinish 1 k tasks in ~5 s~88% fasterMinimal memory/CPU pressureMedium benchmark (Medium, Reddit)
CPU-bound server logic–10–40% throughputMixedLiberty/InfoQ caveat (InfoQ)

2> Structured Concurrency – The Secret Weapon

Virtual threads solved thread cost.
Structured concurrency solves thread chaos.

Spawning millions of threads is easy now.
Managing them? That’s where most teams trip.


What Is Structured Concurrency?

It’s a simple idea with big consequences:

“When you spawn threads to do related work — treat them as a unit.”

If one fails, the others should be cancelled.
If one hangs, there should be a timeout.
When they complete, you should be able to collect all their results without guesswork.

Structured concurrency enforces scoped lifecycles — threads are started, managed, and torn down within a well-defined boundary.


Without Structure — The Classic Mess

executor.submit(() -> fetchUser());
executor.submit(() -> fetchOrders());
executor.submit(() -> fetchWishlist());
// now what? wait? timeout? cancel?

You end up juggling CountDownLatch, Future.get(), ExecutorShutdown, and silent failures in long-running threads.


With Structured Concurrency

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<String> user = scope.fork(() -> fetchUser());
    Future<String> orders = scope.fork(() -> fetchOrders());

    scope.join();   // wait for both
    scope.throwIfFailed(); // bubble up if any failed

    return user.resultNow() + orders.resultNow();
}

What you get:

  • Automatic cancellation if one task fails

  • Clean exception bubbling

  • Thread lifecycle tied to block scope

  • All results guaranteed or cleanly aborted

  • No thread leaks, dangling futures, or weird races


Built for Virtual Threads

  • Structured concurrency assumes you're not micromanaging threads

  • No need to pool or reuse — just spawn and scope

  • The StructuredTaskScope works great with Executors.newVirtualThreadPerTaskExecutor()

This is where Java finally catches up to what Goroutines and Kotlin coroutines offered for years — safe concurrency with composability.


Bottom line?
Virtual threads make blocking safe.
Structured concurrency makes parallelism reliable.

Without structure, you’re just spawning prettier chaos.


3> Gotchas and Limitations in Production

Virtual threads are powerful — but they don’t remove engineering discipline. They just move the failure points.

Here’s what can still go wrong when you push them into production without understanding the edges.


1. Pinned Threads Can Wreck Scalability

Virtual threads are only lightweight when they’re not pinned.
Pinned = stuck to a carrier thread. When does that happen?

  • When you enter native code (JNI, file locks, socket reads not managed by the JVM)

  • When you enter a synchronized block or method

While pinned:

  • The virtual thread cannot be unmounted

  • It blocks a carrier thread

  • You lose all the concurrency benefits

🙅‍♂️ Avoid:

synchronized (this) {
    Thread.sleep(1000); // yikes — this pins the carrier
}

2. Misusing ThreadLocal

Virtual threads support ThreadLocal, but:

  • They are not reused, so thread-local state doesn't persist across tasks

  • Forgetting to clean up = memory leak

  • Passing ThreadLocal across structured scopes is fragile

✅ Prefer Scoped Values (Java 21 feature) — cleaner, explicitly passed, context-safe.


3. Mixing Virtual and Platform Threads

Don’t blend them unless you know what you’re doing.

  • Virtual threads in platform thread pools ≠ benefit

  • Platform threads in virtual thread pools = confusion

  • Metrics and logs will lie to you if you mix contexts blindly

Keep task execution models consistent per service.


4. Monitoring Tools May Not Be Ready

  • Legacy profilers and thread dump tools may miss virtual threads

  • JVM exposes them via JFR and jcmd, but tooling needs updates

  • Your dashboards might show fewer threads than actually running

  • Blocking or pinning events may go undetected unless instrumented correctly

✅ Upgrade observability stack before rollout.


5. Not a Fit for CPU-Bound Parallelism

If your service is CPU-heavy (image processing, encryption, ML inference):

  • Virtual threads give no performance boost

  • You’re limited by core count, not thread count

  • Use traditional parallel constructs (ForkJoinPool, parallelStream, etc.)

Virtual threads are a weapon for I/O-bound concurrency — not brute force compute.


Don’t treat virtual threads like magic.
Treat them like sharp tools — fast, scalable, and very easy to misuse.


4> Best Practices for Adoption

Virtual threads are ready for production — but your code might not be.
Here’s how to adopt them without breaking things or misleading your team.


1. Use Executors.newVirtualThreadPerTaskExecutor()

This is the simplest, safest way to start:

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> {
    // blocking I/O
});

No thread pool tuning. No queue sizing. Just task-per-thread.
Use this in services that are high-concurrency, I/O-bound, and request-scoped.


2. Start Small — Pick the Right Services

Begin rollout in:

  • Notification systems

  • File processors

  • Async workers and polling tasks

  • Read-heavy services with predictable I/O

Avoid starting with:

  • Core transactional systems

  • High-throughput CPU-bound services

  • Anything heavily synchronized or native-JNI-bound


3. Don’t Retrofit Just to “Use Virtual Threads”

If your current code is:

  • already async and reactive

  • using tuned thread pools for CPU tasks

  • tightly scoped and performing well

…then leave it.
Virtual threads aren't about rewriting working code — they're about removing the need for reactive workarounds going forward.


4. Eliminate synchronized and JNI Wrappers Where Possible

Audit for:

  • synchronized blocks or methods (especially around blocking code)

  • Native libraries doing file locks, socket access, or untracked I/O

These pin virtual threads to carrier threads and destroy your scalability.

✅ Use:

  • ReentrantLock

  • Scoped Values

  • StructuredTaskScope with timeouts and cancellation


5. Prepare Your Observability Stack

Update:

  • JVM metrics (thread count, pool activity)

  • Logging frameworks (map task scope to correlation IDs)

  • Profilers and alerting tools (watch for pinned threads, not thread count)

Test under load — virtual thread behavior can mask bottlenecks unless explicitly traced.


6. Educate Your Team Before You Migrate

This isn't just a new executor — it's a new concurrency model.

Make sure devs know:

  • When to use virtual threads

  • When not to

  • How to structure parallel flows with StructuredTaskScope

  • How not to get lured back into thread micro-management


5> Observability & Debugging with Virtual Threads

Virtual threads don’t just change how your app runs — they change how you see it.

If your monitoring, logging, or alerting pipeline treats threads as your primary signal, you’ll miss things unless you adapt.


1. Thread Dumps Look Different

  • Virtual threads appear in thread dumps, but are grouped differently (by carrier)

  • Expect many more threads in dumps — don’t panic

  • Tools like jcmd, VisualVM, and JFR can show you pinned threads (but not all by default)

✅ Use:

cmd <pid> Thread.dump_to_file filename=...

Watch for:

  • # carrier thread vs # virtual thread

  • Threads stuck in RUNNABLE but not progressing

  • Pinned status on blocking code inside synchronized sections


2. Metrics Need Rethinking

If you're tracking:

  • Thread pool queue length

  • Active thread count

  • Executor saturation levels

…you’ll need to adjust.

Why?

  • Virtual thread executors don’t expose those metrics — they don’t queue or cap

  • You may have 100k threads running and no visible queue buildup

✅ Instead, track:

  • Request durations

  • Structured scope success/fail rates

  • Number of concurrent scopes running

  • Time spent pinned (if exposed via JFR or tracing hooks)


3. Logs May Mislead You

With structured concurrency and per-task execution:

  • Thread names change more often

  • Logging MDC (ThreadLocal) won’t carry context unless explicitly scoped

  • Log correlation by thread name becomes unreliable

✅ Use:

  • Scoped Values to pass context

  • Explicit correlation IDs

  • Structured logs tied to logical scopes, not thread identity


4. Debugging Gets Easier — Mostly

✅ What works again:

  • Stack traces are back (goodbye async black holes)

  • Breakpoints hit like normal

  • Exceptions bubble cleanly through StructuredTaskScope

⚠️ What still hurts:

  • Identifying which thread is pinned and why

  • Debugging third-party libraries that use synchronization or JNI under the hood


5. Profiling Tools Are Catching Up

Most JVM profilers (YourKit, JFR, VisualVM) now support virtual threads — but not all do equally well.

  • Some tools ignore carrier thread contention

  • Some misreport CPU time for suspended threads

  • Flame graphs may misrepresent lifecycle transitions

✅ Stick to:

  • JDK 21+

  • JFR event stream

  • Tools that differentiate between pinned and unmounted threads


Virtual threads don’t just change your execution model — they change your visibility model.

If you treat them like platform threads, your dashboards will lie to you.
But if you wire up your tooling with task scopes, structured lifecycles, and real correlation, you’ll see exactly what’s going on — even when you’re spawning 100,000 threads an hour.


6> The Future of Java Concurrency – Closing Thoughts

This isn’t just the rise of virtual threads.

It’s the fall of a 20-year workaround culture.

For years, we built:

  • Thread pools to babysit blocking code

  • Reactive pyramids to sidestep thread starvation

  • Async chains that no one could debug after 3 weeks

We survived on control — but lost readability.
Virtual threads change that.


What We’re Leaving Behind

  • Tuning corePoolSize like it’s sacred geometry

  • Wrapping I/O in CompletableFuture.supplyAsync()

  • Chaining .flatMap().onErrorResume().subscribe() and pretending it’s clean


What We’re Gaining

  • Code that looks like it reads

  • Concurrency that scales without acrobatics

  • Thread-per-request as a viable, safe default

Virtual threads aren’t a silver bullet.
But they restore something we’ve missed for years: clarity without cost.


What's Next

  • Structured concurrency is the real paradigm shift

  • Scoped values will replace ThreadLocal clutter

  • More libraries (HTTP, JDBC, Redis clients) will become virtual-thread aware

  • Java’s concurrency story is becoming modern — not just fast, but human-friendly


End of Thread Wars

From the collapse of thread pools…
To the chaos of reactive…
To the clarity of structured virtual threads...

You’ve seen the war.
You’ve seen the shift.
Now it’s time to rewrite your concurrency — not around limitation, but with intention.

May the Throughput be with you…

0
Subscribe to my newsletter

Read articles from Harshavardhanan directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Harshavardhanan
Harshavardhanan