Mastering Virtual Threads in Java 25: The Complete Guide to Lightweight Concurrency
Writing
JAVA DEVELOPMENT
July 3, 202512 min read

Mastering Virtual Threads in Java 25: The Complete Guide to Lightweight Concurrency

Discover how Virtual Threads in Java 25 revolutionize concurrent programming. Learn practical implementation strategies, performance optimizations, and real-world use cases that will transform your Java applications.

javavirtual-threadsconcurrencyperformancejava25project-loom

Remember when handling 10,000 concurrent users meant provisioning servers with massive thread pools and carefully tuning connection limits? Those days are over. Java 25's Virtual Threads have fundamentally changed how we think about concurrency, and honestly, it's the biggest leap forward in Java threading since... well, since threads were invented.

I've been working with Virtual Threads since their preview days, and I can tell you this: once you experience writing concurrent code that just works without the complexity, you'll never want to go back to traditional threading models.

In this deep-dive guide, we'll explore everything you need to know about Virtual Threads in Java 25, from the basics to advanced patterns that will make your applications lightning-fast and incredibly scalable.

What Are Virtual Threads? (And Why Should You Care?)

Think of Virtual Threads as lightweight threads that are managed by the JVM rather than the operating system. While platform threads have a 1:1 mapping with OS threads (expensive and limited), Virtual Threads are cheap, abundant, and designed for blocking operations.

Here's the mind-blowing part: you can create millions of Virtual Threads without breaking a sweat. Your laptop can handle what previously required entire server farms.

The Problem Virtual Threads Solve

Let's start with a real-world scenario. Imagine you're building an e-commerce API that needs to:

  • Fetch user data from a database
  • Call a payment service
  • Update inventory
  • Send confirmation emails
  • Log analytics events

Traditional Threading Approach:

@RestController
public class OrderController {
 
    @Autowired
    private OrderService orderService;
 
    // This approach ties up precious platform threads
    @PostMapping("/orders")
    public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
        // Thread blocks here waiting for database
        User user = userService.findById(request.getUserId());
 
        // Thread blocks again for payment processing
        PaymentResult payment = paymentService.processPayment(request.getPayment());
 
        // Another blocking call
        inventoryService.reserveItems(request.getItems());
 
        // And another...
        Order order = orderService.createOrder(user, request.getItems(), payment);
 
        return ResponseEntity.ok(OrderResponse.from(order));
    }
}

The problem? Each request consumes a platform thread for its entire lifecycle. With traditional thread pools, you might handle 200-500 concurrent requests before running into thread exhaustion.

Virtual Threads Approach:

@RestController
public class OrderController {
 
    @Autowired
    private OrderService orderService;
 
    // Same code, but now it runs on Virtual Threads!
    @PostMapping("/orders")
    public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
        // Virtual Thread parks during database call - carrier thread is freed
        User user = userService.findById(request.getUserId());
 
        // Parks again during payment - no platform thread blocked
        PaymentResult payment = paymentService.processPayment(request.getPayment());
 
        // Virtual Thread continues seamlessly
        inventoryService.reserveItems(request.getItems());
 
        Order order = orderService.createOrder(user, request.getItems(), payment);
 
        return ResponseEntity.ok(OrderResponse.from(order));
    }
}

Same code, dramatically different behavior. Now you can handle 100,000+ concurrent requests on the same hardware.

How do you create and run your first Virtual Thread in Java?

Enabling Virtual Threads in Java 25 is surprisingly simple:

public class VirtualThreadBasics {
 
    public static void main(String[] args) throws InterruptedException {
        // Create a Virtual Thread the simple way
        Thread vt1 = Thread.ofVirtual().start(() -> {
            System.out.println("Running on: " + Thread.currentThread());
        });
 
        // Or use a factory for multiple threads
        ThreadFactory factory = Thread.ofVirtual().factory();
 
        Thread vt2 = factory.newThread(() -> {
            System.out.println("Another virtual thread: " + Thread.currentThread());
        });
 
        vt2.start();
 
        // Wait for completion
        vt1.join();
        vt2.join();
    }
}

For Spring Boot applications, enable Virtual Threads globally:

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        // Enable Virtual Threads for all web requests
        System.setProperty("spring.threads.virtual.enabled", "true");
        SpringApplication.run(Application.class, args);
    }
}

