When does a `@JvmInline value class` get boxed at runtime instead of being inlined, and why does that matter?
answer
- Boxes: nullable, generic type arg, supertype/interface, reflection
- Unboxed in direct param/variable use
- box-impl / unbox-impl synthetic methods bridge the two forms
- Collections/Sequence of value class → boxing in hot paths
- Functions get mangled JVM names; @JvmName for Java
basics
~20 sNormally the value class is replaced by its single inner value, so no extra object is created. But when it must behave like a real object — for example when it is nullable, stored as a general type, or used through an interface — the compiler creates a wrapper object.
solid answer
~50 sA `@JvmInline value class` is represented at runtime by its underlying value (the single `val`) in the common case, so no wrapper is allocated. It gets **boxed** into a real object whenever the JVM needs an actual reference of the declared type rather than the raw underlying value: when the value class is used as a **nullable** (`Money?`), when it appears as a **type argument** in a generic (`List<Money>`, `Comparable<Money>`), when it is used as a **supertype** such as `Any`/an implemented **interface**, when assigned to a generic/`Any` variable, and across reflection. The underlying-type representation and the boxed representation are connected by compiler-generated `box-impl`/`unbox-impl` methods. It matters because the whole point — zero-cost type safety — quietly disappears in hot paths that, say, stuff value classes into collections; and because boxing affects equality identity and can surprise you in `==` vs reference scenarios. Also note name **mangling**: functions taking value-class params get mangled JVM names to avoid overload clashes, which affects Java interop.
code
kotlin · 10 lines@JvmInline value class Tag(val s: String)
fun take(t: Tag) {} // unboxed: param is a String
val t = Tag("x") // unboxed
take(t) // unboxed
val n: Tag? = t // BOXED (nullable)
val xs: List<Tag> = listOf(t) // BOXED (generic)
val a: Any = t // BOXED (supertype)
println(Tag("x") == Tag("x")) // true: structural equalitygo deeper
Knows boxing means a wrapper object is sometimes created but may not list the exact triggers.
Lists the main boxing triggers (nullable, generic, supertype) and the unboxed common case.
Explains box-impl/unbox-impl, performance implications in collections/hot paths, and structural equality under boxing.
Connects boxing to JVM name mangling, Java interop (@JvmName), API design, and when zero-cost claims break down at scale.
## What "inlining" means `@JvmInline value class Money(val cents: Long)` wraps **one** `val`. In the typical case the compiler **erases** the wrapper and uses the underlying `Long` directly at runtime — a `Money` variable is literally a `long`. No object is allocated. That is the *zero-cost* promise: you get a distinct compile-time type but pay nothing at runtime. ## When it boxes Boxing happens when the platform needs a genuine **object reference of the value-class type**, because the bare underlying value cannot carry that type identity. Concretely: - **Nullable use** — `val m: Money? = Money(5)`. A primitive `long` cannot be `null`, so a wrapper object is created to represent nullability. - **Generics / type arguments** — `List<Money>`, `Set<Money>`, `Comparable<Money>`, `Array<Money>` (note `Array` is special but generally boxes). JVM generics erase to `Object`, so elements are boxed. - **As a supertype** — assigning to `Any`, `Any?`, or any **interface** the value class implements. Using the value through that interface requires a real object. - **Reflection / cross some interop boundaries.** The compiler generates `box-impl` and `unbox-impl` (and a synthetic constructor) to convert between the inlined and boxed forms, inserting them automatically. ```kotlin @JvmInline value class Money(val cents: Long) fun pay(m: Money) {} // param is a raw long — no box val direct = Money(100) // unboxed pay(direct) // unboxed val nullable: Money? = direct // BOXES (nullable) val list = listOf(direct) // BOXES (generic List<Money>) val any: Any = direct // BOXES (supertype) ``` ## Why it matters 1. **Performance**: in tight loops you might think you have zero allocation, but pushing value classes into `List`/`Map`/`Sequence` or marking them nullable reintroduces allocation. Profile hot paths. 2. **Equality**: value classes get structural `equals`/`hashCode` from the underlying value, so `Money(1) == Money(1)` is `true` regardless of boxing — but mixing identity (`===`) reasoning with boxing is a trap. 3. **Java interop & mangling**: a function `fun pay(m: Money)` is compiled with a **mangled** JVM name (e.g. `pay-<hash>`) so overloads that differ only by value-class params don't clash and so Java can't call them in a type-unsafe way. Use `@JvmName` if you need a stable Java-callable name. ## Contrast with typealias A `typealias` never boxes or mangles anything — it is erased entirely and is the underlying type. The boxing discussion is unique to value classes precisely because they are *distinct* types that must sometimes materialize.
- Why are functions taking value-class parameters given mangled JVM names?Because the param is erased to its underlying type, two overloads could collide on the JVM; mangling makes their signatures unique and prevents unsafe Java calls. Use @JvmName for a stable Java-facing name.
- Does `Money(1) == Money(1)` depend on whether they are boxed?No. equals/hashCode are derived from the underlying value, so structural equality holds whether or not boxing occurred.
saying these in an interview costs you the question
- Claiming value classes never box
- Not knowing nullable or generic usage triggers boxing
- Confusing structural equals with reference identity under boxing
- Unaware of JVM name mangling and its Java-interop impact
- Assuming putting value classes in a List keeps them unboxed