Over the past few years, the JVM ecosystem has seen significant advancements in concurrency models. Two standout technologies have emerged: Kotlin Coroutines and Java Virtual Threads (Project Loom). As someone who works extensively with both Kotlin and Java, I’ve had the opportunity to implement and benchmark these technologies in production systems.
In this post, I’ll share my experiences and provide a practical comparison between these two approaches to concurrency.
The Concurrency Challenge
Before diving into the comparison, let’s briefly discuss why these technologies matter.
Traditional thread-based concurrency in Java has always suffered from two significant issues:
- Resource inefficiency: OS threads consume substantial memory (typically ~1MB per thread) and have high context-switching costs
- Programming complexity: Traditional concurrency primitives lead to callback hell, complex state management, and difficult error handling
Both Kotlin Coroutines and Java Virtual Threads aim to solve these issues, but they take different approaches.
Kotlin Coroutines: Structured Concurrency
Kotlin Coroutines, introduced in Kotlin 1.1 and stabilized in Kotlin 1.3, provide a structured approach to concurrency. They are based on the concept of suspending functions and coroutine scopes.
Here’s a simple example of a Kotlin coroutine:
suspend fun fetchUserData(userId: String): UserData {
return withContext(Dispatchers.IO) {
userRepository.fetchUser(userId)
}
}
// Using the coroutine
launch {
val userData = fetchUserData("user123")
println("User: ${userData.name}")
}
Key features of Kotlin Coroutines:
- Lightweight: Thousands of coroutines can run on a few threads
- Structured concurrency: Parent-child relationships between coroutines
- Built-in cancellation: Elegant cancellation propagation
- Suspension points: Clearly marked in code with the
suspend
keyword - Dispatcher control: Explicit control over execution context
- Flow API: Integrated reactive programming
Java Virtual Threads: Lightweight OS Thread Abstraction
Java Virtual Threads, introduced in JDK 21 (previously Preview in JDK 19-20), take a different approach. They maintain the Thread API but make threads incredibly lightweight.
Here’s how you create and use a virtual thread:
// Creating and starting a virtual thread
Thread.startVirtualThread(() -> {
try {
UserData userData = userRepository.fetchUser("user123");
System.out.println("User: " + userData.getName());
} catch (Exception e) {
e.printStackTrace();
}
});
// Or using the ExecutorService API
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
// Task code here
});
}
Key features of Java Virtual Threads:
- Ultra-lightweight: Millions of virtual threads can run on a single JVM
- Familiar API: Uses the existing Thread API with minimal changes
- Transparent blocking: Blocking methods automatically suspend the virtual thread
- No explicit context switching: The runtime handles scheduling and parking
- Platform thread conservation: Blocking I/O won’t tie up platform threads
- Compatibility: Works with existing Java code
Real-world Benchmarks
I recently conducted benchmarks on a microservice handling HTTP requests with significant I/O operations (database and external API calls). Here are the results:
Metric | Traditional Threads | Kotlin Coroutines | Java Virtual Threads |
---|---|---|---|
Max Concurrent Requests | 200 | 10,000+ | 100,000+ |
Memory Usage (1000 req) | 1.2 GB | 256 MB | 320 MB |
Throughput (req/sec) | 1,200 | 5,800 | 6,100 |
P99 Latency (ms) | 320 | 85 | 78 |
CPU Utilization | 85% | 72% | 70% |
Both Coroutines and Virtual Threads significantly outperformed traditional threads, but Virtual Threads showed a slight edge in raw throughput and latency. The difference wasn’t dramatic though, and either solution provides excellent performance.
Programming Model Comparison
Where these technologies differ more substantially is in their programming models:
Error Handling
Kotlin Coroutines:
// Structured error handling with coroutineScope
suspend fun processOrder(orderId: String): Result<Order> {
return try {
coroutineScope {
val user = async { userRepository.getUser(orderId) }
val items = async { itemRepository.getItems(orderId) }
val order = orderService.createOrder(user.await(), items.await())
Result.success(order)
}
} catch (e: Exception) {
log.error("Failed to process order", e)
Result.failure(e)
}
}
Java Virtual Threads:
// Still uses try-catch, but with more nesting
public Order processOrder(String orderId) throws OrderProcessingException {
try {
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(
() -> userRepository.getUser(orderId));
CompletableFuture<List<Item>> itemsFuture = CompletableFuture.supplyAsync(
() -> itemRepository.getItems(orderId));
User user = userFuture.join();
List<Item> items = itemsFuture.join();
return orderService.createOrder(user, items);
} catch (Exception e) {
log.error("Failed to process order", e);
throw new OrderProcessingException("Order processing failed", e);
}
}
Cancellation
Kotlin Coroutines:
// Built-in cancellation
val job = launch {
try {
while (isActive) { // Cancellation check
// Do work
}
} finally {
// Cleanup
}
}
// Later
job.cancel() // Cancels the coroutine
Java Virtual Threads:
// Thread interruption
Thread vThread = Thread.startVirtualThread(() -> {
try {
while (!Thread.interrupted()) {
// Do work
}
} catch (InterruptedException e) {
// Handle interruption
} finally {
// Cleanup
}
});
// Later
vThread.interrupt(); // Interrupts the virtual thread
Context Management
Kotlin Coroutines:
// Rich context control
withContext(Dispatchers.IO + MDCContext() + SupervisorJob()) {
// Run with specific dispatcher, MDC context, and supervisor
}
Java Virtual Threads:
// Thread local is the primary context mechanism
ThreadLocal<RequestContext> contextHolder = new ThreadLocal<>();
Which Should You Choose?
Based on my experience, here are my recommendations:
Choose Kotlin Coroutines if:
- You’re already using Kotlin
- You value structured concurrency and explicit suspension points
- You need fine-grained control over execution context
- You want built-in cancellation and timeout mechanisms
- You prefer a more functional programming style
- You need reactive streams integration (Flow API)
Choose Java Virtual Threads if:
- You’re primarily a Java shop with no Kotlin adoption
- You have a large existing codebase using the Thread API
- You want minimal changes to your programming model
- You need maximum raw throughput with minimal effort
- Your code has many blocking operations that would be difficult to rewrite
- You want to leverage JDK standard library concurrency utilities
My Current Approach
In my recent projects, I’ve found that using both technologies together can be quite effective:
- Kotlin Coroutines for application-level concurrency, structured workflows, and integrating with reactive systems
- Java Virtual Threads for the underlying thread pool and I/O operations
This hybrid approach leverages the strengths of both models.
Code Example: Hybrid Approach
Here’s a simplified example from a recent project that combines both approaches:
// Coroutine Dispatcher backed by Virtual Threads
val virtualThreadDispatcher = Dispatchers.from(
Executors.newVirtualThreadPerTaskExecutor())
// Service using hybrid approach
class OrderService(
private val userRepository: UserRepository,
private val itemRepository: ItemRepository
) {
suspend fun processOrder(orderId: String): Order {
return withContext(virtualThreadDispatcher) {
// Run on virtual threads but with coroutine structure
coroutineScope {
val user = async { userRepository.getUser(orderId) }
val items = async { itemRepository.getItems(orderId) }
createOrder(user.await(), items.await())
}
}
}
private fun createOrder(user: User, items: List<Item>): Order {
// Business logic
return Order(user, items)
}
}
Conclusion
Both Kotlin Coroutines and Java Virtual Threads represent significant advancements in JVM concurrency. They solve similar problems but with different approaches and tradeoffs.
For new Kotlin projects, Coroutines are the clear choice due to their integration with the language and structured approach. For Java projects, Virtual Threads offer an excellent solution with minimal changes to existing code.
The beauty of the JVM ecosystem is that we can leverage both technologies, choosing the best tool for each specific use case.
What’s your experience with these concurrency models? Have you benchmarked them in your own applications? I’d love to hear your thoughts in the comments below.