That's it. Every web request now runs on a Virtual Thread automatically.

How do you build a high-throughput API with Virtual Threads?

Let's build something practical - a social media feed aggregator that fetches data from multiple sources:

@Service
@Slf4j
public class FeedAggregatorService {
 
    private final List<SocialMediaClient> clients;
    private final ExecutorService virtualExecutor;
 
    public FeedAggregatorService(List<SocialMediaClient> clients) {
        this.clients = clients;
        // Create an executor that uses Virtual Threads
        this.virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();
    }
 
    public AggregatedFeed getFeed(String userId, int limit) {
        log.info("Fetching feed for user: {}", userId);
 
        // Launch concurrent requests to all social media platforms
        List<CompletableFuture<FeedData>> futures = clients.stream()
            .map(client -> CompletableFuture.supplyAsync(() -> {
                try {
                    log.debug("Fetching from {}", client.getPlatformName());
                    return client.getUserFeed(userId, limit);
                } catch (Exception e) {
                    log.warn("Failed to fetch from {}: {}",
                             client.getPlatformName(), e.getMessage());
                    return FeedData.empty(client.getPlatformName());
                }
            }, virtualExecutor))
            .toList();
 
        // Wait for all responses (or timeout)
        List<FeedData> feeds = futures.stream()
            .map(future -> {
                try {
                    return future.get(5, TimeUnit.SECONDS);
                } catch (Exception e) {
                    log.warn("Timeout or error getting feed data", e);
                    return FeedData.empty("unknown");
                }
            })
            .toList();
 
        return mergeFeedsIntelligently(feeds, limit);
    }
 
    private AggregatedFeed mergeFeedsIntelligently(List<FeedData> feeds, int limit) {
        // Merge posts by timestamp, apply user preferences, remove duplicates
        List<Post> allPosts = feeds.stream()
            .flatMap(feed -> feed.getPosts().stream())
            .sorted(Comparator.comparing(Post::getTimestamp).reversed())
            .distinct()
            .limit(limit)
            .toList();
 
        return new AggregatedFeed(allPosts, Instant.now());
    }
}
 
// Example client implementation
@Component
public class TwitterClient implements SocialMediaClient {
 
    private final RestTemplate restTemplate;
 
    @Override
    public FeedData getUserFeed(String userId, int limit) {
        // This HTTP call will park the Virtual Thread, not block a platform thread
        String url = "https://api.twitter.com/2/users/{userId}/tweets?max_results={limit}";
 
        try {
            TwitterResponse response = restTemplate.getForObject(
                url, TwitterResponse.class, userId, limit);
 
            List<Post> posts = response.getData().stream()
                .map(tweet -> new Post(
                    tweet.getId(),
                    tweet.getText(),
                    userId,
                    "twitter",
                    tweet.getCreatedAt()))
                .toList();
 
            return new FeedData("twitter", posts);
 
        } catch (Exception e) {
            log.error("Error fetching Twitter feed for user {}", userId, e);
            throw new SocialMediaException("Twitter API error", e);
        }
    }
 
    @Override
    public String getPlatformName() {
        return "twitter";
    }
}

The beautiful thing about this code? It looks like synchronous, blocking code, but it's actually highly concurrent. When each restTemplate.getForObject() call happens, the Virtual Thread parks itself, freeing up the carrier thread to handle other work.

How much faster are Virtual Threads compared to platform threads?

I ran some benchmarks comparing traditional threads vs Virtual Threads for a typical web service scenario:

@Component
@Slf4j
public class PerformanceBenchmark {
 
