Chapter 2: Kotlin on the JVM
Most Kotlin books start with val and var. This one starts one level down, because almost every Kotlin interview question that feels unfair turns out to be a JVM question wearing Kotlin syntax.
Why can't you have a generic array of a reified type without inline? JVM. Why does List<String> and List<Int> collide on overload resolution? JVM. Why does your data class need @JvmOverloads before Java can call it with defaults? JVM. Why is Nothing a type and not a keyword? That one is Kotlin's type theory — but the reason it matters is JVM interop.
You do not need to read bytecode to pass a Kotlin interview. You do need a working mental model of what the compiler emits. This chapter builds it.
2.1 What the Compiler Actually Emits
Kotlin compiles to JVM bytecode. Not to Java source — straight to .class files. But the bytecode it emits has to be callable from Java, which constrains everything.
Take a top-level function:
// File: StringUtils.kt
package com.example.util
fun String.shout(): String = uppercase() + "!"
There is no such thing as a top-level function on the JVM. Everything lives in a class. So Kotlin invents one — a class named after the file, with Kt appended — and makes the function static:
// Roughly what Java sees:
public final class StringUtilsKt {
public static String shout(String $this$shout) { ... }
}
Two things fall out of this immediately, and both are interview questions.
First: the extension receiver is just a parameter. String.shout() compiles to shout(String). The receiver is passed in like any other argument. This is the entire explanation for the static-dispatch behavior you saw in Chapter 1 — there is no vtable, no virtual call, no polymorphism. There is a static method and an argument.
Second: the file name is part of your public API. Rename StringUtils.kt to Strings.kt and every Java caller breaks. This is what @JvmName is for, and it is why library authors pin it explicitly.
Question: "What happens to a top-level function when it's compiled?"
Naive answer:
"It becomes a static method somewhere."
Senior answer:
"It becomes a
public staticmethod on a synthetic class named after the file —Utils.ktproducesUtilsKt. That class name is part of your binary API, so if you ship a library you should pin it with@file:JvmName("Utils"), otherwise a file rename is a breaking change for Java consumers. It also means extension functions are static methods with the receiver as the first parameter, which is why they dispatch on static type rather than runtime type."
Follow-up they'll ask next:
"So how does Kotlin implement
internalvisibility, given the JVM has no such modifier?"
Worth knowing: it doesn't, cleanly. internal members are compiled as public with a mangled name (myFunction$module_name). Java code can call them; it just has to know the mangled name, and the compiler makes that unpleasant enough that nobody does it by accident. internal is a Kotlin-compiler guarantee, not a JVM one.
2.2 Unit Is a Type, Not a Void
Java has void, which is not a type — it is a hole in the type system. You cannot write List<void>. You cannot have a generic function return void and have it unify with anything.
Kotlin needs functions to be first-class. (Int) -> Unit has to be a real type, so Unit has to be a real type. It is a singleton object with exactly one instance:
public object Unit {
override fun toString() = "kotlin.Unit"
}
A function declared to return Unit compiles to a void method on the JVM (the compiler optimizes the singleton away), unless it's used in a generic position, where boxing to the actual Unit instance happens.
The practical consequence:
val callbacks = mutableListOf<() -> Unit>() // legal, obviously
val results = mutableListOf<Unit>() // legal, weird, but legal
And the one that shows up in real code:
// This does NOT do what you think
val nums = listOf(1, 2, 3)
val doubled = nums.map { println(it) } // List<Unit>, not List<Int>
map returns the value of the lambda's last expression. println returns Unit. You now have [Unit, Unit, Unit]. The fix is forEach, or onEach if you want to keep the chain going.
2.3 Nothing: The Type With No Values
Nothing is the bottom type. It has zero instances. No value has type Nothing, which means an expression of type Nothing cannot return normally — it must throw, loop forever, or exit the process.
Because it has no values, it is a subtype of every type. That sounds like a party trick. It is actually load-bearing.
Load-bearing use 1 — throw is an expression:
val name: String = person.name ?: throw IllegalStateException("no name")
throw has type Nothing. The Elvis operator needs both branches to unify to a common type. Nothing is a subtype of String, so the whole expression is String. Without a bottom type, this line would not typecheck.
Load-bearing use 2 — unreachable code detection:
fun fail(message: String): Nothing = throw IllegalStateException(message)
fun process(input: String?): Int {
val value = input ?: fail("input required")
return value.length // compiler knows `value` is String, not String?
}
Declare your error helper as returning Nothing and the compiler will smart-cast for you. Declare it as returning Unit and it will not. This is a free win that most codebases leave on the table.
Load-bearing use 3 — Nothing? and inference:
val x = null // inferred type: Nothing?
Nothing? is the type whose only value is null. That is why listOf(null) gives you List<Nothing?> and why emptyList() can be assigned to List<String> — List<Nothing> is a subtype of List<String> under covariance.
Question: "What's the difference between Unit and Nothing?"
Naive answer:
"
Unitis likevoid.Nothingmeans it throws."
Senior answer:
"
Unithas exactly one value;Nothinghas zero. That's the whole difference and everything else follows. A function returningUnitreturns — it just has nothing interesting to say. A function returningNothingcannot return; it throws, loops forever, or kills the process. BecauseNothinghas no values it's the bottom type, a subtype of everything, which is what makesthrowusable as an expression on the right side of an Elvis. Practically: I declare error helpers as returningNothingso the compiler can smart-cast past them."
Follow-up they'll ask next:
"What's the inferred type of
val x = null?"
Nothing?. And if they're being thorough: what's the inferred type of emptyList() with no expected type? List<Nothing>.
2.4 Null Safety Is a Compile-Time Fiction (Mostly)
String and String? are the same class at runtime. There is one java.lang.String. Nullability is erased.
So how is it enforced? Two mechanisms:
1. The compiler refuses to emit unsafe code. Most of the guarantee is just the type checker saying no.
2. Intrinsic null checks at the boundary. For every public function with non-null parameters, the compiler inserts a check:
fun greet(name: String) = "Hello, $name"
emits, roughly:
public static String greet(String name) {
Intrinsics.checkNotNullParameter(name, "name");
return "Hello, " + name;
}
That check exists because Java can call this function and pass null. Kotlin cannot trust the caller. So it fails fast at the boundary rather than letting a null roam free inside Kotlin code where the type system says it cannot exist.
This is the correct behavior and it is also why "I got a NullPointerException in Kotlin" is not the contradiction people think it is. The NPE is the system working — it caught a lie at the border.
2.5 Platform Types: The Deliberate Hole
When Kotlin sees a Java method returning String with no nullability annotation, it does not know whether it can be null. It has three options:
- Assume nullable → every Java call becomes
?.-soup. Unusable. - Assume non-null → silent NPEs deep inside Kotlin code. Unsafe.
- Admit ignorance → platform types.
Kotlin picks the third. The type is written String! in error messages and IDE hints, and you cannot write it yourself. It means "the compiler is deferring to you." You may treat it as String or as String?, and the compiler will not complain either way.
// Java
public class Legacy {
public static String getName() { return null; }
}
// Kotlin
val a: String = Legacy.getName() // compiles. Throws at runtime.
val b: String? = Legacy.getName() // compiles. Safe.
Both lines typecheck. Only one is correct. Platform types are the largest source of NPEs in Kotlin Android code, because the Android framework is enormous and imperfectly annotated.
Question: "Kotlin is null-safe. So why did I just get an NPE?"
Naive answer:
"Someone used
!!."
Senior answer:
"Five ways, roughly in order of frequency in real code. One: platform types — an unannotated Java API returned
nulland I assigned it to a non-null type, so the intrinsic null check fired at the boundary. Two:!!, which is me explicitly asking for it. Three:lateinitaccessed before initialization — though technically that'sUninitializedPropertyAccessException. Four: leakingthisfrom a constructor so an overriddenopen valreads an uninitialized backing field, which is one of the few ways to getnullinto a non-null type without Java involved. Five: a Java caller passingnullinto a Kotlin non-null parameter, which the intrinsic check catches. The first one dominates, which is why I annotate our Java interop surface with@Nullable/@NonNullrather than relying on discipline."
Follow-up they'll ask next:
"How would you defend a codebase against platform-type NPEs?"
The answer they want: annotate the Java side (@Nullable, @NonNull, or @ParametersAreNonnullByDefault at the package level); treat every unannotated Java return as nullable at the Kotlin boundary and narrow it there, in one place, rather than letting the platform type propagate inward; and enable -Xjsr305=strict so JSR-305 annotations are enforced rather than advisory.
2.6 Type Erasure and Its Consequences
Generics are erased on the JVM. List<String> and List<Int> are both just List at runtime.
fun handle(items: List<String>) {}
fun handle(items: List<Int>) {}
// Error: Platform declaration clash — both have the same JVM signature
This is not Kotlin being fussy. Both functions would compile to handle(List). The JVM cannot tell them apart.
The escape hatches:
@JvmName("handleStrings")
fun handle(items: List<String>) {}
@JvmName("handleInts")
fun handle(items: List<Int>) {}
Or, more idiomatically, don't overload on erased generics.
Erasure is also why this fails:
fun <T> isOfType(value: Any): Boolean = value is T // Error: cannot check for erased type
and why inline + reified exists to fix it, which is Chapter 5's problem.
2.7 The Bytecode You Should Actually Look At
You will not read bytecode in an interview. But you should have read some, once, so that your mental model is grounded rather than folkloric.
In IntelliJ or Android Studio: Tools → Kotlin → Show Kotlin Bytecode, then hit Decompile. You get Java-ish source approximating what the JVM sees.
Three things worth decompiling at least once in your life, because each one silently answers a family of interview questions:
- A
data class— see the generatedequals,hashCode,toString,copy, andcomponentNfunctions, and notice thatcopyis a shallow copy. - An
inlinefunction with a lambda — see the lambda body pasted into the call site, with noFunction0object allocated anywhere. - A
suspendfunction — see the extraContinuationparameter, theint labelfield, and theswitchstatement. That is the coroutine state machine, and seeing it once makes all of Chapter 15 land.
Do this today. It takes ten minutes and it will pay for itself in the first interview.
2.8 Kotlin/JVM Is Not the Only Target
Brief, but interviewers occasionally check:
Kotlin compiles to JVM bytecode, JavaScript, and native binaries (via LLVM). Kotlin Multiplatform shares source across those targets. On Android you are always on the JVM path (then dexed to DEX for ART), but language features are constrained by the need to work everywhere. This is why, for example, expect/actual exists, and why platform-specific APIs like @JvmStatic are annotations rather than keywords.
Android adds a step: Kotlin → JVM bytecode → D8 (dexer) → DEX → R8 (optimizer/shrinker, in release). Chapter 19 covers what R8 does and does not do for you.
Chapter Summary
- Kotlin compiles directly to JVM bytecode that must be Java-callable, and nearly every "weird" Kotlin rule traces back to that constraint.
- Top-level functions become
staticmethods on a syntheticFileNameKtclass. Extension functions are static methods with the receiver as the first parameter — hence static dispatch. internalhas no JVM equivalent; it's compiled aspublicwith a mangled name.Unitis a real type with exactly one value, so functions can be first-class.Nothingis the bottom type with zero values — it can't return, which is what makesthrowwork as an expression.- Declare error helpers as returning
Nothingto get free smart casts. - Nullability is erased at runtime. It's enforced by the compiler plus
Intrinsics.checkNotNullParameterchecks at public boundaries. - Platform types (
String!) are the compiler admitting it doesn't know. They're the biggest real-world source of NPEs in Kotlin Android code. - Generics are erased. That's why you can't overload on
List<String>vsList<Int>, and whyreifiedhad to be invented.
Practice
Decompile a
data class, aninlinefunction, and asuspendfunction. Write down one thing that surprised you about each.Explain, in two sentences, why this fails to compile:
fun <T> Any.castOrNull(): T? = this as? TGiven a Java method
String getUserName()with no annotations, write the Kotlin call site that is both safe and not ugly. Then explain where you'd put that call so it happens exactly once.