Back to Blog

Under the Hood: How Kotlin Coroutines Work on the JVM

Kotlin Coroutines are frequently described as "lightweight threads." We are told that we can spin up 100,000 of them simultaneously without running out of memory.

But from the Java Virtual Machine's (JVM) perspective, there is no such thing as a "coroutine." The JVM only understands native OS-level threads (though Java Virtual Threads are changing this, they are still native abstractions under the hood). How does Kotlin bridge this gap?

To understand coroutines at a deeper level, we have to look at what the Kotlin compiler does to our code during compilation.


1. Continuation Passing Style (CPS)

When the Kotlin compiler encounters the suspend keyword, it triggers a code compilation transformation known as Continuation Passing Style (CPS).

Consider this simple suspending function:

suspend fun getUserDetails(userId: String): UserProfile {
    val token = fetchAuthToken() // suspending
    val profile = fetchProfile(userId, token) // suspending
    return profile
}

During compilation, the compiler alters this function's signature in two major ways:

  1. It adds an extra parameter of type Continuation<Any?> to the end of the argument list.
  2. It changes the return type to Any?.

Here is what the compiled signature looks like in pseudo-Java:

// Compiled Java Signature
public final Object getUserDetails(String userId, Continuation<? super UserProfile> completion)

Why return Any? / Object?

A suspending function might return immediately (if the data is already cached), or it might suspend. If it suspends, it returns a special sentinel value: COROUTINE_SUSPENDED. Because the function can return either your actual result (UserProfile) or the suspension token (COROUTINE_SUSPENDED), the return type must be general enough to hold both—hence Any? in Kotlin, or Object in Java bytecode.


2. The Compiler-Generated State Machine

The true magic of coroutines lies in how suspending functions resume where they left off without blocking the underlying thread. To achieve this, the Kotlin compiler transforms the body of your suspending function into a State Machine represented by an anonymous class.

This class implements the Continuation interface:

interface Continuation<in T> {
    val context: CoroutineContext
    fun resumeWith(result: Result<T>)
}

For every suspending call within your function, the compiler defines a state (or "label"). Let's see how our getUserDetails function is rewritten into a state machine:

// Pseudo-code of the compiler-generated State Machine
class GetUserDetailsStateMachine(
    completion: Continuation<Any?>
) : CoroutineImpl(completion) {
    
    // Local variables we need to preserve across suspension points
    var userId: String? = null
    var token: String? = null
    
    var result: Any? = null
    var label: Int = 0 // Tracks the state of execution

    override fun invokeSuspend(result: Result<Any?>) {
        this.result = result
        // Invoke the original function, passing this state machine as the Continuation
        getUserDetails(null, this)
    }
}

Inside the modified getUserDetails function, a switch (or when) statement routes execution based on the state machine's label:

