8.1 Virtual Thread Fundamentals

Virtual threads, introduced as a preview in Java 19 and finalized in Java 21, represent a revolutionary change in Java's concurrency model. They enable highly scalable concurrent applications with simple, synchronous code.

What Are Virtual Threads?

Traditional Platform Threads:

  • Heavy-weight, one-to-one mapping with OS threads
  • Each thread consumes ~2MB of memory (default stack size)
  • Thread creation and context switching are expensive
  • Limited scalability (typically thousands of threads max)

Virtual Threads:

  • Light-weight, managed by JVM (not OS)
  • Minimal memory footprint (~few KB per thread)
  • Millions of virtual threads possible
  • Scheduled on a pool of carrier (platform) threads
  • Blocking operations don't block carrier threads
// Platform thread - traditional
Thread platformThread = new Thread(() -> {
    System.out.println("Platform thread: " + Thread.currentThread());
});
platformThread.start();

// Virtual thread - modern
Thread virtualThread = Thread.startVirtualThread(() -> {
    System.out.println("Virtual thread: " + Thread.currentThread());
});

Why Virtual Threads?

The Problem with Platform Threads:

// Traditional approach - limited scalability
ExecutorService executor = Executors.newFixedThreadPool(200);

// Can only handle 200 concurrent requests
for (int i = 0; i < 10_000; i++) {
    int taskId = i;
    executor.submit(() -> {
        // If this blocks for I/O, thread is wasted
        String data = fetchFromDatabase(taskId);
        processData(data);
    });
}
// Many tasks queued, waiting for available threads

Virtual Thread Solution:

// Virtual thread approach - scales to millions
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        int taskId = i;
        executor.submit(() -> {
            // Blocking is fine - virtual thread parks efficiently
            String data = fetchFromDatabase(taskId);
            processData(data);
        });
    }
} // All 10,000 tasks run concurrently!

Creating Virtual Threads

Method 1: Thread.startVirtualThread()

// Start and run immediately
Thread vt = Thread.startVirtualThread(() -> {
    System.out.println("Running in: " + Thread.currentThread());
    // do work
});

// Wait for completion
vt.join();

Method 2: Thread.ofVirtual()

// Build but don't start
Thread vt = Thread.ofVirtual()
    .name("worker-1")
    .unstarted(() -> {
        System.out.println("Virtual thread: " + Thread.currentThread().getName());
        // do work
    });

// Start later
vt.start();
vt.join();

Method 3: Virtual Thread per Task Executor

// Most common pattern for concurrent tasks
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // Submit multiple tasks
    Future<String> future1 = executor.submit(() -> fetchData(1));
    Future<String> future2 = executor.submit(() -> fetchData(2));
    Future<String> future3 = executor.submit(() -> fetchData(3));

    // Get results
    System.out.println(future1.get());
    System.out.println(future2.get());
    System.out.println(future3.get());
}

Method 4: ThreadFactory

// Custom thread factory for frameworks
ThreadFactory factory = Thread.ofVirtual().factory();

Thread vt1 = factory.newThread(() -> task1());
Thread vt2 = factory.newThread(() -> task2());

vt1.start();
vt2.start();

How Virtual Threads Work

Carrier Threads:

┌─────────────────────────────────────────────┐
│          ForkJoinPool (Carrier Threads)      │
│   Platform Thread 1  │  Platform Thread 2   │
└─────────────────────────────────────────────┘
           ↑                    ↑
           │ scheduled on       │ scheduled on
           │                    │
┌──────────┴───────┐   ┌────────┴──────────┐
│ Virtual Thread 1 │   │ Virtual Thread 2  │
│ Virtual Thread 3 │   │ Virtual Thread 4  │
│ Virtual Thread 5 │   │ Virtual Thread 6  │
└──────────────────┘   └───────────────────┘
   (parked when           (parked when
    blocking)              blocking)

Execution Model:

  1. Virtual threads are scheduled on carrier (platform) threads
  2. When a virtual thread blocks (I/O, sleep), it's unmounted from carrier
  3. Carrier thread picks up another virtual thread
  4. When I/O completes, virtual thread is remounted on any available carrier
// Demonstration of parking behavior
Thread.startVirtualThread(() -> {
    System.out.println("VT-1 Start on: " + Thread.currentThread());

    try {
        Thread.sleep(100); // VT-1 parks, carrier freed
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }

    // VT-1 resumes on potentially different carrier
    System.out.println("VT-1 Resume on: " + Thread.currentThread());
});

Comparison: Platform vs Virtual Threads

Aspect Platform Threads Virtual Threads
Memory per thread ~2 MB ~few KB
Max concurrent threads ~thousands ~millions
Creation cost High Very low
Context switch OS-level JVM-level
Blocking behavior Blocks OS thread Parks (unmounts)
Thread pool needed Yes No
Best for CPU-bound tasks I/O-bound tasks

Basic Examples

Example 1: Simple HTTP Requests

import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import java.util.*;

