<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Performance on Trevor's Blog</title><link>http://hitrevor.com/en/tags/performance/</link><description>Recent content in Performance on Trevor's Blog</description><generator>Hugo</generator><language>en-US</language><lastBuildDate>Fri, 13 Nov 2026 12:57:05 +0800</lastBuildDate><atom:link href="http://hitrevor.com/en/tags/performance/index.xml" rel="self" type="application/rss+xml"/><item><title>Kotlin Syntactic Sugar in Bytecode: Which Ones Are Actually Free</title><link>http://hitrevor.com/en/posts/sugar/</link><pubDate>Fri, 13 Nov 2026 12:57:05 +0800</pubDate><guid>http://hitrevor.com/en/posts/sugar/</guid><description>I compiled a dozen Kotlin sugars and read the javap output. Three tiers: &lt;code&gt;?.&lt;/code&gt; is a real ifnull jump with zero allocation, inline saves 0.01 B per call while copying the body, string templates hinge on jvmTarget, and lazy&amp;rsquo;s three modes I could not measure.</description><content:encoded><![CDATA[<p>Half of Kotlin&rsquo;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.</p>
<p>The JVM knows nothing about any of it. After compilation it only sees classes, methods, fields and bytecode instructions.</p>
<p>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.</p>
<p><img alt="A dozen syntactic sugars, sorted into three cost tiers" loading="lazy" src="/images/sugar/fig1.png"></p>
<h2 id="start-with-the-cleanest-one">Start with the cleanest one</h2>
<p><code>s?.length ?: -1</code>, a safe call plus an elvis fallback.</p>
<p>Compiled, it is 8 instructions:</p>
<p>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.</p>
<p>No object allocation, no extra function call, no try catch. It is an ifnull jump.</p>
<p>That is true zero cost. The bytecode from <code>?.</code> and from hand-writing <code>if (s != null) s.length else -1</code> is essentially identical.</p>
<h2 id="but--charges-you">But !! charges you</h2>
<p>Same goal, convincing the compiler to trust me: <code>s!!.length</code> compiles to 5 instructions with an <code>Intrinsics.checkNotNull</code> static call wedged in the middle.</p>
<p>Every time execution reaches <code>!!</code>, it really does run a null check, and throws NullPointerException when that fails.</p>
<p>This is not a compile-time guarantee. It is one function call at runtime.</p>
<p>Ordinary parameters are more interesting. I wrote <code>fun checked(s: String): Int = s.length</code>, a non-null parameter type and a body that does nothing at all. Compiled, 6 instructions, and the first thing it does is <code>Intrinsics.checkNotNullParameter(s, &quot;s&quot;)</code>.</p>
<p>Which means the public entry point of every Kotlin function with a non-null parameter checks it first. <code>!!</code> is an explicit check, a non-null parameter is an implicit one, and in bytecode they are the same category of thing.</p>
<p><img alt="The cost of three ways to write null safety" loading="lazy" src="/images/sugar/fig2.png"></p>
<h2 id="extension-functions-and-infix-calls-pure-rewriting">Extension functions and infix calls: pure rewriting</h2>
<p><code>fun String.shout(): String</code> compiles to a static method <code>shout(String)</code>, with the receiver becoming the first parameter.</p>
<p><code>infix fun Int.plus2(other: Int)</code> is also a static method, <code>plus2(int, int)</code>.</p>
<p><code>operator fun plus(o: Op)</code> is just an ordinary member method, <code>plus(Op)</code>.</p>
<p>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.</p>
<p>Zero cost, because they never changed the program&rsquo;s structure. They are a different way of writing down the same method.</p>
<h2 id="when-compiles-to-tableswitch">when compiles to tableswitch</h2>
<p><code>when (n) { 0 -&gt; ...; 1 -&gt; ...; 2 -&gt; ...; else -&gt; ... }</code> compiles to <code>tableswitch</code>, as long as the branch values are consecutive integers.</p>
<p>That is the fastest branch the JVM has: one table lookup and jump, no comparing cases in sequence.</p>
<p>Sparse values degrade to <code>lookupswitch</code>, which is a binary search underneath. Non-constant branches turn into a chain of if checks.</p>
<p>So when&rsquo;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.</p>
<h2 id="data-class-ten-methods">data class: ten methods</h2>
<p>One <code>data class Point(val x: Int, val y: Int)</code> produces a 2111-byte class file and ten things:</p>
<p>two fields, a constructor, two getters, component1, component2, copy, copy$default, toString, hashCode, equals.</p>
<p>You wrote one line of source. The bytecode holds an entire class.</p>
<p>Two of those ten exist purely for destructuring. <code>val (a, b) = point</code> compiles to two componentN calls.</p>
<p>copy$default exists to serve copy with default arguments. Its signature is <code>(Point, int, int, int, Object)</code>, where the third int is a bitmask telling it which parameters took their defaults.</p>
<p><img alt="Ten things generated by one line of data class" loading="lazy" src="/images/sugar/fig4.png"></p>
<h2 id="object-one-static-field">object: one static field</h2>
<p><code>object Singleton</code> compiles to:</p>
<p>a <code>public static final Singleton INSTANCE</code> field, a private constructor, and a static initializer block.</p>
<p>A singleton is a class that locks itself down and holds its only instance in a static field. Writing <code>Singleton.name</code> in Kotlin becomes <code>getstatic INSTANCE</code> plus one method call in bytecode. No reflection, none of the lazy-initialization machinery.</p>
<h2 id="default-parameters-one-more-method">Default parameters: one more method</h2>
<p><code>fun greet(name: String, title: String = &quot;engineer&quot;)</code> generates <code>greet(String, String)</code> and also <code>greet$default(String, String, int, Object)</code>.</p>
<p>That int is a bitmask: bit n set means parameter n took its default. The Object is a marker slot reserved for inline classes.</p>
<p>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.</p>
<p>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.</p>
<h2 id="lazy-delegation-one-field-plus-one-class">lazy delegation: one field plus one class</h2>
<p><code>val text: String by lazy { ... }</code> compiles to 1416 bytes:</p>
<p>a <code>private final kotlin.Lazy text$delegate</code> field, a <code>getText()</code> method, and a static method to hold the lambda.</p>
<p>Every read of <code>text</code> goes through <code>getText()</code>, which delegates to <code>Lazy.getValue()</code>. So a lazy property access always costs one more virtual call than a plain property.</p>
<p>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.</p>
<p>So do not pick a mode off a blog post unless you have actually measured it on your own workload.</p>
<h2 id="inline-saves-objects-costs-code">inline: saves objects, costs code</h2>
<p>inline is the most interesting of the set because it cuts both ways: it removes one call and duplicates the code several times over.</p>
<p>I wrote an <code>inline fun inlined(x: Int)</code> and a plain function with an identical body, each called from six functions.</p>
<p>In the plain version the call sites hold nothing but invokestatic, and the body exists once in the bytecode.</p>
<p>In the inline version all six call sites have the body pasted in. Every useX method shows imul and ishl directly.</p>
<p>That is the essence of inline: one function call and a possible lambda object removed, paid for in duplicated code.</p>
<p>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.</p>
<p>9,872 out of a million is 0.01 B per call, 0.03% of that 36 B object.</p>
<p>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.</p>
<p>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:</p>
<p>expected performance impact from inlining is insignificant. Inlining works best for functions with parameters of function types.</p>
<p>The compiler is telling you your inline is useless, and only worth it when a parameter is a function type.</p>
<p>The compiler talking you out of abusing inline is more honest than most technical blogs.</p>
<p><img alt="Code duplication in inline" loading="lazy" src="/images/sugar/fig3.png"></p>
<h2 id="string-templates-still-use-stringbuilder">String templates still use StringBuilder</h2>
<p>One left, and it was the most surprising.</p>
<p><code>&quot;$name is $age years old, ${age * 2}&quot;</code> compiles to <code>new StringBuilder</code> with five appends and a toString.</p>
<p>Java has been on invokedynamic plus String Concat Factory since 9. Kotlin is a beat behind on this one.</p>
<p>I assumed it was a flag problem, so I tried <code>-Xstring-concat=indy-with-constants</code>, and the compiler warned: this flag does nothing with JVM target 1.8.</p>
<p>Because Kotlin&rsquo;s default jvmTarget is 1.8, class file major version 52.</p>
<p>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.</p>
<p>The deciding factor is jvmTarget, not the flag.</p>
<p>So which implementation your string templates get depends on what <code>jvmTarget</code> says in your build script. Most Android projects are still on 1.8 or 11, which means StringBuilder.</p>
<p><img alt="Four combinations, four implementations" loading="lazy" src="/images/sugar/fig5.png"></p>
<h2 id="the-tiers-after-taking-it-apart">The tiers, after taking it apart</h2>
<p>Once it is laid out, three tiers.</p>
<p>Genuinely free: safe calls, extension functions, infix calls, operator overloading. In bytecode they are ordinary method calls and jumps, not one instruction extra.</p>
<p>More code, no more time: data class, object, default parameters, when. The generated output looks alarming, but runtime behavior matches hand-written code.</p>
<p>Real cost: <code>!!</code> and the runtime check on every non-null parameter, lazy&rsquo;s delegated call on every access, inline&rsquo;s code duplication, and StringBuilder for string templates at a low jvmTarget.</p>
<p>And one more tier, &ldquo;depends on config&rdquo;: which string template implementation you get changes the moment you edit one line of jvmTarget.</p>
<p>The feeling I came away with is that &ldquo;syntactic sugar&rdquo; 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.</p>
]]></content:encoded></item></channel></rss>