Zygote
Java to Kotlinnerd

Kotlin under the hood

A data class is a hand-written class, an object is a singleton with an INSTANCE field, and a suspend function is a method with a label field. Kotlin's elegance is entirely at the source level; the bytecode is ordinary.

11 minAPI 21–37 · 17interactivequizverified 2026-10-27

The most useful thing you can know about Kotlin is what it compiles to. It explains the size of your APK, the shape of your stack traces, why an inline function cannot do certain things, and why a suspend function is not a thread.

TL;DR#

  • Kotlin compiles to JVM class files, then D8 converts them to DEX, exactly like Java.1
  • A data class generates real methods: getters, componentN, copy, equals, hashCode, toString.
  • An object is a class with a private constructor and a public static INSTANCE field.
  • An extension function is a static method whose first parameter is the receiver. It cannot access private members and does not modify the class.
  • An inline function is copied into the call site by the compiler; the lambda becomes a block of code, not an object.
  • A suspend function compiles to a method that takes a Continuation and returns Object, with a generated state machine to save and restore its position.

Mental model#

Think of Kotlin as a very good shorthand writer taking dictation for a very literal typist.

The shorthand is what you write: concise, expressive, full of implied meaning. The typist is the compiler. The typist does not reproduce the shorthand. It expands every abbreviation into full words, adds the boilerplate the reader needs, and produces a document that is longer and more explicit than what you handed over.

Reading the typist's output is how you learn what the shorthand actually means — and occasionally discover it means something you did not intend.

Where the analogy breaks#

A shorthand writer produces a document with the same meaning. A compiler sometimes produces code with more meaning than you wrote: null checks you did not type, state machine fields you never declared, synthetic $default methods for default arguments. The expansion is not neutral, and it has a cost in method count and code size.

The big picture#

Loading diagram…
Text description of this diagram

Two files side by side. User.kt declares a data class User with name and age, an object Session with a nullable token property, and a top-level function greet. The decompiled Java shape shows User with two private final fields, a constructor, getName, getAge, component1, component2, copy, toString, hashCode and equals; Session as a final class with a public static INSTANCE field, a private constructor and getToken and setToken; and UserKt as a class with a static greet method that concatenates the string. Six steps highlight the lines: a val is a field plus getter, componentN exists for destructuring, copy is a real method, equality and printing are generated, an object is a singleton class, and a string template is concatenation.

Step through all six. The pattern to notice: every Kotlin feature that looks free costs at least one method, and the compiler is honest enough to show you the bill in the decompiled output.

Deep dive#

Data classes: the bill for equality#

data class User(val name: String, val age: Int)

The compiler generates:

GeneratedSignatureWhy
ConstructorUser(String, Int)Obvious, but worth stating
GettersgetName(), getAge()Properties, not public fields
componentNcomponent1(), component2()Destructuring and for loops
copycopy(String, Int) plus a synthetic copy$defaultImmutable updates, with defaults filled in by the synthetic method
equalsequals(Object)Structural equality
hashCodehashCode()Consistency with equals
toStringtoString()Debugging and logging

Two subtleties matter in practice.

copy with defaults is not one method. user.copy(age = 31) compiles to a synthetic copy$default call that fills in omitted arguments. That synthetic method is another entry in the method count, and R8 cannot always remove it.

equals uses the class identity too. Two data classes with identical properties but different class names are never equal. That is obvious when stated, and a surprising source of bugs when one of them is a subclass.

Objects: a singleton with a field#

object Session {
    var token: String? = null
}

becomes a class with:

  • a private constructor,
  • a public static final Session INSTANCE field,
  • static state (the token backing field),
  • instance getters and setters that read and write that static field.

This is why object is thread-safe in its initialisation (the class loader guarantees it) and not thread-safe in its state. Two threads calling Session.token = x race exactly as they would with a global variable, because that is what it is.

Extension functions: static methods in disguise#

fun String.capitalise(): String = replaceFirstChar { it.uppercase() }

compiles to:

public static final String capitalise(String $this$capitalise) {
    // implementation
}

Three consequences follow directly:

  • The extension cannot access private members of String, because it is not inside String.
  • The extension is resolved statically. If a class has a member function with the same name, the member always wins, silently.
  • It is a static call, so there is no virtual dispatch to mock or override.

Inline functions: code, not objects#

A lambda in Kotlin normally becomes an object — an instance of Function0, Function1 and so on. For a function called in a hot loop, that is an allocation per call.

inline tells the compiler to copy the function body, and the lambda body, into the call site:

inline fun <T> measure(block: () -> T): Pair<T, Long> {
    val start = System.nanoTime()
    val result = block()
    return result to (System.nanoTime() - start)
}

After inlining there is no lambda object and no function call: just the block, expanded. That is why inline exists in Kotlin's standard library for functions like run, let and with.

The cost is code size: every call site gets its own copy. And the restrictions are a direct consequence: an inline function cannot store its lambda parameter in a variable, because there is no object to store.

Suspend functions: a method with a label#

This is the one worth understanding properly.

suspend fun loadProfile(repo: Repository): Profile {
    val token = repo.awaitToken()      // suspension point 1
    val user = repo.load(token)        // suspension point 2
    repo.save(user)                    // suspension point 3
    return Profile(user)
}

The compiler transforms this into roughly:1