public class BasicVirtualThreadExample {
    public static void main(String[] args) throws InterruptedException {
        List<String> urls = List.of(
            "https://api.github.com/users/github",
            "https://api.github.com/users/microsoft",
            "https://api.github.com/users/google"
        );

        // With virtual threads - all requests concurrent
        long start = System.currentTimeMillis();
        List<Thread> threads = new ArrayList<>();

        for (String url : urls) {
            Thread vt = Thread.startVirtualThread(() -> {
                try {
                    HttpClient client = HttpClient.newHttpClient();
                    HttpRequest request = HttpRequest.newBuilder()
                        .uri(URI.create(url))
                        .timeout(Duration.ofSeconds(5))
                        .build();

                    HttpResponse<String> response = client.send(
                        request,
                        HttpResponse.BodyHandlers.ofString()
                    );

                    System.out.printf("%s: %d bytes%n", 
                        url, response.body().length());

                } catch (Exception e) {
                    System.err.println("Failed: " + url + " - " + e.getMessage());
                }
            });
            threads.add(vt);
        }

        // Wait for all to complete
        for (Thread vt : threads) {
            vt.join();
        }

        long duration = System.currentTimeMillis() - start;
        System.out.println("Completed in: " + duration + "ms");
    }
}

Example 2: Database Queries

import java.sql.*;
import java.util.*;
import java.util.concurrent.*;

public class DatabaseQueryExample {
    public static void main(String[] args) throws Exception {
        List<Integer> userIds = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<User>> futures = userIds.stream()
                .map(id -> executor.submit(() -> fetchUser(id)))
                .toList();

            // Collect results
            List<User> users = futures.stream()
                .map(f -> {
                    try {
                        return f.get();
                    } catch (Exception e) {
                        throw new RuntimeException(e);
                    }
                })
                .toList();

            System.out.println("Fetched " + users.size() + " users");
        }
    }

    private static User fetchUser(int id) {
        try (Connection conn = getConnection()) {
            PreparedStatement stmt = conn.prepareStatement(
                "SELECT * FROM users WHERE id = ?"
            );
            stmt.setInt(1, id);

            ResultSet rs = stmt.executeQuery();
            if (rs.next()) {
                return new User(
                    rs.getInt("id"),
                    rs.getString("name"),
                    rs.getString("email")
                );
            }
            return null;
        } catch (SQLException e) {
            throw new RuntimeException("Failed to fetch user: " + id, e);
        }
    }

    private static Connection getConnection() throws SQLException {
        return DriverManager.getConnection(
            "jdbc:postgresql://localhost/mydb",
            "user",
            "password"
        );
    }

    record User(int id, String name, String email) {}
}

Example 3: File Processing

import java.io.IOException;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;

public class FileProcessingExample {
    public static void main(String[] args) throws Exception {
        Path inputDir = Path.of("input");
        Path outputDir = Path.of("output");
        Files.createDirectories(outputDir);

        List<Path> files = Files.list(inputDir)
            .filter(Files::isRegularFile)
            .toList();

        System.out.println("Processing " + files.size() + " files...");

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Void>> futures = files.stream()
                .map(file -> executor.submit(() -> {
                    processFile(file, outputDir);
                    return null;
                }))
                .toList();

            // Wait for all
            for (Future<Void> future : futures) {
                future.get();
            }
        }

        System.out.println("All files processed!");
    }

    private static void processFile(Path input, Path outputDir) {
        try {
            String content = Files.readString(input);

            // Simulate processing
            String processed = content.toUpperCase();

            Path output = outputDir.resolve(input.getFileName());
            Files.writeString(output, processed);

            System.out.println("Processed: " + input.getFileName());

        } catch (IOException e) {
            System.err.println("Error processing " + input + ": " + e.getMessage());
        }
    }
}

Thread Properties

Checking if Thread is Virtual:

Thread thread = Thread.currentThread();
if (thread.isVirtual()) {
    System.out.println("Running in virtual thread");
} else {
    System.out.println("Running in platform thread");
}

Thread Names:

// Auto-generated names
Thread vt1 = Thread.startVirtualThread(() -> {
    System.out.println(Thread.currentThread().getName());
    // Output: VirtualThread-0 (or similar)
});

// Custom names
Thread vt2 = Thread.ofVirtual()
    .name("data-processor-1")
    .start(() -> {
        System.out.println(Thread.currentThread().getName());
        // Output: data-processor-1
    });

// Name prefix for executor
ThreadFactory factory = Thread.ofVirtual()
    .name("worker-", 0)
    .factory();

ExecutorService executor = Executors.newThreadPerTaskExecutor(factory);

Thread Priority (No Effect):

// Virtual threads ignore priority
Thread vt = Thread.ofVirtual()
    .priority(Thread.MAX_PRIORITY) // Ignored!
    .start(() -> {
        // priority has no effect on scheduling
    });

Thread Groups (Deprecated):

// Virtual threads don't support thread groups
Thread vt = Thread.startVirtualThread(() -> {
    ThreadGroup group = Thread.currentThread().getThreadGroup();
    System.out.println(group); // null
});

