Project Loom brings revolutionary changes to Java concurrency with virtual threads and structured concurrency. In this post, we’ll migrate a thread-pool-based web scraper to virtual threads, demonstrating the dramatic simplification and scalability improvements.
The Problem: Scaling Concurrent HTTP Requests
Imagine you need to scrape thousands of web pages concurrently. With traditional platform threads, you face several challenges:
| Challenge | Impact |
|---|---|
| Memory | ~1MB stack per thread, limiting total threads |
| Thread Pool Sizing | Too few threads = queuing; too many = memory exhaustion |
| Blocking I/O | Threads sit idle waiting for responses |
| Scalability | 10K concurrent requests requires ~10GB of thread stacks |
Before: Traditional Thread Pool Approach
Here’s how we’d typically implement a web scraper with platform threads:
Java
public class WebScraperTraditional {
// Fixed thread pool - typically sized based on available processors
private static final int THREAD_POOL_SIZE = Runtime.getRuntime().availableProcessors() * 2;
private final ExecutorService executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
public List<ScrapedResult> scrapeAll(List<String> urls) {
List<Callable<ScrapedResult>> tasks = urls.stream()
.map(url -> (Callable<ScrapedResult>) () -> scrapeUrl(url))
.toList();
try {
List<Future<ScrapedResult>> futures = executor.invokeAll(tasks);
return futures.stream().map(f -> f.get()).toList();
} catch (Exception e) {
Thread.currentThread().interrupt();
return List.of();
}
}
private ScrapedResult scrapeUrl(String url) {
// Blocking HTTP call - ties up the thread while waiting
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return new ScrapedResult(url, response.statusCode(), response.body().length());
}
}
Problem: With 16 threads and 1000 URLs, only 16 requests can run concurrently. The rest queue up.
Scala
object WebScraperTraditional:
private val ThreadPoolSize = Runtime.getRuntime.availableProcessors * 2
def scrapeAll(urls: List[String]): List[ScrapedResult] =
Using.resource(Executors.newFixedThreadPool(ThreadPoolSize)) { executor =>
val tasks = urls.map(url =>
new Callable[ScrapedResult] { def call() = scrapeUrl(url) }
).asJava
val futures = executor.invokeAll(tasks)
futures.asScala.map(_.get()).toList
}
Kotlin
object WebScraperTraditional {
private val THREAD_POOL_SIZE = Runtime.getRuntime().availableProcessors() * 2
fun scrapeAll(urls: List<String>): List<ScrapedResult> {
val executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE)
return try {
val tasks = urls.map { url -> Callable { scrapeUrl(url) } }
executor.invokeAll(tasks).map { it.get() }
} finally {
executor.shutdown()
}
}
}
After: Virtual Threads Approach
With Java 21’s virtual threads, the migration is surprisingly simple:
Java
public class WebScraperVirtual {
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
public List<ScrapedResult> scrapeAll(List<String> urls) {
// newVirtualThreadPerTaskExecutor() - the key change!
// Creates a new virtual thread for each task
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<ScrapedResult>> futures = urls.stream()
.map(url -> executor.submit(() -> scrapeUrl(url)))
.toList();
return futures.stream().map(f -> f.get()).toList();
}
}
// The scrapeUrl method is IDENTICAL to before!
// Blocking code works efficiently with virtual threads
private ScrapedResult scrapeUrl(String url) {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return new ScrapedResult(url, response.statusCode(), response.body().length());
}
}
The change: Just replace newFixedThreadPool(N) with newVirtualThreadPerTaskExecutor().
Scala
object WebScraperVirtual:
def scrapeAll(urls: List[String]): List[ScrapedResult] =
// One virtual thread per URL - all run concurrently!
Using.resource(Executors.newVirtualThreadPerTaskExecutor()) { executor =>
val futures = urls.map(url => executor.submit(() => scrapeUrl(url)))
futures.map(_.get()).toList
}
Kotlin
object WebScraperVirtual {
fun scrapeAll(urls: List<String>): List<ScrapedResult> =
Executors.newVirtualThreadPerTaskExecutor().use { executor ->
val futures = urls.map { url -> executor.submit<ScrapedResult> { scrapeUrl(url) } }
futures.map { it.get() }
}
}
Key Virtual Thread APIs
Thread.startVirtualThread()
The simplest way to start a virtual thread:
Thread.startVirtualThread(() -> {
// Your code runs in a virtual thread
System.out.println("Hello from virtual thread!");
});
Thread.ofVirtual() Builder
For more control over thread creation:
Thread thread = Thread.ofVirtual()
.name("scraper-", 0) // Named threads: scraper-0, scraper-1, etc.
.start(() -> {
// Your code here
});
Executors.newVirtualThreadPerTaskExecutor()
The recommended approach for concurrent tasks:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// Each submit() creates a new virtual thread
var future1 = executor.submit(() -> fetchUserData());
var future2 = executor.submit(() -> fetchProductData());
// All tasks run concurrently
return combine(future1.get(), future2.get());
}
Structured Concurrency with StructuredTaskScope
Java 21 introduces structured concurrency (preview) for managing related concurrent tasks:
ShutdownOnFailure - All Must Succeed
public long fetchAllOrFail(List<String> urls) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Fork tasks - each runs in its own virtual thread
List<Subtask<Integer>> subtasks = urls.stream()
.map(url -> scope.fork(() -> fetchContentLength(url)))
.toList();
scope.join(); // Wait for all
scope.throwIfFailed(); // Throws if any task failed
// All succeeded - aggregate results
return subtasks.stream().mapToInt(Subtask::get).sum();
}
}
ShutdownOnSuccess - First Success Wins
public int fetchAnySuccessful(List<String> mirrors) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<Integer>()) {
// Race multiple mirrors
for (String url : mirrors) {
scope.fork(() -> fetchContentLength(url));
}
scope.join();
return scope.result(); // Returns first successful result
}
}
Comparison: ShutdownOnFailure vs ShutdownOnSuccess
| Aspect | ShutdownOnFailure | ShutdownOnSuccess |
|---|---|---|
| Use case | Need ALL results | Need ANY result |
| On first failure | Cancel all, throw | Continue others |
| On first success | Continue all | Cancel others, return |
| Returns | All results | First success |
Scoped Values: Modern Alternative to ThreadLocal
Java 21 introduces ScopedValue (preview) as a replacement for ThreadLocal:
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
private static final ScopedValue<UserContext> USER_CONTEXT = ScopedValue.newInstance();
public String handleRequest(String userId, String role, String url) throws Exception {
String requestId = UUID.randomUUID().toString().substring(0, 8);
UserContext context = new UserContext(userId, role);
// Bind scoped values for this request
return ScopedValue
.where(REQUEST_ID, requestId)
.where(USER_CONTEXT, context)
.call(() -> processRequest(url));
}
private String processRequest(String url) throws Exception {
// Access scoped values without explicit parameters!
String requestId = REQUEST_ID.get();
UserContext user = USER_CONTEXT.get();
log("Processing request for user " + user.userId());
return fetchWithLogging(url);
}
ThreadLocal vs ScopedValue
| Feature | ThreadLocal | ScopedValue |
|---|---|---|
| Mutability | Mutable | Immutable per scope |
| Cleanup | Manual remove() | Automatic with scope |
| Memory | Can leak | Cleaned up automatically |
| Virtual threads | Works, but heavy | Optimized |
When to Use Virtual Threads vs Platform Threads
Use Virtual Threads For:
✅ I/O-bound operations (HTTP calls, database queries, file I/O)
✅ High-concurrency servers (web servers, API gateways)
✅ Microservices making many outbound API calls
✅ Batch processing with parallel I/O operations
✅ Replacing callback-based async code
Use Platform Threads For:
✅ CPU-bound computations (number crunching, cryptography)
✅ Native code integration (JNI calls)
✅ Operations requiring thread affinity
✅ Code using synchronized blocks extensively
Migration Checklist
- Replace ExecutorService creation
// Before Executors.newFixedThreadPool(200) // After Executors.newVirtualThreadPerTaskExecutor() - Replace ThreadLocal with ScopedValue
// Before ThreadLocal<User> currentUser = new ThreadLocal<>(); // After ScopedValue<User> CURRENT_USER = ScopedValue.newInstance(); - Replace synchronized with ReentrantLock (to avoid “pinning”)
// Before - can pin virtual thread synchronized (lock) { blockingCall(); } // After - allows virtual thread to unmount lock.lock(); try { blockingCall(); } finally { lock.unlock(); } - Consider Structured Concurrency for related tasks
For Scala Developers
Virtual threads provide similar benefits to effect systems like ZIO or Cats Effect:
| Feature | Virtual Threads | ZIO/Cats Effect |
|---|---|---|
| Lightweight concurrency | ✓ | ✓ (Fibers) |
| Non-blocking semantics | ✓ | ✓ |
| Blocking code style | ✓ | Via blocking wrapper |
| Structured concurrency | StructuredTaskScope | Built-in |
| Effect tracking | No | Yes (IO monad) |
Key insight: Virtual threads let you write blocking-style code that scales like async code, without needing an effect system.
For Kotlin Developers
Virtual threads complement Kotlin coroutines:
| Use Case | Virtual Threads | Coroutines |
|---|---|---|
| Kotlin-only codebase | Can use | Preferred |
| Java interop | Preferred | Possible |
| Blocking Java libraries | Excellent | Needs Dispatchers.IO |
| Structured concurrency | StructuredTaskScope | Built-in |
Performance Comparison
With 1000 URLs that each take 1 second to fetch:
| Approach | Threads | Time | Memory |
|---|---|---|---|
| Sequential | 1 | ~1000s | ~1MB |
| Thread pool (16) | 16 | ~63s | ~16MB |
| Thread pool (200) | 200 | ~5s | ~200MB |
| Virtual threads | 1000 | ~1s | ~few MB |
Virtual threads achieve maximum parallelism with minimal memory!
Conclusion
Project Loom’s virtual threads represent a paradigm shift in Java concurrency:
- Simple migration: Often just change
newFixedThreadPool()tonewVirtualThreadPerTaskExecutor() - Massive scalability: Handle millions of concurrent operations
- Familiar code style: Write blocking code that scales like async
- Structured concurrency: Better resource management and cancellation
For I/O-bound workloads, virtual threads provide dramatic simplification while improving scalability. Combined with structured concurrency and scoped values, Java 21 offers a complete modern concurrency toolkit.
Code Samples
See the complete implementations in our repository:
This is part of our Java 21 Interview Preparation series. Check out the full preparation plan for more topics.