From Java to Kotlin – Part X: Virtual Threads and Coroutines
Considering a move to Kotlin? Coming from a Java background? In this short series of blog posts, I’ll take a look at familiar, straightforward Java concepts and demonstrate how you can approach them in Kotlin. While many of these points have already been discussed in earlier posts by colleagues, my focus is simple: how you used to do it in Java, and how you do it in Kotlin.
Case 10: You need to do many things at once. Mostly waiting. Sometimes working.
The problem
Modern apps spend a lot of time waiting:
-
Waiting for a web request
-
Waiting for a database query
-
Waiting for a file or message from another system
During I/O waits, threads are blocked, which often leads to low CPU utilization. So the real question is not:
“How fast can we run code?”
but
“How can we wait efficiently?”
The Java way (classic threads)
Java’s old answer: give each task a thread.
-
Each thread blocks while waiting
-
The OS schedules threads for you
-
Works fine for a few tasks
Problems appear with thousands of tasks:
-
Threads are relatively heavy
-
Memory usage grows fast
-
Shared data = easy to make mistakes
-
Debugging is tricky
Think of threads as people waiting on chairs. Each blocks a chair while they wait — expensive if the room is full.
The Java way (Java 21+): Virtual Threads
Java 21 introduced virtual threads:
-
Look like threads in code
-
JVM schedules them efficiently
-
Blocking a virtual thread doesn’t block an OS thread
-
You can create thousands with a significantly lower memory overhead
Thread virtualThread = Thread.startVirtualThread(() ->
System.out.println(message + " from a virtual thread!")
);
virtualThread.join();
Here: the code still “blocks,” but it’s cheap. Your mental model stays the same: threads are doing work.
Virtual threads are perfect if:
-
Most code is I/O-bound
-
You have existing blocking APIs
-
You don’t want to rewrite everything
The Kotlin way: Coroutines
Kotlin asks a different question:
“What does it mean to wait in code?”
Coroutines are not threads. They are small, lightweight units of work that pause and resume:
-
While waiting, no thread is blocked
-
Waiting becomes explicit in the API
-
Cancellation and lifetimes are structured
fun greeting(message: String) = runBlocking {
println(message)
launch {
println("$message from a coroutine!")
}
}
-
runBlocking= create a coroutine scope from a blocking (non-coroutine) context. (* Nuance) -
launch= start a coroutine
No thread is wasted. Waiting is visible, explicit, and safe.
Suspending functions
In Kotlin, waiting is part of the type system:
suspend fun loadUser(): User
This tells the caller: “This function may pause.” Hidden delays? Gone. Cancellation? Easier.
Coroutines encourage structured concurrency: every coroutine has a parent, lifetimes are bounded, leaks are prevented. Virtual threads do not provide this. Comparable behavior requires explicit use of StructuredTaskScope.
Who schedules what?
Key similarity: both models abstract away low-level thread management.
Key difference: who owns and exposes the scheduling model.
Virtual threads: Scheduling is handled transparently by the JVM. Developers write blocking-style code, and the JVM decides when virtual threads run. Threads are cheap, but scheduling is largely an implementation detail of the runtime.
Coroutines: Scheduling is handled by the coroutine framework. Coroutines run on threads, but a coroutine dispatcher determines when and where a coroutine executes. Developers can explicitly choose or configure this dispatcher.
Note: Coroutines still execute on underlying threads, but the coroutine scheduler controls when a coroutine is active. With virtual threads, this control resides entirely within the JVM.
| Feature | Threads | Virtual Threads (Java 21+) | Coroutines (Kotlin) |
|---|---|---|---|
Resource cost |
Heavy (1 MB+ per thread) |
Light (a few KB per thread) |
Very light (bytes per coroutine) |
Blocking |
Blocks OS thread |
Blocks virtual thread (cheap) |
Suspends coroutine, does not block thread |
Scheduling |
OS / JVM |
JVM schedules virtual threads |
Kotlin scheduler ( |
Mental model |
Threads are doing work |
Threads are doing work, but cheap |
Coroutine pauses/resumes; waiting is explicit |
Structured concurrency |
❌ |
✅ (when using |
✅ Parent-child relationship, scoped lifetimes |
Typical usage |
Multi-threaded code |
Existing blocking code with many tasks |
Async I/O, structured concurrency, modern Kotlin apps |
runBlocking |
N/A |
N/A |
Used only for bridging blocking code, e.g., in |
Analogy:
-
Threads = people blocking chairs while waiting
-
Virtual Threads = same people, chairs magically multiply
-
Coroutines = people read a book while waiting, then continue
Interoperability reality
Kotlin runs on the JVM, so:
-
Coroutines can run on virtual threads
-
Virtual threads don’t replace
suspend -
Libraries still need to be coroutine-aware
Using virtual threads doesn’t remove the need to think about async boundaries.
Takeaway
Java asks:
“How can we make blocking cheap?”
Kotlin asks:
“What does it really mean to wait in code?”
Virtual threads: make blocking cheap. Perfect if you have existing blocking code and many I/O-bound tasks, without consuming too much memory.
Coroutines: make waiting explicit and safe. Functions that can pause are visible via suspend, and structured concurrency helps manage lifetimes and cancellation.
Extra nuance – runBlocking
runBlocking is mainly used to bridge blocking code and coroutines, e.g., in main() or tests.
In real Kotlin applications, you usually use CoroutineScope with launch or async without runBlocking.
RunBlocking examples may be confusing; it is not the standard way to write coroutines in production code.
// Application-wide scope
val appScope = CoroutineScope(Dispatchers.Default)
fun main() {
appScope.launch {
println("Coroutine started on thread: ${Thread.currentThread().name}")
delay(1000L)
println("Coroutine finished after 1 second")
}
println("Main function continues immediately")
// Keep the JVM alive for demonstration purposes only
Thread.sleep(1500L)
// Proper shutdown
appScope.cancel()
}
What happens here:
-
CoroutineScope(Dispatchers.Default)+Dispatchers.Default)-
Creates an application-level coroutine scope backed by a shared thread pool.
-
The lifecycle must be managed explicitly.
-
-
launch { … }
-
starts a coroutine without blocking a thread.
-
-
delay(1000L)
-
Suspends the coroutine without blocking a thread. The underlying thread is free to run other coroutines.
-
-
The main function continues immediately.
-
Thread.sleep(1500L)
-
Used here only to keep the JVM alive for demonstration purposes. In real applications (e.g., servers, Android apps), lifecycle management keeps the application running, so this is usually unnecessary.
-
In short:
Java = “I’m blocking, but it’s cheap.”
Kotlin = “I’m waiting, and everyone knows it.”
Code comparison: Virtual Threads vs Coroutines
Java (Virtual Threads)
Thread virtualThread = Thread.startVirtualThread(() -> {
try {
Thread.sleep(500); // simulate waiting for I/O
System.out.println("Hello from a virtual thread!");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
});
virtualThread.join();
-
Thread.sleepblocks, but it’s cheap -
JVM schedules threads for you
-
Mental model: threads are doing work
Kotlin (Coroutines)
fun greeting() = runBlocking {
delay(500) // simulate waiting for I/O
println("Hello from the main coroutine")
launch {
delay(500)
println("Hello from a coroutine!")
}
}
-
delaysuspends, does not block -
launchstarts a coroutine that pauses and resumes -
No wasted threads; efficient scaling
-
Waiting is explicit (
suspend)
Bonus: Kotlin Coroutines – Cheat Sheet
| Concept | What it does | Blocks thread? | Typical usage |
|---|---|---|---|
|
Marks a function that can pause |
No |
Functions that perform I/O, waiting, or long-running tasks |
|
Starts a coroutine |
No |
Fire-and-forget tasks, background work |
|
Starts a coroutine returning a result ( |
No |
Parallel computations, combine results with |
|
Starts a coroutine in a blocking context |
Yes |
Only in |
|
Manages coroutine lifetimes |
– |
Application- or component-wide coroutines |
|
Dispatcher for CPU-intensive tasks |
No |
Computation, background processing |
|
Dispatcher for I/O-intensive tasks |
No |
Database, network, file I/O |
|
Dispatcher for UI thread (Android) |
No |
UI updates, user interactions |
|
Pauses a coroutine without blocking a thread |
No |
Simulating wait, sleep-like behavior |
|