Virtual Threads (Project Loom)
What are Virtual Threads?
Virtual Threads are lightweight threads managed by the JVM, not the operating system.
Main advantages:
- Extremely lightweight – millions can exist concurrently
- JVM-managed – no 1:1 mapping to OS threads
- Carrier threads – virtual threads run on platform threads
- 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-1Carrier 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 coresHow it works:
- Virtual thread is created and assigned a task
- Scheduler assigns virtual thread to a free carrier thread
- On blocking (I/O, sleep), virtual thread is unmounted
- Carrier thread can execute another virtual thread
- 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:
- 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();
}- 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 threadsPerformance 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
-
Use virtual threads for I/O-intensive tasks
// ✅ Good for virtual threads executor.submit(() -> readFromFile()); executor.submit(() -> callExternalAPI()); executor.submit(() -> queryDatabase()); -
Platform threads for CPU-intensive tasks
// ✅ Better with platform threads ForkJoinPool.commonPool().submit(() -> complexCalculation()); -
Avoid pinning (before JDK 24)
// ❌ Bad synchronized(lock) { blockingOperation(); } // ✅ Good ReentrantLock lock = new ReentrantLock(); lock.lock(); try { blockingOperation(); } finally { lock.unlock(); } -
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(); -
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.