Skip to main content
kotlin in depth advanced patterns for java engineers

Kotlin and the JVM — Bytecode and Interop

4 min read Chapter 16 of 21

Every Kotlin file compiles to JVM bytecode. Understanding what the compiler generates is the difference between writing efficient code and accidentally creating performance problems.

You’ve been writing Kotlin — data classes, extension functions, when expressions, default parameters. They feel concise and expressive. But what’s happening underneath? The JVM doesn’t know what a data class is. It doesn’t understand extension functions. Every Kotlin feature you use compiles down to the same bytecode constructs Java uses: classes, methods, fields, and the standard JVM instruction set.

This chapter tears off the abstraction layer.

The K2 Compilation Pipeline

Kotlin Compilation Pipeline

The Kotlin compiler (since Kotlin 2.0, the K2 compiler) transforms your .kt files into .class files in two major phases:

Frontend — Parsing, name resolution, type checking, and control flow analysis. The K2 frontend produces FIR (Frontend Intermediate Representation), a fully resolved and type-checked tree. This is where the compiler decides what your code means: which overload to call, what type an expression has, whether a smart cast is valid.

Backend — FIR transforms into JVM bytecode. This is where Kotlin-specific features get lowered to JVM-compatible constructs. A data class becomes a regular class with generated methods. An extension function becomes a static method. A when expression becomes a tableswitch, lookupswitch, or if-else chain.

If you’re coming from Java, the critical difference is what happens between these phases. The Java compiler (javac) performs relatively straightforward lowering — your source maps closely to the bytecode. The Kotlin compiler performs substantially more transformation. A five-line data class can generate hundreds of bytecode instructions across multiple methods you never wrote.

This gap between source and bytecode is where performance surprises hide.

What This Chapter Covers

We’ll take a feature-by-feature approach. For each Kotlin construct, we’ll examine:

  1. The Kotlin source you write
  2. The bytecode the compiler generates (via decompilation)
  3. The performance implications — allocations, virtual dispatch, boxing

Then we’ll tackle the interop boundary: what happens when Kotlin code calls Java code and vice versa. Platform types, SAM conversions, nullability mismatches, and the annotations that smooth over the rough edges.

Your Bytecode Investigation Toolkit

Before we start decompiling, you need three tools in your workflow:

IntelliJ “Show Kotlin Bytecode”

The fastest feedback loop. Open any Kotlin file, then navigate to Tools → Kotlin → Show Kotlin Bytecode. Click “Decompile” to see the equivalent Java source. This gives you a readable Java representation of what the compiler generated.

// Write this in a .kt file
data class User(val name: String, val age: Int)

Hit “Decompile” and you’ll see the generated equals(), hashCode(), toString(), copy(), component1(), and component2() — six methods from a single line of Kotlin.

javap -c — Raw Bytecode

When the decompiled Java isn’t enough, javap shows actual JVM instructions:

kotlinc User.kt -include-runtime -d user.jar
javap -c -p -cp user.jar User

The -c flag disassembles bytecode. The -p flag shows private members. You’ll see the actual invokevirtual, invokestatic, and invokeinterface instructions the JVM executes. This is where you spot boxing operations (java/lang/Integer.valueOf) and unnecessary allocations.

kotlinc -Xprint-ir — Compiler IR

For deeper analysis, print the compiler’s intermediate representation:

kotlinc -Xprint-ir User.kt

This shows the lowered IR before bytecode generation — useful for understanding how the compiler transforms high-level Kotlin constructs. You’ll see desugared default parameters, generated synthetic methods, and the exact lowering strategy the compiler chose.

Comparing Java and Kotlin Output

A useful habit: write the same logic in both Java and Kotlin, compile both, and compare the bytecode with javap. When the outputs are identical, the Kotlin feature has zero overhead. When they differ, you’ve found something worth understanding.

// Java
public final class JavaUser {
    private final String name;
    public JavaUser(String name) { this.name = name; }
    public String getName() { return name; }
}
// Kotlin
class KotlinUser(val name: String)

Run javap -c on both — the bytecode is virtually identical. The Kotlin version generates the same field, constructor, and getter. No overhead.

Chapter Structure

The next sections decompose each major Kotlin feature into its bytecode representation:

Section 1: Decompiling Kotlin — Data classes, object declarations, companion objects, extension functions, when expressions, string templates, and default parameters. Each one examined through decompilation, with a performance overhead assessment.

Section 2: Java Interop Edge Cases — Platform types, SAM conversions, JVM annotations (@JvmStatic, @JvmField, @JvmOverloads, @Throws), and the practical gotchas of mixed-language codebases. Includes a checklist for making Kotlin libraries consumable from Java.

By the end, you’ll look at Kotlin source and see the bytecode underneath. That mental model is what separates engineers who use Kotlin from engineers who understand it.