public final Object loadProfile(Repository repo, Continuation<? super Profile> cont) {
    Object result = repo.awaitToken(cont);
    if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED;
    // ... continue, saving locals into the continuation
}

with a generated continuation class holding:

  • a label field, set to 0, 1, 2 or 3,
  • fields for every local variable that is live across a suspension point,
  • the completion callback.

On suspension, the function returns normally — it does not block. It returns the sentinel COROUTINE_SUSPENDED, and the dispatcher later calls resumeWith on the continuation, which re-enters the same method and jumps to the saved label.

Loading diagram…
Text description of this diagram

A state machine with six states. label0 is first entry. label1 is resumed after awaitToken returns. label2 is resumed after load returns. label3 is resumed after save returns. done is the final return. COROUTINE_SUSPENDED is a warning state, not a state of the function but the value returned to signal that the frame has been parked. Transitions: label0 to suspended when awaitToken suspends, setting label to 1; suspended back to label1 on resume with the token; label1 to suspended when load suspends; suspended to label2 on resume; label2 to suspended when save suspends; suspended to label3 on resume; label3 returns; and each label can also go straight to done if the callee did not actually suspend.

That last transition is the subtle one. A suspend function only parks if the callee actually suspends. If a cached value is returned immediately, the state machine falls through without returning, and the whole chain completes synchronously. This is why suspend is not "always async" — it is "can suspend".

K2 and KSP#

K2 is the rewritten Kotlin compiler front end. Its practical impact is speed: faster analysis, faster compilation, and better IDE responsiveness.1 It also enables language features the old front end could not express. The generated bytecode shape is unchanged.1

KSP (Kotlin Symbol Processing) is the annotation-processing answer that avoids kapt's javac round trip.1 kapt generates Java stubs from Kotlin, runs the processor, then compiles them — three passes. KSP reads Kotlin symbols directly, so it is significantly faster and understands Kotlin constructs that kapt flattens away.

What Kotlin does not have#

  • No checked exceptions. The compiler will not force you to handle IOException. This is convenient and it is a real source of unhandled errors.
  • No primitive-typed generics (List<Int> boxes on the JVM). Value classes are the workaround.
  • No tail-call optimisation on the JVM.
  • No static members in the Java sense; top-level functions and objects compile to static methods and static fields on a generated class, which is close but not identical.

Why this knowledge pays for itself#

Three concrete situations where knowing the compilation shape changes what you do.

APK size. A data class with eight properties generates roughly a dozen methods: eight getters, eight componentN, copy, copy$default, equals, hashCode, toString. Across a hundred model classes, that is measurable. R8 removes the ones nothing calls, which is why componentN and copy often disappear from a release build — and why a debug build's method count is a poor predictor of the release one's.

Stack traces. A crash inside a generated copy$default frame is not a bug in your code; it is the compiler's synthetic method. Recognising the name saves a confused hour.

Interop boundaries. A Kotlin function that returns String to a Java caller is a platform type on the Java side, with no nullability information. The Kotlin compiler emits a null check on the Kotlin side, but the Java caller can still pass null in. The boundary is where the guarantees stop, and knowing that is what makes a mixed codebase safe.

How it evolved#

2016–2023Kotlin 1.x front endthe original type checker and code generator
2024–nowK2rewritten front end; faster analysis and compilation
2021–nowKSPannotation processing without javac stubs

Under the hood#

  • the compiler itself, as vendored for the platform build
  • the coroutines library, used by platform code as well as apps
  • what actually executes the generated DEX, suspend state machine included

Curiosity corner#

  • inline exists for allocation, not elegance. The keyword is an instruction to the compiler about code shape, not a hint about intent.
  • Every suspend function allocates a continuation object the first time it suspends. That is why a tight loop of suspend calls is not free, and why channelFlow and similar APIs exist.
  • Decompiling your own Kotlin is the fastest way to learn it. javap -c on a small file is more instructive than most tutorials.

Try it#

See the generated members

javap -p app/build/tmp/kotlin-classes/debug/com/example/User.class

Example output
public final class User {
  public User(java.lang.String, int);
  public final java.lang.String getName();
  public final int getAge();
  public final java.lang.String component1();
  ...
See the suspend signature

javap -p app/build/tmp/kotlin-classes/debug/com/example/ProfileRepository.class | grep loadProfile

Example output
public final java.lang.Object loadProfile(Repository, Continuation);
Disassemble the state machine

javap -c app/build/tmp/kotlin-classes/debug/com/example/ProfileRepository.class

Example output
  invokevirtual #9  // Method awaitToken
  aload_1
  instanceof #12 // CoroutineSingletons

Build it#

Write a suspend function with two suspension points, compile it, and decompile the class. Find the label field. Then call it with two different dispatchers and confirm the same bytecode runs on both — the dispatcher is a runtime decision, not a compile-time one.

Check yourself#

Check yourself

1 / 12

Why did Android choose the Java language but not the JVM?

0 answered · 0 correct

Sources#

Sources

Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.

  1. docsKotlin documentationkotlinlang.orgaccessed 2026-10-27↩
  2. docsAndroid for Kotlin developersdeveloper.android.comaccessed 2026-10-27↩
  3. docsKotlin coroutines on Androiddeveloper.android.comaccessed 2026-10-27↩
  4. docsAndroid Runtime (ART) and Dalviksource.android.comaccessed 2026-10-27↩