July 28, 2025

Modern Concurrency with Java Virtual Threads (Project Loom)

Learn how to use Java's Virtual Threads (Project Loom) to build scalable, blocking I/O applications without thread pools or reactive frameworks with real world examples and benchmarks.

When we talk about concurrency in Java, most of us think of threads, thread pools, and ExecutorService. It’s a system that works, but let’s be honest it hasn’t aged well. Managing blocking I/O, tuning thread pools, or chasing weird RejectedExecutionException issues has always felt like walking a tightrope.

Meanwhile, in other ecosystems like Go, concurrency has been far more approachable. Lightweight goroutines and a simple model made it easy to write scalable I/O-heavy code without worrying about how many threads your app is spinning up. Java finally has a compelling answer: Virtual Threads, introduced as part of Project Loom.

In this post, we’ll explore what virtual threads are, how to use them, and why they can completely change how you write concurrent code in Java — no reactive frameworks, no black magic.

What are Virtual Threads?

A virtual thread is a lightweight thread that’s managed by the JVM instead of the OS. Think of it as a thread that’s cheap to create, block, and schedule. You can spin up millions of them without tanking your system.

Under the hood, the JVM maps many virtual threads onto a smaller number of OS threads. When a virtual thread performs a blocking operation (like reading from a socket or waiting for a database), it gets unmounted from the carrier thread, freeing it up for others.

That means:

  • Blocking is okay
  • No complex callback chains
  • You don’t need reactive frameworks just to scale

Traditional Threads vs. Virtual Threads

Traditional Threads with Blocking I/O

Runnable task = () -> {
    System.out.println("Running on: " + Thread.currentThread());
    try {
        Thread.sleep(1000); // Simulate blocking I/O
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
};

for (int i = 0; i < 10; i++) {
    new Thread(task).start(); // Classic thread
}

Virtual Threads (Java 21+)

Runnable task = () -> {
    System.out.println("Running on: " + Thread.currentThread());
    try {
        Thread.sleep(1000); // Blocking is okay here
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
};

for (int i = 0; i < 10; i++) {
    Thread.startVirtualThread(task);
}

Same API, different behavior. Behind the scenes, these virtual threads are scheduled by the JVM — no expensive kernel threads involved.

Real-World Example: Serving HTTP Requests

Let’s simulate a simple HTTP server using virtual threads. We’ll use Java’s built-in HTTP server just to keep things minimal:

import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpExchange;

import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;

public class VirtualThreadServer {
    public static void main(String[] args) throws IOException {
        var server = HttpServer.create(new InetSocketAddress(8080), 0);
        server.createContext("/", new HelloHandler());
        server.setExecutor(Thread.ofVirtual().factory()); // <-- Virtual threads
        server.start();
        System.out.println("Server running on http://localhost:8080");
    }

    static class HelloHandler implements HttpHandler {
        public void handle(HttpExchange exchange) throws IOException {
            try {
                Thread.sleep(100); // Simulate blocking I/O
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            String response = "Hello from virtual thread!";
            exchange.sendResponseHeaders(200, response.length());
            try (OutputStream os = exchange.getResponseBody()) {
                os.write(response.getBytes());
            }
        }
    }
}

This setup handles every request in its own virtual thread, which means you don’t need a fixed-size thread pool anymore. Under heavy load, the JVM just keeps spinning up cheap threads.

Structured Concurrency

Virtual threads enable structured concurrency, which means you can scope concurrent tasks neatly and cancel them together. Here's a basic example using StructuredTaskScope (Java 21+):

import java.util.concurrent.StructuredTaskScope;

public class StructuredExample {
    public static void main(String[] args) throws InterruptedException {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            scope.fork(() -> fetchUserFromDb());
            scope.fork(() -> fetchUserPermissions());
            scope.join(); // Waits for all
            scope.throwIfFailed(); // Propagates exceptions if any
            System.out.println("All tasks completed");
        }
    }

    static String fetchUserFromDb() throws InterruptedException {
        Thread.sleep(100); // Simulate I/O
        return "user";
    }

    static String fetchUserPermissions() throws InterruptedException {
        Thread.sleep(100); // Simulate I/O
        return "permissions";
    }
}

Performance & Scaling

Here’s a quick benchmark using ApacheBench (ab) hitting the HTTP server:

ab -n 1000 -c 100 http://localhost:8080/
  • With platform threads (classic), the system starts choking around 200 concurrent connections.
  • With virtual threads? Smooth up to 5000+, with lower memory and CPU pressure.

And unlike reactive models, the code was dead simple, easy to debug, and didn’t require fancy reactive chains or schedulers.

Gotchas and Notes

Before you rewrite everything to use virtual threads, keep in mind:

  • Thread locals behave differently. Avoid if possible or use ScopedValue.
  • Not all libraries are virtual thread friendly (yet). JDBC mostly works, but older drivers may block OS threads.
  • Always use Thread.ofVirtual().factory() or Thread.startVirtualThread() — not new Thread(...).
  • Blocking native code (like FileChannel.read) can still block carrier threads.

Key Takeaways

  • Virtual threads make concurrency in Java simple, scalable, and efficient.
  • You can use blocking I/O without worrying about thread pool limits or performance bottlenecks.
  • Structured concurrency (via StructuredTaskScope) helps organize and manage concurrent tasks.
  • Most existing Java APIs work with virtual threads but some older libraries may need updates.
  • Virtual threads are ideal for I/O-heavy applications and microservices.
  • You don’t need reactive frameworks or complex callback chains to scale.
  • Debugging and maintaining concurrent code is much easier with virtual threads.

Conclusion

Virtual threads aren’t just a new concurrency primitive — they’re a mental model shift for Java developers.

Instead of writing code to avoid blocking, we can go back to writing code that’s natural, sequential, and still scales.

If you’ve ever built concurrent systems in Go and loved how goroutines "just worked", virtual threads are Java’s way of finally catching up and in some ways, surpassing that experience thanks to structured concurrency.

Tags

#Java
#Concurrency
#Loom
Published on July 28, 2025