Chaturmind
LearnDSASystem DesignDevOpsEngineering GrowthBlog
Start learning
Chaturmind

Structured learning paths for engineers who want to go deep. Written by practitioners.

Learn

  • Java
  • DSA
  • System Design
  • Spring Boot
  • AI / ML
  • DevOps
  • Engineering Growth

Company

  • Blog
  • Contact

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Chaturmind. All rights reserved.

Built for engineers who want to go deep.


← Java 21 — New Features

Data-Oriented Programming

  • Records
  • Sealed Classes
  • Pattern Matching

Virtual Threads (Project Loom)

  • Virtual Threads
Chaturmind
← Java 21 — New Features

Data-Oriented Programming

  • Records
  • Sealed Classes
  • Pattern Matching

Virtual Threads (Project Loom)

  • Virtual Threads
HomeLearnJavaJava 21 — New FeaturesVirtual Threads (Project Loom)
✓ FreeAdvanced· 12 min read

Virtual Threads

Millions of cheap threads — how Project Loom changes Java server-side concurrency.

Published September 21, 2026


Virtual Threads (Java 21)

The Problem with Platform Threads

A traditional Java thread maps 1:1 to an OS thread. OS threads are expensive:

  • ~1 MB of stack memory each
  • Context switches are OS-level (slow)
  • A server with 200 concurrent requests needs 200 OS threads

This is why Node.js and reactive frameworks (WebFlux) were invented — to handle more concurrent requests without more threads.

Virtual Threads — the solution

Virtual threads are lightweight, JVM-managed threads:

  • ~1 KB of heap per virtual thread (vs ~1 MB for platform threads)
  • Scheduled by the JVM on a small pool of carrier threads
  • When a virtual thread blocks (I/O, sleep), the carrier thread is unmounted and used for another virtual thread
// 100,000 virtual threads — this works
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 100_000).forEach(i ->
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1)); // blocks virtual thread, not OS thread
            System.out.println("Done: " + i);
        })
    );
}
// Completes in ~1 second — all 100K sleep concurrently

Enabling in Spring Boot

spring:
  threads:
    virtual:
      enabled: true

With this property, Spring Boot replaces its Tomcat thread pool with virtual threads. Each incoming HTTP request runs on its own virtual thread.

When Virtual Threads Help

✅ I/O-bound workloads: REST calls, DB queries, file reads — virtual threads shine here

❌ CPU-bound workloads: image processing, cryptography — virtual threads don't help because the carrier thread is occupied the entire time

Structured Concurrency (Java 21 preview)

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<User>  user  = scope.fork(() -> fetchUser(userId));
    Future<Order> order = scope.fork(() -> fetchOrder(orderId));
    scope.join();   // wait for both
    scope.throwIfFailed();
    return new Response(user.resultNow(), order.resultNow());
}

Structured concurrency ensures child tasks are cleaned up when the parent scope exits — no more fire-and-forget threads.

Thread.ofVirtual() — the explicit builder API

Thread vt = Thread.ofVirtual()
    .name("worker-", 0)   // name prefix + auto-incrementing counter
    .unstarted(() -> doWork());
vt.start();

// Or start immediately:
Thread.ofVirtual().start(() -> doWork());

Executors.newVirtualThreadPerTaskExecutor() (shown above) is the pool-shaped entry point; Thread.ofVirtual() is the lower-level builder for creating individual virtual threads directly, useful when you want a named, one-off virtual thread outside an executor's task-submission model.

Pinning — the full picture

A virtual thread is pinned to its carrier thread (can't be unmounted while blocked) in two situations: while executing inside a synchronized block or method, and while executing a native method or a foreign-function call. A pinned virtual thread blocks its carrier for the duration — if enough virtual threads pin simultaneously, you can exhaust the small carrier-thread pool and lose the scalability benefit entirely, silently. The fix for the synchronized case is switching to ReentrantLock (see synchronized and Locks), which does not pin, since it's implemented without relying on the JVM monitor mechanism that causes pinning.

Migration considerations: assumptions that break

Thread-pool-sizing formulas (see the CPU-bound vs I/O-bound formula in ExecutorService & Thread Pools) assume threads are an expensive, limited resource to be carefully rationed — that assumption is specifically why platform-thread pools are capped. Virtual threads invert this: they're cheap enough (~1 KB each) that you're meant to create one per task, uncapped, rather than pooling and reusing them — newVirtualThreadPerTaskExecutor() deliberately does not limit concurrency the way newFixedThreadPool(n) does. Code that was written assuming ExecutorService submission implies bounded concurrency (a natural backpressure mechanism) can silently lose that backpressure when swapped to a virtual-thread executor — if unbounded task submission was relying on the pool's fixed size to throttle callers, that throttling needs to be reintroduced explicitly (e.g. a Semaphore) rather than assumed from the executor.

Interview Tip

"Virtual threads eliminate the need for reactive programming for I/O-bound workloads. They let you write blocking-style synchronous code that performs like async code — without the complexity of CompletableFuture chains or WebFlux."

Key caveat: avoid synchronised blocks with virtual threads. synchronized pins the virtual thread to its carrier thread, negating the benefit. Use ReentrantLock instead.

Previous

Pattern Matching

AI Tutor

Lesson: Virtual Threads

Quick actions

AI responses can be inaccurate. Verify critical information.