    // Simulate a typical web service call pattern
    public void simulateWebServiceCall() {
        try {
            // Database query (100ms average)
            Thread.sleep(100);
 
            // External API call (200ms average)
            Thread.sleep(200);
 
            // Cache operation (50ms average)
            Thread.sleep(50);
 
            // Business logic (10ms average)
            Thread.sleep(10);
 
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
 
    public void benchmarkPlatformThreads(int requests) {
        ExecutorService executor = Executors.newFixedThreadPool(200);
 
        long start = System.currentTimeMillis();
 
        List<Future<?>> futures = IntStream.range(0, requests)
            .mapToObj(i -> executor.submit(this::simulateWebServiceCall))
            .toList();
 
        futures.forEach(future -> {
            try {
                future.get();
            } catch (Exception e) {
                log.error("Error in platform thread execution", e);
            }
        });
 
        long duration = System.currentTimeMillis() - start;
        log.info("Platform threads: {} requests in {}ms", requests, duration);
 
        executor.shutdown();
    }
 
    public void benchmarkVirtualThreads(int requests) {
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
 
        long start = System.currentTimeMillis();
 
        List<Future<?>> futures = IntStream.range(0, requests)
            .mapToObj(i -> executor.submit(this::simulateWebServiceCall))
            .toList();
 
        futures.forEach(future -> {
            try {
                future.get();
            } catch (Exception e) {
                log.error("Error in virtual thread execution", e);
            }
        });
 
        long duration = System.currentTimeMillis() - start;
        log.info("Virtual threads: {} requests in {}ms", requests, duration);
 
        executor.shutdown();
    }
}

Results (10,000 requests):

  • Platform threads (200 pool): ~18 seconds
  • Virtual threads: ~360 milliseconds

That's a 50x improvement in throughput, and memory usage was dramatically lower with Virtual Threads.

What advanced patterns work best with Virtual Threads?

Pattern 1: Fan-Out/Fan-In Operations

Perfect for scenarios where you need to call multiple services and aggregate results:

@Service
public class ProductEnrichmentService {
 
    public EnrichedProduct enrichProduct(String productId) {
        // Start all enrichment operations concurrently
        var pricesFuture = CompletableFuture.supplyAsync(() ->
            priceService.getCurrentPricing(productId));
 
        var reviewsFuture = CompletableFuture.supplyAsync(() ->
            reviewService.getProductReviews(productId));
 
        var inventoryFuture = CompletableFuture.supplyAsync(() ->
            inventoryService.getAvailability(productId));
 
        var recommendationsFuture = CompletableFuture.supplyAsync(() ->
            recommendationService.getSimilarProducts(productId));
 
        // Wait for all operations to complete
        CompletableFuture<Void> allFutures = CompletableFuture.allOf(
            pricesFuture, reviewsFuture, inventoryFuture, recommendationsFuture);
 
        try {
            allFutures.get(2, TimeUnit.SECONDS);
 
            return new EnrichedProduct(
                productId,
                pricesFuture.get(),
                reviewsFuture.get(),
                inventoryFuture.get(),
                recommendationsFuture.get()
            );
        } catch (TimeoutException e) {
            log.warn("Timeout enriching product {}, returning partial data", productId);
            return buildPartialProduct(productId, pricesFuture, reviewsFuture,
                                     inventoryFuture, recommendationsFuture);
        } catch (Exception e) {
            log.error("Error enriching product {}", productId, e);
            throw new ProductEnrichmentException("Failed to enrich product", e);
        }
    }
}

Pattern 2: Streaming Data Processing

Virtual Threads excel at handling streaming scenarios:

@Service
public class StreamProcessor {
 
    public void processEventStream(String streamName) {
        // Each event is processed on its own Virtual Thread
        ExecutorService processor = Executors.newVirtualThreadPerTaskExecutor();
 
        eventStreamSource.connect(streamName)
            .forEach(event -> processor.submit(() -> {
                try {
                    // Complex processing that might involve I/O
                    ProcessedEvent processed = processEvent(event);
 
                    // Save to database (blocking operation)
                    eventRepository.save(processed);
 
                    // Send notification (another blocking operation)
                    notificationService.sendNotification(processed);
 
                    // Update metrics
                    updateMetrics(processed);
 
                } catch (Exception e) {
                    log.error("Failed to process event {}", event.getId(), e);
                    deadLetterQueue.send(event, e);
                }
            }));
    }
 
    private ProcessedEvent processEvent(Event event) {
        // Simulate complex processing
        return switch (event.getType()) {
            case USER_ACTION -> processUserAction(event);
            case SYSTEM_EVENT -> processSystemEvent(event);
            case EXTERNAL_WEBHOOK -> processWebhook(event);
            default -> throw new UnsupportedEventTypeException(
                "Unknown event type: " + event.getType());
        };
    }
}

What are the best practices for using Virtual Threads?

1. Don't Use Thread Pools (Seriously)

With Virtual Threads, the old "limited thread pool" thinking goes out the window:

// ❌ DON'T: Create limited pools of Virtual Threads
ExecutorService badExecutor = Executors.newFixedThreadPool(10,
    Thread.ofVirtual().factory());
 
// ✅ DO: Use unlimited Virtual Thread executors
ExecutorService goodExecutor = Executors.newVirtualThreadPerTaskExecutor();
 
// ✅ OR: Create Virtual Threads directly
Thread.ofVirtual().start(() -> {
    // Your code here
});

2. Avoid CPU-Intensive Tasks

Virtual Threads are designed for I/O-bound work. For CPU-intensive tasks, stick with platform threads:

@Service
public class TaskDispatcher {
 
    private final ExecutorService cpuExecutor =
        Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
    private final ExecutorService ioExecutor =
        Executors.newVirtualThreadPerTaskExecutor();
 
    public CompletableFuture<ProcessingResult> processTask(Task task) {
        if (task.isCpuIntensive()) {
            // Use platform threads for CPU work
            return CompletableFuture.supplyAsync(() ->
                performCpuIntensiveWork(task), cpuExecutor);
        } else {
            // Use Virtual Threads for I/O work
            return CompletableFuture.supplyAsync(() ->
                performIoWork(task), ioExecutor);
        }
    }
}

3. Be Careful with ThreadLocal

ThreadLocal with Virtual Threads can lead to memory issues since there can be millions of them:

// ❌ DON'T: Use ThreadLocal with Virtual Threads for large data
private static final ThreadLocal<UserContext> USER_CONTEXT =
    new ThreadLocal<>();
 
// ✅ DO: Use ScopedValues (Java 25 preview feature)
private static final ScopedValue<UserContext> USER_CONTEXT =
    ScopedValue.newInstance();
 
public void handleRequest(HttpServletRequest request) {
    UserContext context = extractUserContext(request);
 
    ScopedValue.runWhere(USER_CONTEXT, context, () -> {
        // Code that needs access to user context
        processRequest();
    });
}

4. Monitor Virtual Thread Usage

Keep track of your Virtual Thread metrics:

@Component
public class VirtualThreadMonitor {
 
    private final MeterRegistry meterRegistry;
 
    @EventListener
    @Async
    public void monitorVirtualThreads(ApplicationReadyEvent event) {
        Gauge.builder("virtual.threads.active")
            .description("Number of active Virtual Threads")
            .register(meterRegistry, this, VirtualThreadMonitor::getActiveVirtualThreads);
    }
 
    private double getActiveVirtualThreads(VirtualThreadMonitor monitor) {
        return Thread.getAllStackTraces().keySet().stream()
            .filter(Thread::isVirtual)
            .count();
    }
}

What are the most common Virtual Thread mistakes and how do you fix them?

Pitfall 1: Blocking Synchronized Operations

Virtual Threads can't park during synchronized blocks, which defeats the purpose:

// ❌ BAD: Synchronized blocks pin Virtual Threads
private final Object lock = new Object();
 
public void badMethod() {
    synchronized (lock) {
        // This pins the Virtual Thread to its carrier
        callBlockingService(); // Platform thread stays blocked
    }
}
 
// ✅ GOOD: Use java.util.concurrent locks
private final ReentrantLock lock = new ReentrantLock();
 
public void goodMethod() {
    lock.lock();
    try {
        // Virtual Thread can park and unpark during blocking operations
        callBlockingService();
    } finally {
        lock.unlock();
    }
}

Pitfall 2: Not Configuring Carrier Thread Pool

For applications with specific requirements, tune the carrier thread pool:

// Configure carrier thread pool size
System.setProperty("jdk.virtualThreadScheduler.parallelism", "8");
 
// Or programmatically
@Configuration
public class VirtualThreadConfig {
 
    @Bean
    @Primary
    public Executor taskExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}

Pitfall 3: Overusing Virtual Threads for Short Tasks

For very short-lived tasks, the overhead of creating Virtual Threads might not be worth it:

// ❌ Overkill for simple operations
List<String> results = data.stream()
    .map(item -> CompletableFuture.supplyAsync(() ->
        simpleTransformation(item), virtualExecutor))
    .map(CompletableFuture::join)
    .toList();
 
// ✅ Use parallel streams for CPU-bound transformations
List<String> results = data.parallelStream()
    .map(this::simpleTransformation)
    .toList();

How do you configure Virtual Threads in a production Spring Boot application?

Here's how to properly configure Virtual Threads in a production Spring Boot application:

@Configuration
@EnableAsync
@EnableScheduling
public class VirtualThreadConfiguration {
 
    @Bean("virtualTaskExecutor")
    @Primary
    public Executor virtualTaskExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }
 
    @Bean
    public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
        return protocolHandler -> {
            protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        };
    }
 