When to Use Virtual Threads

✅ Good Use Cases:

  • High-throughput servers (web servers, API gateways)
  • I/O-bound workloads (database queries, file operations)
  • Network operations (HTTP clients, message queues)
  • Microservices with many blocking calls
  • Request-per-thread model applications
// Perfect for web server handling many concurrent requests
public class SimpleWebServer {
    public void handleRequests() throws IOException {
        try (var server = ServerSocketChannel.open()) {
            server.bind(new InetSocketAddress(8080));

            while (true) {
                SocketChannel client = server.accept();

                // Spawn virtual thread per request
                Thread.startVirtualThread(() -> handleClient(client));
            }
        }
    }

    private void handleClient(SocketChannel client) {
        try {
            // Blocking I/O is fine - virtual thread parks
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            client.read(buffer);

            // Process request (may call database, other services)
            String response = processRequest(buffer);

            // Write response
            client.write(ByteBuffer.wrap(response.getBytes()));
            client.close();

        } catch (IOException e) {
            System.err.println("Error handling client: " + e.getMessage());
        }
    }
}

❌ Poor Use Cases:

  • CPU-bound tasks (use ForkJoinPool or parallel streams)
  • Very short-lived tasks (overhead not worth it)
  • Tasks requiring thread affinity
  • Code with heavy synchronized block usage
// Bad: CPU-intensive task
// Use ForkJoinPool or parallel streams instead
IntStream.range(0, 1_000_000)
    .parallel() // Better than virtual threads for CPU work
    .map(i -> expensiveComputation(i))
    .sum();

// Bad: Very short tasks
for (int i = 0; i < 1_000_000; i++) {
    Thread.startVirtualThread(() -> {
        // Just incrementing a counter - overhead exceeds benefit
        counter.incrementAndGet();
    });
}

Performance Characteristics

Memory Usage:

// Platform threads
ExecutorService platform = Executors.newFixedThreadPool(1000);
// Memory: ~2 GB (2 MB × 1000 threads)

// Virtual threads
ExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor();
// Memory: ~few MB for thousands of virtual threads

Throughput Comparison:

import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;

public class ThroughputComparison {
    public static void main(String[] args) throws Exception {
        int numTasks = 10_000;

        // Platform threads - limited by pool size
        System.out.println("Platform threads:");
        benchmarkPlatform(numTasks);

        // Virtual threads - scales to all tasks
        System.out.println("\nVirtual threads:");
        benchmarkVirtual(numTasks);
    }

    private static void benchmarkPlatform(int numTasks) throws Exception {
        Instant start = Instant.now();

        try (var executor = Executors.newFixedThreadPool(200)) {
            List<Future<?>> futures = new ArrayList<>();
            for (int i = 0; i < numTasks; i++) {
                futures.add(executor.submit(() -> {
                    simulateIO();
                }));
            }

            for (Future<?> future : futures) {
                future.get();
            }
        }

        Duration duration = Duration.between(start, Instant.now());
        System.out.println("Time: " + duration.toMillis() + "ms");
    }

    private static void benchmarkVirtual(int numTasks) throws Exception {
        Instant start = Instant.now();

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<?>> futures = new ArrayList<>();
            for (int i = 0; i < numTasks; i++) {
                futures.add(executor.submit(() -> {
                    simulateIO();
                }));
            }

            for (Future<?> future : futures) {
                future.get();
            }
        }

        Duration duration = Duration.between(start, Instant.now());
        System.out.println("Time: " + duration.toMillis() + "ms");
    }

    private static void simulateIO() {
        try {
            Thread.sleep(100); // Simulate blocking I/O
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

// Typical results:
// Platform threads (200 pool): ~5000ms (200 threads × 50 batches × 100ms)
// Virtual threads: ~100ms (all 10,000 tasks run concurrently)

Best Practices

  1. Use Virtual-Thread-Per-Task Executor

    // Good
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        executor.submit(() -> task());
    }
    
    // Avoid creating threads manually in loops
    for (int i = 0; i < 10000; i++) {
        Thread.startVirtualThread(() -> task()); // Less structured
    }
    
  2. Let Virtual Threads Block

    // Good - simple blocking code
    String data = blockingHttpCall();
    processData(data);
    
    // Don't use async patterns
    // completableFuture.thenApply(...).thenApply(...) // Unnecessary complexity
    
  3. Name Your Threads

    Thread.ofVirtual()
        .name("order-processor-" + orderId)
        .start(() -> processOrder(orderId));
    
  4. Use Try-With-Resources

    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        // submit tasks
    } // Automatically shuts down and waits
    
  5. Don't Pool Virtual Threads

    // Bad - defeats the purpose
    ExecutorService pool = Executors.newFixedThreadPool(
        100,
        Thread.ofVirtual().factory()
    );
    
    // Good - one virtual thread per task
    var executor = Executors.newVirtualThreadPerTaskExecutor();