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.
Getting Started: Your First Virtual Thread
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.
Real-World Example: Building a High-Throughput API
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.
Performance: The Numbers Don't Lie
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.
Advanced Patterns: When Virtual Threads Shine
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());
};
}
}
Best Practices: Getting Virtual Threads Right
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();
}
}
Common Pitfalls and How to Avoid 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();
Integration with Spring Boot: Production-Ready Setup
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
The Future is Here: Why Virtual Threads Matter
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!
Happy threading! 🧵✨