    @Bean
    @ConditionalOnProperty(name = "app.virtual-threads.web.enabled", havingValue = "true")
    public WebMvcConfigurer webMvcConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
                configurer.setTaskExecutor(virtualTaskExecutor());
                configurer.setDefaultTimeout(30000);
            }
        };
    }
}
 
// Application properties
# application.yml
app:
  virtual-threads:
    web:
      enabled: true
 
spring:
  threads:
    virtual:
      enabled: true
  task:
    execution:
      pool:
        virtual-threads: true

Why do Virtual Threads matter for the future of Java?

Virtual Threads represent a fundamental shift in how we think about concurrency in Java. They solve the C10K problem (handling 10,000 concurrent connections) and push us well into the C1M territory (1 million concurrent connections).

For most applications, this means:

  • Simpler Code: Write straightforward blocking code that performs like async code
  • Better Resource Utilization: Handle more concurrent users with less hardware
  • Improved Reliability: Fewer thread pool exhaustion issues
  • Easier Debugging: Call stacks that actually make sense

The best part? You can often get these benefits by changing just a few configuration lines in existing applications.

Wrapping Up

Virtual Threads aren't just another Java feature - they're a paradigm shift that makes concurrent programming accessible to every Java developer. You no longer need to be a concurrency expert to write highly scalable applications.

