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

Decompiling Kotlin — What the Compiler Actually Generates

9 min read Chapter 17 of 21

Let’s open the hood on six Kotlin features you use every day and see exactly what the compiler produces. For each one, we’ll look at the Kotlin source, the decompiled Java equivalent, and the bytecode-level cost.

Data Classes: Five Methods You Didn’t Write

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

Decompile this and you get a class with six generated members beyond the constructor:

public final class User {
    private final String name;
    private final int age;

    public User(String name, int age) {
        Intrinsics.checkNotNullParameter(name, "name");
        this.name = name;
        this.age = age;
    }

    // component functions for destructuring
    public final String component1() { return this.name; }
    public final int component2() { return this.age; }

    // copy with default parameter pattern
    public final User copy(String name, int age) {
        Intrinsics.checkNotNullParameter(name, "name");
        return new User(name, age);
    }

    // synthetic bridge for default copy parameters
    public static User copy$default(User self, String name, int age, int mask, Object handler) {
        if ((mask & 1) != 0) name = self.name;
        if ((mask & 2) != 0) age = self.age;
        return self.copy(name, age);
    }

    public String toString() {
        return "User(name=" + this.name + ", age=" + this.age + ")";
    }

    public int hashCode() {
        int result = this.name.hashCode();
        result = result * 31 + Integer.hashCode(this.age);
        return result;
    }

    public boolean equals(Object other) {
        if (this == other) return true;
        if (!(other instanceof User)) return false;
        User otherUser = (User) other;
        return Intrinsics.areEqual(this.name, otherUser.name) && this.age == otherUser.age;
    }

    // getters
    public final String getName() { return this.name; }
    public final int getAge() { return this.age; }
}

Notice the copy$default synthetic method. It takes a bitmask integer that tracks which parameters the caller provided. When you write user.copy(age = 30), the compiler calls copy$default(user, null, 30, 1, null) — the mask 1 tells the method to pull name from the original instance.

Why data class inheritance is restricted: Data classes are final. You can’t extend them, and they can’t extend other data classes. The reason is equals() symmetry. If Admin extends User and User.equals() checks instanceof User, then user.equals(admin) is true but admin.equals(user) could be false. The compiler prevents this contract violation by disallowing the hierarchy entirely.

Performance note: Each copy() call allocates a new object. In tight loops or reactive streams, chained copies like user.copy(name = "A").copy(age = 1) create intermediate garbage. The compiler does not optimize these away.

Object Declarations: Singletons via Class Loading

object Logger {
    fun log(message: String) = println(message)
}

The decompiled output reveals the classic singleton pattern:

public final class Logger {
    public static final Logger INSTANCE;

    private Logger() {}

    static {
        Logger var0 = new Logger();
        INSTANCE = var0;
    }

    public final void log(String message) {
        System.out.println(message);
    }
}

The INSTANCE field is populated in a static {} initializer block. This is thread-safe because the JVM specification guarantees that class initialization is synchronized — only one thread can execute the static initializer, and the result is visible to all threads afterward. No volatile, no double-checked locking, no synchronized blocks required.

In Java, you’d either write this boilerplate yourself or use an enum singleton:

// Java equivalent — more ceremony for the same guarantee
public enum Logger {
    INSTANCE;
    public void log(String message) { System.out.println(message); }
}

Kotlin’s object compiles to a class-based singleton, not an enum. The bytecode cost is identical to a regular class with a static field.

Companion Objects: The Hidden Inner Class

class Config {
    companion object {
        const val MAX_RETRIES = 3
        fun default(): Config = Config()
    }
}

This compiles to two classes:

public final class Config {
    public static final int MAX_RETRIES = 3;
    public static final Config.Companion Companion = new Config.Companion(null);

    public static final class Companion {
        public final Config default() {
            return new Config();
        }

        private Companion() {}

        // synthetic constructor for internal access
        public Companion(DefaultConstructorMarker marker) {
            this();
        }
    }
}

The const val gets inlined — MAX_RETRIES becomes a static final field on the outer class, and usages get replaced with the literal 3 at call sites. No accessor method, no companion object involved.

But default() lives on the Companion inner class. From Java, you’d call it as Config.Companion.default() — awkward. Fix it with @JvmStatic:

companion object {
    @JvmStatic fun default(): Config = Config()
}

Now the compiler generates both a method on the Companion class and a static delegating method on Config itself:

// Generated on Config class
public static final Config default() {
    return Companion.default();
}

Java callers can now write Config.default(). The delegation adds one extra method call — the JIT inlines this trivially.

@JvmField serves a similar purpose for properties. Without it, a companion object property generates a getter on the Companion class. With @JvmField, the field is exposed directly on the outer class.

Extension Functions: Static Methods in Disguise

fun String.wordCount(): Int = this.split("\\s+".toRegex()).size

Decompiled:

public final class StringExtensionsKt {
    public static final int wordCount(String $this$wordCount) {
        // split logic...
        return result;
    }
}

