<?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>Bytecode on Trevor's Blog</title><link>http://hitrevor.com/en/tags/bytecode/</link><description>Recent content in Bytecode 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/bytecode/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><item><title>Kotlin 2.5 Value Classes Save No Memory, and Destructuring Changes Meaning</title><link>http://hitrevor.com/en/posts/kotlin25rc/</link><pubDate>Fri, 13 Nov 2026 08:20:14 +0800</pubDate><guid>http://hitrevor.com/en/posts/kotlin25rc/</guid><description>Kotlin 2.5.0-RC pushes full value classes, so I read bytecode: javap shows a plain class, no box-impl. In a list it allocates 36.0 B, the same as the data class. The real win is a class file 21% smaller, and default destructuring still silently swaps names.</description><content:encoded><![CDATA[<p>On November 11, 2026, Kotlin 2.5.0-RC landed, and the headline feature is full value classes. A value class with several fields, which sounds exactly like a zero-cost abstraction.</p>
<p>Last month, when Beta2 came out, I wrote up a changelog round covering release formats and a few changed defaults. This time I wanted to measure something I skipped last time: does it actually save memory?</p>
<p>After installing the compiler, the first thing I did was not write a demo from the docs. It was run javap and read what the compiler emitted.</p>
<p>The conclusion first: in the bytecode it is an ordinary class. Not a trace of inline anywhere.</p>
<h2 id="first-confirm-what-the-compiler-accepts">First, confirm what the compiler accepts</h2>
<p>Download kotlin-compiler-2.5.0-RC.zip, unzip it, and the version is kotlinc-jvm 2.5.0-RC, running on JRE 21.0.10.</p>
<p>The first step hit a wall. Write a <code>value class VComplex(val real: Double, val imaginary: Double)</code> and the compiler refuses:</p>
<p>the feature &ldquo;full value classes&rdquo; is experimental and should be enabled explicitly.</p>
<p>You need <code>-Xfull-value-classes</code> before it accepts anything. The docs say the feature is Experimental and must be enabled explicitly. All true, it just does not tell you what you will see once it is enabled.</p>
<p><img alt="2.5.0-RC features and flags" loading="lazy" src="/images/kotlin25rc/fig1.png"></p>
<h2 id="bytecode-is-more-honest-than-the-docs">Bytecode is more honest than the docs</h2>
<p>I wrote two controls. One <code>data class DComplex(real, imaginary)</code>, one <code>value class VComplex(real, imaginary)</code>. Same fields, same types.</p>
<p><code>javap -p</code> on VComplex: a private final double real, a private final double imaginary, a two-argument constructor, two getters, one toString.</p>
<p>That is all. No <code>box-impl</code>, no <code>unbox-impl</code>, and no packing of the two doubles into some other representation.</p>
<p>Now the single-field <code>@JvmInline value class VUserId(val raw: Long)</code>, which looks completely different: <code>constructor-impl(long)</code>, <code>box-impl(long)</code>, <code>unbox-impl()</code>, <code>hashCode-impl(long)</code>, <code>equals-impl(long, Object)</code>, and every one of those methods takes a primitive long.</p>
<p>That is what inline means. The multi-field one is not it.</p>
<p><img alt="Bytecode comparison" loading="lazy" src="/images/kotlin25rc/fig2.png"></p>
<h2 id="so-does-it-save-anything">So does it save anything?</h2>
<p>I measured allocation with <code>ThreadMXBean.getCurrentThreadAllocatedBytes</code>, 1,000,000 runs, looking at average bytes per run.</p>
<p>First round, reading fields off local variables: VComplex 9.3 B, DComplex 10.4 B. Both numbers are far below what an object should occupy, because JIT did scalar replacement and the object was never created at all.</p>
<p>That measurement does not hold up. So I moved them into an ArrayList, forcing the objects to exist.</p>
<p>VComplex into a List: 36.0 B per run. DComplex into a List: 36.0 B per run.</p>
<p>Identical. Zero bytes apart.</p>
<p>The 36 is easy enough to explain: a 16-byte object header plus 16 bytes for the two doubles. A clean integer.</p>
<p>So on the JVM, a multi-field value class saves not a single byte. The only size difference from the data class is in the class file: VComplex.class is 1,760 bytes, DComplex.class is 2,216 bytes, 21% smaller, and what is missing is copy and componentN.</p>
<h2 id="not-even-jvminline-escapes">Not even @JvmInline escapes</h2>
<p>I measured the single-field control while I was at it. VUserId into a List: 28.0 B. DUserId as well: 28.0 B.</p>
<p>Again not a byte apart.</p>
<p>The reason is plain: once it goes into a generic container it has to be boxed, and once boxed it is an object, indistinguishable from an ordinary class.</p>
<p>So the claim that an inline value class is a zero-cost abstraction holds only at the layer of passing arguments directly. Put it inside a <code>List</code>, a <code>Map</code>, an <code>Array&lt;Any&gt;</code>, and the cost comes straight back.</p>
<p>This is not a 2.5 problem, it is the old rule of JVM generic erasure. It is just that a lot of people reach for value classes as a memory-saving tool.</p>
<p><img alt="Allocated bytes comparison" loading="lazy" src="/images/kotlin25rc/fig3.png"></p>
<h2 id="what-it-saves-is-semantics">What it saves is semantics</h2>
<p>So what is a multi-field value class for?</p>
<p>It is for the idea that two instances with identical contents should not differ by identity. A data class <code>==</code> compares contents, but two instances are still two objects, each with its own reference, its own lock, its own place on the heap.</p>
<p>A value class removes that identity. You cannot rely on reference equality, which means you cannot use it as a lock and cannot use object identity as a cache key.</p>
<p>The price is no <code>copy()</code> and no <code>componentN()</code>. The docs mention copy vars coming later. Not yet.</p>
<p>I did measure deduplication and equals: <code>setOf(a, b).size</code> is 1 both ways, and <code>toString</code> prints <code>VComplex(real=1.0, imaginary=2.0)</code>, the same as the data class.</p>
<p>So what it gives you is a constraint, not performance.</p>
<h2 id="where-it-really-differs-from-a-data-class">Where it really differs from a data class</h2>
<p>I measured the equals edge cases too, because that is where surprises like to hide.</p>
<p>NaN against NaN: true for VComplex, true for DComplex. Negative zero against positive zero: false on both sides. Same behaviour as a data class, all of it following Double.compare semantics. No extra surprise, no extra trap.</p>
<p>Inheritance does have something new. A <code>value class</code> can implement a sealed interface, and a <code>value object</code> can join in:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">sealed</span> <span class="k">interface</span> <span class="nc">Resp</span>
</span></span><span class="line"><span class="cl"><span class="k">value</span> <span class="k">class</span> <span class="nc">Found</span><span class="p">(</span><span class="k">val</span> <span class="py">id</span><span class="p">:</span> <span class="n">Int</span><span class="p">)</span> <span class="p">:</span> <span class="n">Resp</span>
</span></span><span class="line"><span class="cl"><span class="k">value</span> <span class="k">object</span> <span class="nc">Unknown</span> <span class="p">:</span> <span class="n">Resp</span>
</span></span></code></pre></div><p><code>Unknown::class.java.name</code> prints Unknown, and the compiler resolves <code>Found(1) is Resp</code> to true outright, with a warning that the check is always true. Because a value class&rsquo;s inheritance is fixed at compile time, there is no second possibility at runtime.</p>
<p>So the substantive differences from a data class are three: no <code>copy()</code>, no <code>componentN()</code>, no object identity.</p>
<p>The first two are subtractions. The third is the reason it exists.</p>
<h2 id="destructuring-has-a-silent-trap">Destructuring has a silent trap</h2>
<p>The RC made only-syntax the default mode for name-based destructuring. I wrote about the trap last time, so here is harder evidence.</p>
<p><code>data class User(val username: String, val email: String)</code>, an instance of <code>User(&quot;alice&quot;, &quot;alice@example.com&quot;)</code>, and then one line: <code>val (email, username) = user</code>.</p>
<p>The variable names and the property names are swapped. This is extremely common in production code. You type by position, not by name.</p>
<p>Default mode prints: email is alice, username is <a href="mailto:alice@example.com">alice@example.com</a>. Completely transposed.</p>
<p>Add <code>-Xname-based-destructuring=complete</code>, recompile the same source, not one character changed, and it prints: email is <a href="mailto:alice@example.com">alice@example.com</a>, username is alice.</p>
<p>Both modes compile. Neither reports an error. The runtime results are exact opposites.</p>
<p>There is a name-mismatch mode in between that does not change behaviour, only warns, and its exact wording is variable name &rsquo;email&rsquo; differs from accessed property name &lsquo;username&rsquo;, this code will change its meaning.</p>
<p>It does not say this is a bug. It says the meaning of this code will change. If you want to clear the mines early, this is the only mode you can use.</p>
<p><img alt="Same code, two results" loading="lazy" src="/images/kotlin25rc/fig4.png"></p>
<h2 id="the-migration-flags-are-still-missing-in-rc">The migration flags are still missing in RC</h2>
<p>The docs, in the migration section, say: starting with 2.5.20, unmarked single-field value classes produce a warning, 2.6 or 2.7 promotes that to an error, and until then you can use <code>-Xinline-value-class-migration=warning</code> and <code>strict</code> to prepare.</p>
<p>I tried exactly that. The compiler replied: flag is not supported by this version of the compiler. Neither one is accepted.</p>
<p>All three destructuring modes work, not one of the two migration flags exists. The docs are not wrong, they describe a state that is coming, but written on the RC page they read as available now.</p>
<p><img alt="What the docs say vs what RC does" loading="lazy" src="/images/kotlin25rc/fig5.png"></p>
<h2 id="verify-it-yourself-in-two-minutes">Verify it yourself in two minutes</h2>
<p>Do not take my word for it. The commands are below.</p>
<p>Download and unzip the compiler, write a minimal comparison file, compile with the flag, then open both classes with javap. Look for the <code>-impl</code> suffix and you know at a glance whether it is inline.</p>
<p>The allocation measurement is simple too, <code>ThreadMXBean</code> in two lines:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="k">val</span> <span class="py">mx</span> <span class="p">=</span> <span class="nc">ManagementFactory</span><span class="p">.</span><span class="n">getThreadMXBean</span><span class="p">()</span> <span class="k">as</span> <span class="n">com</span><span class="p">.</span><span class="n">sun</span><span class="p">.</span><span class="n">management</span><span class="p">.</span><span class="n">ThreadMXBean</span>
</span></span><span class="line"><span class="cl"><span class="k">val</span> <span class="py">before</span> <span class="p">=</span> <span class="n">mx</span><span class="p">.</span><span class="n">getCurrentThreadAllocatedBytes</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="c1">// 这里跑你的循环
</span></span></span><span class="line"><span class="cl"><span class="k">val</span> <span class="py">after</span> <span class="p">=</span> <span class="n">mx</span><span class="p">.</span><span class="n">getCurrentThreadAllocatedBytes</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="n">println</span><span class="p">((</span><span class="n">after</span> <span class="p">-</span> <span class="n">before</span><span class="p">)</span> <span class="p">/</span> <span class="n">循环次数</span><span class="p">)</span>
</span></span></code></pre></div><p>One thing you have to watch: the local-variable scenario measures nothing. JIT eliminates the object entirely and both numbers come out at a dozen bytes or so. Put it in a collection and measure that, because that is what it looks like when the object is really created.</p>
<p>I fell into this on my first attempt and nearly wrote down the wrong conclusion, that both sides save memory.</p>
<h2 id="what-you-can-do-before-upgrading">What you can do before upgrading</h2>
<p>Three things.</p>
<p>First, hang <code>-Xname-based-destructuring=name-mismatch</code> on your build and run it once. Zero behaviour change, pure checking, and the warnings it produces are the line numbers whose meaning will change later.</p>
<p>Second, if you are using multi-field value classes, work out whether you are after memory or after semantics. If it is memory, the measurements say you will not get it.</p>
<p>Third, do not put value classes on your list of performance optimizations. True zero cost only holds for single-field direct argument passing, and you have to write <code>@JvmInline</code> explicitly, because from 2.5 on the words <code>value class</code> no longer automatically mean inline.</p>
<p>As for full value classes themselves: experimental, gone the moment the flag is off, and production use will have to wait.</p>
<p>The one thing I most want to say after all this measuring: a type with value in its name is still an object on the JVM. If you want to save memory, you still have to look at where your data structures actually live.</p>
]]></content:encoded></item></channel></rss>