Start small: enable Virtual Threads in a non-critical service and watch your throughput numbers. Once you see a 10x-50x improvement in concurrent request handling, you'll understand why this is the future of Java.

The era of thread pools, careful resource management, and complex async programming is coming to an end. Welcome to the age of Virtual Threads, where concurrent programming is finally simple again.


Have you started using Virtual Threads in your projects? I'd love to hear about your experiences and any patterns you've discovered. Drop a comment below and let's discuss!

References

Keep Reading

Happy threading! 🧵✨

Frequently Asked Questions

What are Virtual Threads in Java?

Virtual Threads are lightweight threads managed by the JVM rather than the operating system. Unlike platform threads which have a 1:1 mapping with OS threads, Virtual Threads are cheap to create (millions possible) and designed for I/O-bound operations. When a Virtual Thread blocks on I/O, it parks itself and frees the carrier thread for other work.

Do Virtual Threads replace platform threads?

No. Virtual Threads are designed for I/O-bound tasks like database calls, HTTP requests, and file operations. For CPU-intensive work like complex calculations or data processing, platform threads remain the better choice. Use Virtual Threads for I/O and platform threads for CPU-bound work.

Are Virtual Threads compatible with Spring Boot?

Yes. Spring Boot 3.2+ has native support for Virtual Threads. You can enable them globally by setting spring.threads.virtual.enabled=true in your application properties. Every web request will then run on a Virtual Thread automatically.

What is the performance improvement with Virtual Threads?

In I/O-heavy workloads, Virtual Threads can provide 10x to 50x throughput improvements over traditional thread pools. Benchmarks show 10,000 concurrent requests completing in ~360ms with Virtual Threads versus ~18 seconds with a 200-thread platform thread pool.

Last updated: April 2, 2026

Rabinarayan Patra

Rabinarayan Patra

SDE II at Amazon. Previously at ThoughtClan Technologies building systems that processed 700M+ daily transactions. I write about Java, Spring Boot, microservices, and the things I figure out along the way. More about me →

X (Twitter)LinkedIn

Stay in the loop

Get the latest articles on system design, frontend & backend development, and emerging tech trends — straight to your inbox. No spam.