RU | EN | DE

Virtual Threads (Project Loom)

What are Virtual Threads?

Virtual Threads are lightweight threads managed by the JVM, not the operating system.

Main advantages:

  1. Extremely lightweight – millions can exist concurrently
  2. JVM-managed – no 1:1 mapping to OS threads
  3. Carrier threads – virtual threads run on platform threads
  4. Automatic unmounting – when blocking, virtual thread is removed from carrier thread

Platform Threads vs Virtual Threads

// Platform Thread (traditional)
Thread platformThread = new Thread(() -> {
    // Blocks OS thread
    Thread.sleep(1000);
});
platformThread.start();
 
// Virtual Thread
Thread virtualThread = Thread.ofVirtual().start(() -> {
    // Blocks only virtual thread, not carrier
    Thread.sleep(1000);
});

Platform Threads:

  • 1:1 mapping to OS threads
  • Each ~1 MB stack memory
  • Limited (thousands)
  • Blocking locks OS thread

Virtual Threads:

  • N:M mapping (many virtual to few platform)
  • Minimal memory overhead
  • Millions possible
  • Blocking only locks virtual thread

Creating Virtual Threads

// Method 1: Thread.ofVirtual()
Thread vt1 = Thread.ofVirtual().start(() -> {
    System.out.println("Virtual Thread 1");
});
 
// Method 2: Thread.startVirtualThread()
Thread vt2 = Thread.startVirtualThread(() -> {
    System.out.println("Virtual Thread 2");
});
 
// Method 3: Thread.Builder
Thread vt3 = Thread.ofVirtual()
    .name("my-virtual-thread")
    .unstarted(() -> {
        System.out.println("Virtual Thread 3");
    });
vt3.start();
 
// Method 4: ExecutorService
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> {
        System.out.println("Virtual Thread 4");
    });
}

Thread Groups and Naming

// Thread group for virtual threads
AtomicInteger counter = new AtomicInteger(0);
 
Thread.Builder builder = Thread.ofVirtual()
    .name("worker-", counter.getAndIncrement());
 
Thread t1 = builder.unstarted(() -> task());
Thread t2 = builder.unstarted(() -> task());
 
System.out.println(t1.getName()); // worker-0
System.out.println(t2.getName()); // worker-1

Carrier Threads

Carrier Threads are platform threads that execute virtual threads.

// ForkJoinPool is used as carrier pool by default
ForkJoinPool carrierPool = ForkJoinPool.commonPool();
System.out.println("Carrier threads: " + carrierPool.getParallelism());
// Usually = number of CPU cores

How it works:

  1. Virtual thread is created and assigned a task
  2. Scheduler assigns virtual thread to a free carrier thread
  3. On blocking (I/O, sleep), virtual thread is unmounted
  4. Carrier thread can execute another virtual thread
  5. When I/O completes, virtual thread is mounted again

Little’s Law

Little’s Law helps calculate system throughput:

L = λ × W

L = average number of requests in system
λ = arrival rate (requests per second)  
W = average time in system (latency)

Example:

If: 1000 RPS and average latency 100ms
Then: L = 1000 × 0.1 = 100 concurrent requests

With Platform Threads: need at least 100 threads
With Virtual Threads: can handle with fewer carrier threads

Practical Example: HTTP Server

// Old way with Platform Threads
try (var executor = Executors.newFixedThreadPool(200)) {
    ServerSocket serverSocket = new ServerSocket(8080);
    while (true) {
        Socket client = serverSocket.accept();
        executor.submit(() -> handleClient(client));
    }
}
 
// New way with Virtual Threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    ServerSocket serverSocket = new ServerSocket(8080);
    while (true) {
        Socket client = serverSocket.accept();
        executor.submit(() -> handleClient(client));
    }
}
 
void handleClient(Socket client) {
    try (var in = new BufferedReader(new InputStreamReader(client.getInputStream()));
         var out = new PrintWriter(client.getOutputStream())) {
        
        String request = in.readLine();
        // I/O blocking - virtual thread gets unmounted
        String response = processRequest(request);
        out.println(response);
    }
}

Rate Limiting with Semaphore

public class RateLimitedService {
    private static final int MAX_CONCURRENT = 10;
    private static final Semaphore semaphore = new Semaphore(MAX_CONCURRENT);
    
    public String processRequest(String request) throws InterruptedException {
        semaphore.acquire(); // Wait if limit reached
        try {
            // Actual processing
            return doWork(request);
        } finally {
            semaphore.release();
        }
    }
    
    private String doWork(String request) {
        // Simulate I/O
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return "Processed: " + request;
    }
}
 