The receiver becomes the first parameter. Extension functions are static methods — no bytecode mechanism for extending a class at runtime. Three consequences follow from this:

No access to private members. The extension function isn’t a member of String, it’s a static method in a separate class. It can only access String’s public API, just like any other external code.

No dynamic dispatch. Extension functions are resolved at compile time based on the declared type, not the runtime type:

open class Base
class Derived : Base()

fun Base.greet() = "Base"
fun Derived.greet() = "Derived"

val obj: Base = Derived()
println(obj.greet()) // prints "Base" — resolved statically

In Java terms, this is GreetKt.greet(obj) where the method signature takes Base. The compiler chose the Base overload at compile time. If you expected runtime polymorphism, you need a member function or an interface.

Namespace collisions. If a member function with the same signature exists, the member always wins. The extension function becomes unreachable. The compiler will warn you, but only if both are visible in the same file.

when Expressions: Three Bytecode Strategies

The compiler doesn’t generate the same bytecode for every when expression. It picks from three strategies depending on the data:

Dense integers → tableswitch

fun describe(code: Int): String = when (code) {
    200 -> "OK"
    201 -> "Created"
    202 -> "Accepted"
    204 -> "No Content"
    else -> "Unknown"
}

If the values form a dense range (few gaps), the compiler emits a tableswitch — a jump table indexed by value. O(1) dispatch, no comparisons. This is the fastest variant.

Sparse integers → lookupswitch

fun describe(code: Int): String = when (code) {
    200 -> "OK"
    404 -> "Not Found"
    500 -> "Server Error"
    else -> "Unknown"
}

When values are spread far apart, a jump table would be wasteful (200+ empty slots). The compiler uses lookupswitch — a sorted table with binary search. O(log n) dispatch.

Types, conditions, or strings → if-else chain

fun classify(obj: Any): String = when (obj) {
    is String -> "text"
    is Int -> "number"
    is List<*> -> "list"
    else -> "other"
}

No switch instruction works here — instanceof checks compile to sequential if-else comparisons. Order matters: put the most frequent cases first.

For String values in when, the compiler typically uses hashCode() comparisons first (like Java’s switch-on-string), then equals() to verify.

String Templates: StringBuilder Under the Hood

val greeting = "Hello, $name! You have $count messages."

Decompiled:

String greeting = "Hello, " + name + "! You have " + count + " messages.";

The Kotlin compiler relies on the same javac-style string concatenation optimization. On JDK 9+, this compiles to invokedynamic with StringConcatFactory — the JVM generates an optimal concatenation strategy at runtime. On JDK 8, you get explicit StringBuilder chains.

For complex templates with multiple expressions, verify you’re not creating intermediate strings in the template expressions themselves:

// This creates an intermediate string from items.joinToString()
// before feeding it to the template
val report = "Items: ${items.joinToString(", ")}, Total: ${items.size}"

The template overhead is zero — it’s the expression inside ${} that might allocate.

Default Parameters: The Bitmask Trick

fun connect(host: String, port: Int = 443, timeout: Long = 5000L) { /* ... */ }

The compiler generates two methods:

// Full-parameter method (the real implementation)
public static final void connect(String host, int port, long timeout) { /* ... */ }

// Synthetic method with bitmask
public static void connect$default(
    String host, int port, long timeout, int mask, Object handler
) {
    if ((mask & 2) != 0) port = 443;
    if ((mask & 4) != 0) timeout = 5000L;
    connect(host, port, timeout);
}

When you call connect("localhost"), the compiler emits connect$default("localhost", 0, 0L, 6, null). The mask 6 (binary 110) indicates that parameters 2 and 3 should use defaults.

Java callers don’t have access to this mechanism — they see only the full-parameter method. Add @JvmOverloads to generate real Java-style overloads:

@JvmOverloads
fun connect(host: String, port: Int = 443, timeout: Long = 5000L) { /* ... */ }

Now the compiler generates three methods:

public static final void connect(String host, int port, long timeout) { /* ... */ }
public static final void connect(String host, int port) { connect(host, port, 5000L); }
public static final void connect(String host) { connect(host, 443); }

Each overload delegates to the next, filling in the default. Java callers get a natural API. The cost is additional method entries in the class file — negligible in practice.

Performance Overhead Summary

FeatureJVM OverheadNotes
Data class methodsZero at declarationcopy() allocates; equals()/hashCode() are standard-cost
Object declarationZeroClass-loading singleton, same as Java static field
Companion objectOne inner class@JvmStatic/@JvmField eliminate accessor indirection
Extension functionsZeroStatic method call, identical to Java utility class
when (int)Zerotableswitch/lookupswitch, same as Java switch
when (type/string)Zeroif-else chain, same as Java equivalent
String templatesZeroSame concatenation strategy as Java
Default parametersNear-zeroOne extra synthetic method; bitmask check is trivially cheap

The pattern is clear: most Kotlin features compile down to exactly what you’d write in Java. The overhead, when it exists, comes from what the feature does (allocations in copy(), boxing in generics) rather than how the compiler implements it.