Half of Kotlin’s convenience comes from syntactic sugar, and that has not changed on 2.5.0-RC in 2026. Extension functions, infix calls, operator overloading, null safety, default parameters, data class: they all read like native language features.
The JVM knows nothing about any of it. After compilation it only sees classes, methods, fields and bytecode instructions.
So I compiled a dozen of the common ones, one at a time, and opened them with javap to see what they turn into. The sorting came out clean: three are genuinely free, a few only save typing, and a few quietly charge you.

Start with the cleanest one
s?.length ?: -1, a safe call plus an elvis fallback.
Compiled, it is 8 instructions:
aload reads the parameter, dup copies it, ifnull jumps to the branch, call length, goto to merge, pop the leftover top of stack, push -1, return.
No object allocation, no extra function call, no try catch. It is an ifnull jump.
That is true zero cost. The bytecode from ?. and from hand-writing if (s != null) s.length else -1 is essentially identical.
But !! charges you
Same goal, convincing the compiler to trust me: s!!.length compiles to 5 instructions with an Intrinsics.checkNotNull static call wedged in the middle.
Every time execution reaches !!, it really does run a null check, and throws NullPointerException when that fails.
This is not a compile-time guarantee. It is one function call at runtime.
Ordinary parameters are more interesting. I wrote fun checked(s: String): Int = s.length, a non-null parameter type and a body that does nothing at all. Compiled, 6 instructions, and the first thing it does is Intrinsics.checkNotNullParameter(s, "s").
Which means the public entry point of every Kotlin function with a non-null parameter checks it first. !! is an explicit check, a non-null parameter is an implicit one, and in bytecode they are the same category of thing.

Extension functions and infix calls: pure rewriting
fun String.shout(): String compiles to a static method shout(String), with the receiver becoming the first parameter.
infix fun Int.plus2(other: Int) is also a static method, plus2(int, int).
operator fun plus(o: Op) is just an ordinary member method, plus(Op).
None of the three leaves a trace in bytecode saying it used to be syntactic sugar. The call site is a plain invokestatic or invokevirtual.
Zero cost, because they never changed the program’s structure. They are a different way of writing down the same method.
when compiles to tableswitch
when (n) { 0 -> ...; 1 -> ...; 2 -> ...; else -> ... } compiles to tableswitch, as long as the branch values are consecutive integers.
That is the fastest branch the JVM has: one table lookup and jump, no comparing cases in sequence.
Sparse values degrade to lookupswitch, which is a binary search underneath. Non-constant branches turn into a chain of if checks.
So when’s performance is entirely a function of which values you write, and the compiler guarantees nothing. Consecutive constants get you tableswitch. Anything else and it downgrades.
data class: ten methods
One data class Point(val x: Int, val y: Int) produces a 2111-byte class file and ten things:
two fields, a constructor, two getters, component1, component2, copy, copy$default, toString, hashCode, equals.
You wrote one line of source. The bytecode holds an entire class.
Two of those ten exist purely for destructuring. val (a, b) = point compiles to two componentN calls.
copy$default exists to serve copy with default arguments. Its signature is (Point, int, int, int, Object), where the third int is a bitmask telling it which parameters took their defaults.

object: one static field
object Singleton compiles to:
a public static final Singleton INSTANCE field, a private constructor, and a static initializer block.
A singleton is a class that locks itself down and holds its only instance in a static field. Writing Singleton.name in Kotlin becomes getstatic INSTANCE plus one method call in bytecode. No reflection, none of the lazy-initialization machinery.
Default parameters: one more method
fun greet(name: String, title: String = "engineer") generates greet(String, String) and also greet$default(String, String, int, Object).
That int is a bitmask: bit n set means parameter n took its default. The Object is a marker slot reserved for inline classes.
The call site decides what to do. Pass everything and it takes the normal method; miss one and it jumps to $default, which fills the gap and delegates.
I timed passing all of them against missing one, 5,000,000 iterations each, both landing between 8 and 9 milliseconds, the same as an ordinary five-parameter function. That overhead does not measure.
lazy delegation: one field plus one class
val text: String by lazy { ... } compiles to 1416 bytes:
a private final kotlin.Lazy text$delegate field, a getText() method, and a static method to hold the lambda.
Every read of text goes through getText(), which delegates to Lazy.getValue(). So a lazy property access always costs one more virtual call than a plain property.
A counterintuitive observation while I was in there. The docs list three LazyThreadSafetyMode values; I ran SYNCHRONIZED, PUBLICATION and NONE 5,000,000 times each, and differences like 5 milliseconds against 36 milliseconds flip from run to run. I could not measure a stable conclusion. At that level JIT behavior matters more than the mode itself.
So do not pick a mode off a blog post unless you have actually measured it on your own workload.
inline: saves objects, costs code
inline is the most interesting of the set because it cuts both ways: it removes one call and duplicates the code several times over.
I wrote an inline fun inlined(x: Int) and a plain function with an identical body, each called from six functions.
In the plain version the call sites hold nothing but invokestatic, and the body exists once in the bytecode.
In the inline version all six call sites have the body pasted in. Every useX method shows imul and ishl directly.
That is the essence of inline: one function call and a possible lambda object removed, paid for in duplicated code.
But how much does it actually save. I measured 1,000,000 higher-order calls with a lambda: the inline version allocates 0 B, the plain version 9,872 B.
9,872 out of a million is 0.01 B per call, 0.03% of that 36 B object.
Because JIT escape-analyzed the lambda away. On a modern JVM, the object overhead of a non-inline higher-order function basically does not exist.
The ironic part is that my inline example emitted a warning at compile time. The official docs say inline only matters for function-type parameters, and the compiler threw that sentence straight into the build output:
expected performance impact from inlining is insignificant. Inlining works best for functions with parameters of function types.
The compiler is telling you your inline is useless, and only worth it when a parameter is a function type.
The compiler talking you out of abusing inline is more honest than most technical blogs.

String templates still use StringBuilder
One left, and it was the most surprising.
"$name is $age years old, ${age * 2}" compiles to new StringBuilder with five appends and a toString.
Java has been on invokedynamic plus String Concat Factory since 9. Kotlin is a beat behind on this one.
I assumed it was a flag problem, so I tried -Xstring-concat=indy-with-constants, and the compiler warned: this flag does nothing with JVM target 1.8.
Because Kotlin’s default jvmTarget is 1.8, class file major version 52.
I tried all four combinations: target 1.8 is StringBuilder whether or not you pass the flag; target 21 needs no flag and becomes invokedynamic on its own, major version 65.
The deciding factor is jvmTarget, not the flag.
So which implementation your string templates get depends on what jvmTarget says in your build script. Most Android projects are still on 1.8 or 11, which means StringBuilder.

The tiers, after taking it apart
Once it is laid out, three tiers.
Genuinely free: safe calls, extension functions, infix calls, operator overloading. In bytecode they are ordinary method calls and jumps, not one instruction extra.
More code, no more time: data class, object, default parameters, when. The generated output looks alarming, but runtime behavior matches hand-written code.
Real cost: !! and the runtime check on every non-null parameter, lazy’s delegated call on every access, inline’s code duplication, and StringBuilder for string templates at a low jvmTarget.
And one more tier, “depends on config”: which string template implementation you get changes the moment you edit one line of jvmTarget.
The feeling I came away with is that “syntactic sugar” makes people assume the compiler will sort it out anyway. The compiler just faithfully translates what you wrote into instructions, and whether that is good or bad is entirely a question of what you wrote.