// Usage with Virtual Threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    RateLimitedService service = new RateLimitedService();
    
    for (int i = 0; i < 100; i++) {
        int requestId = i;
        executor.submit(() -> {
            try {
                String result = service.processRequest("Request-" + requestId);
                System.out.println(result);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
    }
}

Pinning – When Virtual Threads Get “Stuck”

Pinning occurs when a virtual thread cannot be unmounted from the carrier thread.

Causes of pinning:

  1. synchronized blocks (before JDK 24)
// ❌ Causes pinning (before JDK 24)
synchronized(lock) {
    Thread.sleep(1000); // Carrier thread blocks!
}
 
// ✅ Solution: Use ReentrantLock
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    Thread.sleep(1000); // Virtual thread can unmount
} finally {
    lock.unlock();
}
  1. Native methods or foreign code
// Native calls can cause pinning
native void someNativeCall();

JDK 24+ Improvement

From JDK 24, synchronized no longer causes pinning!

// In JDK 24+ this is OK
synchronized(lock) {
    Thread.sleep(1000); // No pinning!
}

Practical Example: Database Queries

public class DatabaseService {
    private final DataSource dataSource;
    private final ReentrantLock lock = new ReentrantLock();
    
    public List<User> getUsers() {
        lock.lock();
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users");
             ResultSet rs = stmt.executeQuery()) {
            
            List<User> users = new ArrayList<>();
            while (rs.next()) {
                users.add(mapUser(rs));
            }
            return users;
        } catch (SQLException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
}
 
// Parallel database queries with Virtual Threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    DatabaseService db = new DatabaseService();
    
    CompletableFuture<List<User>> usersFuture = 
        CompletableFuture.supplyAsync(db::getUsers, executor);
    
    CompletableFuture<List<Order>> ordersFuture = 
        CompletableFuture.supplyAsync(db::getOrders, executor);
    
    CompletableFuture<List<Product>> productsFuture = 
        CompletableFuture.supplyAsync(db::getProducts, executor);
    
    CompletableFuture.allOf(usersFuture, ordersFuture, productsFuture).join();
    
    Dashboard dashboard = new Dashboard(
        usersFuture.join(),
        ordersFuture.join(),
        productsFuture.join()
    );
}

Structured Concurrency (Preview Feature)

// Structured concurrency - all tasks managed together
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    
    Future<User> userFuture = scope.fork(() -> fetchUser(userId));
    Future<List<Order>> ordersFuture = scope.fork(() -> fetchOrders(userId));
    Future<Address> addressFuture = scope.fork(() -> fetchAddress(userId));
    
    scope.join();           // Wait for all
    scope.throwIfFailed();  // Throw if any failed
    
    return new UserProfile(
        userFuture.resultNow(),
        ordersFuture.resultNow(),
        addressFuture.resultNow()
    );
} // Automatic cleanup of all threads

Performance Comparison

public class PerformanceTest {
    
    // Platform Threads
    public void testPlatformThreads() throws InterruptedException {
        long start = System.currentTimeMillis();
        
        try (var executor = Executors.newFixedThreadPool(1000)) {
            for (int i = 0; i < 100_000; i++) {
                executor.submit(() -> {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                });
            }
        }
        
        long duration = System.currentTimeMillis() - start;
        System.out.println("Platform threads: " + duration + "ms");
    }
    
    // Virtual Threads
    public void testVirtualThreads() throws InterruptedException {
        long start = System.currentTimeMillis();
        
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 100_000; i++) {
                executor.submit(() -> {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                });
            }
        }
        
        long duration = System.currentTimeMillis() - start;
        System.out.println("Virtual threads: " + duration + "ms");
    }
}
 
// Typical results:
// Platform threads: ~1000-2000ms (limited by thread pool)
// Virtual threads: ~100-200ms (millions of virtual threads possible)

Best Practices

  1. Use virtual threads for I/O-intensive tasks

    // ✅ Good for virtual threads
    executor.submit(() -> readFromFile());
    executor.submit(() -> callExternalAPI());
    executor.submit(() -> queryDatabase());
  2. Platform threads for CPU-intensive tasks

    // ✅ Better with platform threads
    ForkJoinPool.commonPool().submit(() -> complexCalculation());
  3. Avoid pinning (before JDK 24)

    // ❌ Bad
    synchronized(lock) { blockingOperation(); }
     
    // ✅ Good
    ReentrantLock lock = new ReentrantLock();
    lock.lock();
    try { blockingOperation(); } finally { lock.unlock(); }
  4. Use ThreadLocal with caution

    // ⚠️ ThreadLocal with millions of virtual threads = memory problem
    ThreadLocal<HeavyObject> threadLocal = new ThreadLocal<>();
     
    // ✅ Better: Use ScopedValue (Preview)
    ScopedValue<HeavyObject> scopedValue = ScopedValue.newInstance();
  5. Structured concurrency for related tasks

    // ✅ All tasks managed together
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        var f1 = scope.fork(task1);
        var f2 = scope.fork(task2);
        scope.join();
        return combine(f1.resultNow(), f2.resultNow());
    }

When to Use Virtual Threads?

✅ Use virtual threads for:

  • Web servers (many concurrent connections)
  • Microservices (I/O intensive)
  • Database queries
  • External API calls
  • File I/O
  • Message queues

❌ Avoid virtual threads for:

  • CPU-bound computations
  • Tight loops without I/O
  • Already optimized reactive code
  • Very long-lived threads

Migration from Platform to Virtual Threads

// Before
ExecutorService oldExecutor = Executors.newFixedThreadPool(100);
 
// After - simple replacement!
ExecutorService newExecutor = Executors.newVirtualThreadPerTaskExecutor();
 
// Usage is identical
newExecutor.submit(() -> handleRequest());

Conclusion

Virtual threads are a game-changer for Java applications:

  • Simpler code than reactive programming
  • Better resource utilization than thread pools
  • Higher throughput for I/O-intensive apps
  • Seamless migration from existing code

From Java 21, they are production-ready and should be considered for most server applications.