Skip to content

Virtual Threads (Project Loom) — Complete Guide

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about Virtual Threads (Project Loom). We cover key concepts, practical examples, and best practices to help you master this topic.

The Problem with Traditional Threads

Java's threading model has served developers well for decades, but platform threads (also called OS threads) are a finite, expensive resource. Each platform thread consumes around 1 MB of stack memory and requires a costly OS syscall to create. A typical server can handle only a few thousand platform threads before performance degrades significantly. This limitation forced developers to use asynchronous programming patterns with callbacks, futures, or reactive frameworks, which introduced complexity and made code harder to read and debug.

Virtual threads solve this problem by decoupling the abstraction of a thread from the OS thread that runs it. A virtual thread is a lightweight thread managed by the JVM rather than the operating system. Millions of virtual threads can exist in a single application, each consuming only a few hundred bytes. They enable writing simple synchronous code that scales like asynchronous code, combining the best of both worlds.

flowchart LR
    A[Concurrency Need] --> B[Platform Threads
1:1 with OS] A --> C[Virtual Threads
M:1 with OS] B --> D[Limited ~1MB stack
Thousands max] C --> E[Tiny ~KB stack
Millions possible] E --> F[Simple synchronous code
Async scalability]

What Are Virtual Threads?

A virtual thread is an instance of java.lang.Thread that is not tied to a specific OS thread. The JVM schedules virtual threads onto a small pool of carrier threads (platform threads). When a virtual thread blocks on an I/O operation, the JVM unmounts it from the carrier thread and mounts another virtual thread, allowing the carrier thread to stay busy. This mount-unmount cycle happens transparently.

Creating Virtual Threads

Java 21 provides several ways to create virtual threads.

// Using Thread.ofVirtual()
Thread vThread = Thread.ofVirtual()
    .name("my-virtual-thread")
    .start(() -> {
        System.out.println("Hello from " + Thread.currentThread());
    });

// Using Thread.startVirtualThread()
Thread vThread2 = Thread.startVirtualThread(() -> {
    System.out.println("Quick virtual thread");
});

// Using Executors.newVirtualThreadPerTaskExecutor()
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> {
        System.out.println("Virtual thread from executor");
    });
}

vThread.join();
vThread2.join();

Output:

Hello from VirtualThread[my-virtual-thread]/runnable@ForkJoinPool-1-worker-1
Quick virtual thread
Virtual thread from executor

Checking if a Thread is Virtual

Thread t = Thread.ofVirtual().start(() -> {});
System.out.println("Is virtual? " + t.isVirtual());
t.join();

Output:

Is virtual? true

Virtual vs Platform Threads

The key differences between platform and virtual threads affect how you design concurrent applications.

Aspect Platform Thread Virtual Thread
Creation cost Expensive (syscall) Cheap (JVM-managed)
Stack size ~1 MB ~10 KB (grows as needed)
Max count Thousands Millions
Tied to OS thread Yes No (mounts/unmounts)
CPU-bound tasks Good Good (same carrier pool)
I/O-bound tasks Wastes carrier thread Excellent (unmounts on block)

When to Use Virtual Threads

Virtual threads excel at I/O-bound workloads where threads spend most of their time waiting. They are ideal for web servers, database access, REST API calls, and file operations.

// Simulating many I/O operations with virtual threads
var start = System.nanoTime();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            try {
                Thread.sleep(100); // Simulate I/O
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
    });
}
var end = System.nanoTime();
System.out.println("Completed 10,000 tasks in " +
    (end - start) / 1_000_000 + " ms");

Output (approximate):

Completed 10,000 tasks in 250 ms

The same workload with platform threads would either fail with an out-of-memory error or take significantly longer due to thread creation overhead and context switching.

Structured Concurrency

Structured concurrency is a companion feature that treats groups of related tasks as a single Unit of Work. It ensures that if a subtask fails, all related subtasks are cancelled, preventing thread leaks and orphaned work.

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<String> user = scope.fork(() -> fetchUser(1));
    Future<String> orders = scope.fork(() -> fetchOrders(1));
    
    scope.join();           // Wait for all tasks
    scope.throwIfFailed();  // Propagate any failure
    
    System.out.println(user.resultNow());
    System.out.println(orders.resultNow());
}

