← Back to books

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 static method on a synthetic class named after the file — Utils.kt produces UtilsKt. 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 internal visibility, 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:

"Unit is like void. Nothing means it throws."

Senior answer:

"Unit has exactly one value; Nothing has zero. That's the whole difference and everything else follows. A function returning Unit returns — it just has nothing interesting to say. A function returning Nothing cannot return; it throws, loops forever, or kills the process. Because Nothing has no values it's the bottom type, a subtype of everything, which is what makes throw usable as an expression on the right side of an Elvis. Practically: I declare error helpers as returning Nothing so 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 null and 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: lateinit accessed before initialization — though technically that's UninitializedPropertyAccessException. Four: leaking this from a constructor so an overridden open val reads an uninitialized backing field, which is one of the few ways to get null into a non-null type without Java involved. Five: a Java caller passing null into 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/@NonNull rather 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:

  1. A data class — see the generated equals, hashCode, toString, copy, and componentN functions, and notice that copy is a shallow copy.
  2. An inline function with a lambda — see the lambda body pasted into the call site, with no Function0 object allocated anywhere.
  3. A suspend function — see the extra Continuation parameter, the int label field, and the switch statement. 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 static methods on a synthetic FileNameKt class. Extension functions are static methods with the receiver as the first parameter — hence static dispatch.
  • internal has no JVM equivalent; it's compiled as public with a mangled name.
  • Unit is a real type with exactly one value, so functions can be first-class. Nothing is the bottom type with zero values — it can't return, which is what makes throw work as an expression.
  • Declare error helpers as returning Nothing to get free smart casts.
  • Nullability is erased at runtime. It's enforced by the compiler plus Intrinsics.checkNotNullParameter checks 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> vs List<Int>, and why reified had to be invented.

Practice

  1. Decompile a data class, an inline function, and a suspend function. Write down one thing that surprised you about each.

  2. Explain, in two sentences, why this fails to compile:

    fun <T> Any.castOrNull(): T? = this as? T
    
  3. Given 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.