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 (Dispatchers)

Mental model

Threads are doing work

Threads are doing work, but cheap

Coroutine pauses/resumes; waiting is explicit

Structured concurrency

✅ (when using StructuredTaskScope since Java 21, optional but without it ❌)

✅ 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 main() or tests; not standard in production

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.sleep blocks, 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!")
    }
}
  • delay suspends, does not block

  • launch starts 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

suspend fun

Marks a function that can pause

No

Functions that perform I/O, waiting, or long-running tasks

launch { …​ }

Starts a coroutine

No

Fire-and-forget tasks, background work

async { …​ }

Starts a coroutine returning a result (Deferred)

No

Parallel computations, combine results with await()

runBlocking { …​ }

Starts a coroutine in a blocking context

Yes

Only in main() or tests, not standard in production

CoroutineScope

Manages coroutine lifetimes

Application- or component-wide coroutines

Dispatchers.Default

Dispatcher for CPU-intensive tasks

No

Computation, background processing

Dispatchers.IO

Dispatcher for I/O-intensive tasks

No

Database, network, file I/O

Dispatchers.Main

Dispatcher for UI thread (Android)

No

UI updates, user interactions

delay(time)

Pauses a coroutine without blocking a thread

No

Simulating wait, sleep-like behavior

  • Deferred<T> represents a coroutine that will produce a result of type T in the future.

  • await() suspends the coroutine until the Deferred result is ready, without blocking the underlying thread.

  • Typical usage: val result = async { computeSomething() }.await()

shadow-left