fun getUserDetails(userId: String?, completion: Continuation<Any?>): Any? {
    // 1. Establish state machine wrapper
    val sm = completion as? GetUserDetailsStateMachine ?: GetUserDetailsStateMachine(completion)

    // 2. State Machine Routing
    switch (sm.label) {
        case 0:
            // First run: save arguments
            sm.userId = userId
            sm.label = 1
            // Call fetchAuthToken, passing the state machine
            val res = fetchAuthToken(sm)
            if (res == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
            // If completed synchronously, fall through to case 1
            sm.result = res
            
        case 1:
            // Resumed after fetchAuthToken
            val token = sm.result as String
            sm.token = token
            sm.label = 2
            // Call fetchProfile, passing the state machine
            val res = fetchProfile(sm.userId!!, token, sm)
            if (res == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
            sm.result = res

        case 2:
            // Resumed after fetchProfile
            val profile = sm.result as UserProfile
            return profile
    }
}

The Suspension Flow

When fetchAuthToken(sm) is called:

  1. If the network call is asynchronous, the function returns COROUTINE_SUSPENDED.
  2. getUserDetails immediately returns COROUTINE_SUSPENDED to its caller, freeing up the current thread to run other tasks.
  3. Once the network response arrives, the client calls sm.resumeWith(result).
  4. This invokes invokeSuspend(), incrementing the label and executing getUserDetails(null, sm) again.
  5. The switch statement jumps directly to case 1, restoring the local userId and continuing execution where it left off.

This is how Kotlin avoids blocking threads: suspension is just returning a token and saving local variables to a heap-allocated state machine object.


3. Thread Orchestration: The Role of Dispatchers

While the state machine manages what happens when a coroutine pauses, Coroutine Dispatchers manage where it resumes.

On the JVM, dispatchers act as execution wrappers around Java Executors (Thread Pools):

  • Dispatchers.Default: Backed by a pool of threads equal to the number of CPU cores (minimum 2). It is optimized for CPU-heavy tasks like sorting lists, parsing JSON, or running mathematical models.
  • Dispatchers.IO: Backed by an elastic thread pool (defaulting to 64 threads or the number of cores, whichever is larger). It is optimized for blocking network or disk I/O operations where threads spend most of their time waiting.
// Launching on different pools
val job = CoroutineScope(Dispatchers.Default).launch {
    // Executes on CPU-bound thread pool
    val result = calculateFibonacci(40)
    
    withContext(Dispatchers.IO) {
        // Suspends on Default thread pool, schedules and resumes on IO thread pool
        saveToFile(result)
    }
}

Thread Hand-off under the hood

When you call withContext(Dispatchers.IO):

  1. The compiler registers a new suspension point.
  2. The current coroutine (state machine) is intercepted by Dispatchers.IO.
  3. Dispatchers.IO submits the coroutine's execution block as a task to its internal thread pool.
  4. When a thread in the IO pool is free, it runs the block.
  5. Once complete, the coroutine is dispatched back to the original dispatcher (Dispatchers.Default in this case) to resume.

4. Cooperative Multitasking and Suspension

Unlike OS-level threads, which are preemptive (the OS kernel can pause a thread at any time to run another), Kotlin Coroutines are cooperative.

A coroutine must yield control voluntarily to allow other coroutines on the same thread to run. Control is yielded when a coroutine hits a suspension point (like delay(), yield(), or a network request).

If a coroutine executes a heavy blocking loop without a suspending call, it will monopolize the thread:

// Anti-pattern: This blocks the thread pool and cannot be cancelled!
CoroutineScope(Dispatchers.Default).launch {
    var count = 0
    while (count < Int.MAX_VALUE) {
        count++ // Blocks the execution thread entirely
    }
}

To make CPU-intensive loops cooperative and cancelable, you must periodically check the coroutine's cancellation state:

// Correct cooperative loop
CoroutineScope(Dispatchers.Default).launch {
    var count = 0
    while (count < Int.MAX_VALUE) {
        ensureActive() // Throws CancellationException if cancelled
        // Or yield() to give other coroutines a chance to run
        count++
    }
}

Project Loom (Virtual Threads) vs. Kotlin Coroutines

With the introduction of Virtual Threads in Java 21 (Project Loom), JVM concurrency has evolved. Virtual threads are managed by the Java runtime instead of the OS kernel, allowing millions of virtual threads to run on a small pool of carrier threads.

How does this impact Kotlin Coroutines?

  • Kotlin Coroutines are a language-level compiler abstraction. They work on older JVM versions (Java 8+), support non-JVM targets (iOS, WebAssembly, JS), and offer structured concurrency primitives (like async/await, Flow, and cancellation scopes).
  • Virtual Threads are a JVM-level runtime abstraction. They allow legacy blocking synchronous Java code to run with near-zero overhead without rewriting the code to use reactive/callback structures.

In modern backend services, Kotlin Coroutines can run on top of Project Loom's Virtual Threads by wrapping Loom's executor in a dispatcher: Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher(), combining the rich API of coroutines with the runtime efficiency of virtual threads.


Conclusion

At their core, Kotlin Coroutines are not magic. They are a sophisticated compiler optimization. By transforming suspending functions into state machines and passing continuations, Kotlin allows us to write synchronous-looking, readable code that executes asynchronously, preserving resources and keeping applications highly responsive.

To see how to manage this asynchronous state cleanly in your mobile architectures, check out my guide on State Management in Kotlin Multiplatform, or learn how to decouple your services using dependency injection in my tutorial on Mastering DI with Koin.


Need a Concurrency & Mobile Systems Expert?

If you are looking to design high-throughput reactive systems, optimize threading performance, or implement structured coroutine architectures in your shared modules, I can help. I provide remote contracting and consulting services for engineering teams in Europe, the UK, and Canada (CEST, GMT, and EST timezone support).

Let's Connect & Discuss Your Project