Output:

User{id=1, name='Alice'}
Order{id=101, total=250.00}

Common Mistakes

1. Synchronized Blocks Pin Carrier Threads

If a virtual thread enters a synchronized block or method, it pins the carrier thread, preventing unmounting during blocking operations.

// Problematic: synchronized blocks pin carrier threads
private final Object lock = new Object();

void doWork() {
    synchronized (lock) {
        try {
            Thread.sleep(1000); // Carrier thread is pinned!
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

Prefer ReentrantLock instead, which does not pin carrier threads.

2. Thread Pool Abuse with Virtual Threads

Using a thread pool with virtual threads defeats their purpose. Virtual threads should be created without pooling.

// Wrong: pooling virtual threads
ExecutorService pool = Executors.newFixedThreadPool(100);
for (int i = 0; i < 10_000; i++) {
    pool.submit(task); // Limits scalability
}

// Correct: per-task executor
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(task);
    }
}

3. ThreadLocal Memory Leaks

Virtual threads can outlive their intended scope. Using ThreadLocal with virtual threads can cause memory leaks because the JVM may cache virtual threads.

// Risky with virtual threads
ThreadLocal<byte[]> largeData = new ThreadLocal<>();
largeData.set(new byte[1024 * 1024]); // May not be GC'd promptly

4. Assuming Platform Thread Performance

Virtual threads are not faster for CPU-bound work. They share carrier threads from a fork-join pool (typically matching CPU core count).

// This will NOT run faster with virtual threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100; i++) {
        executor.submit(() -> computePi(10_000)); // CPU-bound
    }
}

5. Ignoring InterruptedException

Virtual threads respect interruption. Swallowing InterruptedException can leave threads in inconsistent states.

6. Mixing Platform and Virtual Threads in ThreadLocal

Values stored in ThreadLocal on a platform thread are not visible to virtual threads, and vice versa.

Practice Questions

  1. What is the primary advantage of virtual threads over platform threads for I/O-bound applications?
  2. How does the JVM handle a blocking operation inside a virtual thread?
  3. What happens when a virtual thread enters a synchronized block that performs blocking I/O?
  4. Why is thread pooling unnecessary (and counterproductive) for virtual threads?
  5. Describe a scenario where platform threads would outperform virtual threads.

Challenge: Write a program that creates 100,000 virtual threads, each making a simulated HTTP request (using Thread.sleep to simulate latency). Measure the total execution time. Then implement the same with platform threads and compare.

FAQ

What Java version introduced virtual threads?

Virtual threads were previewed in Java 19 and 20 and became generally available in Java 21 as part of Project Loom.

Do virtual threads improve CPU-bound performance?

No. Virtual threads excel at I/O-bound workloads. For CPU-bound tasks, platform threads perform equally well because virtual threads run on a limited pool of carrier threads.

Can virtual threads use ThreadLocal?

Yes, but with caution. ThreadLocal works with virtual threads, but the JVM may cache virtual threads, potentially causing memory leaks. Prefer scoped values (in preview) for better lifecycle management.

Are virtual threads compatible with existing libraries?

Most libraries work without changes. However, native libraries using JNI or JNA that expect a specific OS thread may not work correctly with virtual threads.

Should I replace all platform threads with virtual threads?

Not necessarily. Use virtual threads for I/O-bound workloads. For CPU-intensive tasks, long-running platform threads, or code that relies on synchronized blocks heavily, platform threads may still be appropriate.

Mini Project: High-Performance Web Scraper

Build a web scraper that fetches 1,000 URLs concurrently using virtual threads. The scraper should:

  • Accept a list of URLs and fetch each in a separate virtual thread
  • Parse HTML title tags from each response
  • Handle timeouts and failures gracefully
  • Report progress every 100 requests
  • Aggregate results into a map of URL to title

Use HttpClient.newHttpClient() (which works naturally with virtual threads) and Executors.newVirtualThreadPerTaskExecutor().

What's Next

You have mastered virtual threads, the most significant concurrency improvement in modern Java. In the next lesson, you will learn about the Java Platform Module System (JPMS) for building modular applications.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro