Understanding how Kotlin compiles to bytecode is a common advanced interview topic. It shows whether you actually know what your Kotlin code does at runtime. This comes up frequently in senior-level interviews.
Kotlin source code is written in .kt files, compiled by the Kotlin compiler (kotlinc), and produces Java bytecode (.class files) that runs on the JVM. These .class files are identical in format to what javac produces from .java files. This is why Kotlin and Java interop freely — at the bytecode level, the JVM doesn’t know which language produced the class file.
The compiler generates equals(), hashCode(), toString(), copy(), and componentN() functions. These are generated only for properties in the primary constructor. In bytecode, a data class is a regular class with these methods added — there is no special JVM concept for “data class.”
A companion object compiles to a static inner class. For class UserRepository { companion object { fun create() = UserRepository() } }, the compiler generates UserRepository$Companion with the method as an instance method. The outer class gets a static final Companion field.
From Java, you’d call UserRepository.Companion.create(). Adding @JvmStatic generates a static method on UserRepository that delegates to the companion, so Java can call UserRepository.create().
@JvmStatic generates an additional static method on the enclosing class that delegates to the companion object method. It doesn’t move the method — it creates a bridge. This is purely a Java interop convenience.
Kotlin properties compile to a private field with getter and setter. @JvmField exposes the field directly without generating accessors. Useful for Java interop — instead of user.getName(), Java code accesses user.name directly. Also used for constants in companion objects to avoid the Companion access pattern.
Kotlin supports default parameter values but Java doesn’t. @JvmOverloads generates multiple overloaded methods — one for each combination of default parameters. Commonly used for custom View constructors.
class CustomCard @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr)
Kotlin inserts parameter checks at the beginning of public functions. For fun process(name: String), the compiler adds Intrinsics.checkNotNullParameter(name, "name") as the first instruction. This throws if Java code passes null. For private functions, the compiler may skip these checks. Nullable types generate no checks.
A sealed class compiles to an abstract class with a private constructor. Each subclass becomes a separate class. The sealed restriction is enforced at compile time by the Kotlin compiler, not by the JVM. when on a sealed class compiles to instanceof checks.
A sealed class compiles to an abstract class. A sealed interface compiles to a regular Java interface. A class can implement multiple sealed interfaces but only extend one sealed class. There is no JVM concept of “sealed” — it’s compile-time enforcement.
The compiler adds a Continuation<T> parameter and changes the return type to Any?. The function returns COROUTINE_SUSPENDED when it suspends, and the actual value when it completes.
// What you write
suspend fun fetchUser(id: String): User
// What the compiler generates
fun fetchUser(id: String, continuation: Continuation<User>): Any?
Each suspension point becomes a state. The compiler creates a class implementing Continuation with a label field tracking the current state. Local variables that survive across suspension points are stored as fields.
// Original: two suspension points
suspend fun loadProfile(id: String): Profile {
val user = fetchUser(id) // suspension point 1
val avatar = fetchAvatar(user) // suspension point 2
return Profile(user, avatar)
}
// Compiler generates:
// label 0 → call fetchUser, save state, return COROUTINE_SUSPENDED
// label 1 → restore state, call fetchAvatar, return COROUTINE_SUSPENDED
// label 2 → create Profile, return result
The compiler copies the function body and lambda body directly into the call site. No FunctionN object is allocated. repeat(3) { println(it) } compiles to the same bytecode as writing the loop manually. Non-local returns are only possible with inline lambdas.
A non-inline lambda compiles to an anonymous inner class implementing FunctionN (Function0, Function1, etc.). The lambda body goes into invoke(). If the lambda captures variables, those are stored as fields. Lambdas capturing mutable variables (var) are wrapped in Ref.ObjectRef.
val greet = { name: String -> "Hello, $name" }
// Compiles to roughly:
// class MainKt$main$greet$1 : Function1<String, String> {
// override fun invoke(name: String): String = "Hello, $name"
// }
SAM conversion lets you pass a lambda where a Java interface with a single abstract method is expected. For Java interfaces, Kotlin uses invokedynamic on newer JVM targets. For Kotlin’s fun interface, the compiler generates an adapter class.
fun interface Validator {
fun validate(input: String): Boolean
}
val emailValidator = Validator { it.contains("@") }
Kotlin’s SAM conversion only works on fun interface declarations, not regular Kotlin interfaces.
when on an Int or enum ordinal uses tableswitch (O(1) lookup) for dense values or lookupswitch (O(log n) binary search) for sparse values. when on a String switches on hashCode() then uses equals(). when with is checks generates instanceof instructions.
With by class delegation, the compiler generates forwarding methods for every method in the delegated interface. The delegate is stored as a field. Each forwarding method calls the delegate’s method. Zero runtime overhead compared to manual forwarding.
class LoggingList<T>(
private val inner: MutableList<T>
) : MutableList<T> by inner
// Compiler generates: add() → inner.add(), get() → inner.get(), etc.
The compiler replaces @JvmInline value class UserId(val id: String) with just String at most call sites. Boxing happens when used as nullable, generic, or through an interface. Functions accepting value classes get their names mangled to avoid signature clashes.
Property delegation compiles to a delegate field and a getter/setter that calls the delegate’s getValue()/setValue(). For lazy, the compiler stores a Lazy<T> instance and the getter calls lazy.value.
val config: Config by lazy { loadConfig() }
// Generates:
// private val config$delegate: Lazy<Config> = lazy { loadConfig() }
// val config: Config get() = config$delegate.value
by lazy defaults to SYNCHRONIZED with double-checked locking.
@JvmName assigns a custom name to a declaration in bytecode. Used when Kotlin’s default naming causes conflicts — like two functions with the same name but different generic types that erase to the same JVM signature. Also provides cleaner names for Java callers.
A regular call compiles to invokevirtual or invokestatic. A suspension point saves all live local variables into the continuation’s fields, sets the label, and returns COROUTINE_SUSPENDED. On resume, it restores fields and jumps to the correct label. A suspend function with 5 suspension points generates roughly 5x the bytecode of a regular function with 5 method calls.
object and companion object in bytecode?const val differ from val in a companion object?when on enum vs sealed class?