Performance

Inline functions, value classes, boxing on the JVM, sequences vs collections, tailrec, and allocation-aware patterns.

inline value class Boxing Sequences tailrec const Allocation
๐Ÿ”ฅ

Inline Functions, Revisited

Functions & Lambdas introduces inline for the non-local return it unlocks; the performance angle is the other half of the story. Every lambda is normally compiled to an object implementing a function interface, and invoking it is a virtual call through that object. Inlining erases both costs by pasting the function and lambda bodies directly at the call site โ€” at the cost of larger compiled bytecode, since the body is now duplicated everywhere it's called.

What actually changes at the bytecode level inline
inline fun <T> measureAndLog(label: String, block: () -> T): T {
    val start = System.nanoTime()
    val result = block()
    println("$label took ${System.nanoTime() - start}ns")
    return result
}

val total = measureAndLog("sum") {
    (1..1_000_000).sum()
}

// Roughly equivalent to what the compiler generates in place of the call โ€”
// no Function0 object for the lambda, no invoke() call through it:
// val start = System.nanoTime()
// val result = (1..1_000_000).sum()
// println("sum took ${System.nanoTime() - start}ns")
๐Ÿ’ก
Inlining pays off for small, hot, frequently-called functions โ€” exactly the profile of the standard library's own scope functions and collection operators. It's a net loss for large function bodies called from many places: you trade one shared copy of the bytecode for many duplicated ones, growing the compiled artifact with no runtime benefit proportional to the cost.
๐Ÿ’Ž

Value Classes

Wrapping a primitive in a class for type safety โ€” a UserId instead of a bare Int, so you can't accidentally pass a product ID where a user ID belongs โ€” normally costs an allocation per wrapper instance. A value class (annotated @JvmInline) removes that cost in most cases: the compiler tries to represent it as the underlying type directly at compile time, unwrapping it away entirely, while still giving you full type-checking against mixing it up with the raw type or another value class.

Type safety without the wrapper allocation @JvmInline value class
@JvmInline
value class UserId(val value: Int)

@JvmInline
value class ProductId(val value: Int)

fun fetchUser(id: UserId): User = /* ... */ User(id.value, "Ada")

val userId = UserId(42)
fetchUser(userId)               // fine
// fetchUser(ProductId(42))   // ERROR: not a UserId, even though both wrap Int
// fetchUser(42)              // ERROR: raw Int isn't a UserId either

// At runtime, in most contexts, this compiles down to passing a
// plain Int โ€” no UserId object is actually allocated on the heap
โš ๏ธ
The "zero-cost" behavior has real exceptions: a value class used as a generic type argument, stored in a nullable field, or referenced through an interface it implements typically does get boxed, because the JVM's generics and interfaces require a real object reference at those points. Value classes are a strong default for wrapper types used directly, not an unconditional guarantee for every usage shape.
๐Ÿ“ฆ

Boxing on the JVM

Kotlin's Int compiles to the JVM's primitive int wherever possible โ€” no object, no heap allocation, just a raw 32-bit value on the stack or in a field. The JVM's generics, though, are implemented entirely with object references (a holdover from before generics existed at all), so any time an Int needs to fill a generic type parameter โ€” going into a List<Int>, for instance โ€” it gets boxed into a heap-allocated Integer wrapper object first.

Where boxing happens Boxing
// Unboxed: compiles to a primitive int, no allocation
val x: Int = 42
fun square(n: Int): Int = n * n

// Boxed: List is generic, so every element is an Integer object
val boxed: List<Int> = listOf(1, 2, 3)

// Unboxed: IntArray is backed by a real int[], not Integer[]
val unboxed: IntArray = intArrayOf(1, 2, 3)

// A nullable Int must be boxed too: there's no "null" representation
// for a primitive int, so Int? always uses the boxed Integer
val nullable: Int? = 5  // boxed, even though 5 itself isn't null
๐Ÿ’ก
For large collections of numeric primitives on a genuine hot path โ€” game loops, numeric processing, tight simulation code โ€” prefer IntArray/DoubleArray/LongArray over List<Int>/List<Double>/List<Long> to avoid boxing every element individually. For everyday application code processing a few thousand elements, the difference is rarely worth the API ergonomics you give up โ€” measure before reaching for this.
๐ŸŒŠ

Sequences vs Collections for Large Chains

Chaining operators on a List โ€” .filter { }.map { }.take(3) โ€” evaluates eagerly: each operator fully processes the list and allocates a brand-new intermediate list before the next operator even starts. A Sequence evaluates lazily, element by element, through the whole chain at once, with no intermediate list materialized between steps. The full mechanics live in the Sequences guide; the performance summary is short.

Eager vs lazy over a long chain asSequence()
val numbers = (1..1_000_000).toList()

// Eager: filter() allocates a full intermediate list of ~500,000 items,
// THEN map() processes and allocates another list from all of those,
// even though only 3 results were ever needed
val eager = numbers
    .filter { it % 2 == 0 }
    .map { it * it }
    .take(3)

// Lazy: each number flows through filter โ†’ map โ†’ take as a pipeline,
// one element at a time, stopping as soon as 3 results are collected โ€”
// no full intermediate list ever materializes
val lazy = numbers.asSequence()
    .filter { it % 2 == 0 }
    .map { it * it }
    .take(3)
    .toList()  // terminal operation: only now does evaluation actually run
โš ๏ธ
Sequences aren't a free upgrade. For a short chain over a small collection, the per-element overhead of the sequence's lazy iterator machinery often loses to the JIT-friendly, straightforward eager list operations. Reach for asSequence() specifically for long operator chains over large collections, or where an early terminal operation like first()/take(n) means most of the data never needs processing at all.
๐Ÿ”

tailrec

The JVM doesn't optimize tail calls on its own, so a naively recursive function risks a StackOverflowError on deep recursion โ€” a real concern in Kotlin the way it usually isn't in languages with runtime tail-call support. Marking a function tailrec asks the compiler to rewrite a qualifying recursive call into a loop at compile time, eliminating the stack growth entirely. The qualification is strict: the recursive call must be the very last operation, with nothing left to do with its result afterward.

Compiled to a loop, not real recursion tailrec
// The recursive call is the LAST thing that happens โ€” nothing
// wraps around it afterward, which is what makes it tail-callable
tailrec fun factorial(n: Long, accumulator: Long = 1): Long =
    if (n <= 1) accumulator else factorial(n - 1, n * accumulator)

println(factorial(100_000))  // no StackOverflowError: compiled to an ordinary loop

// NOT tail-recursive: the multiplication happens AFTER the recursive
// call returns, so there's still work pending on the stack frame
fun factorialNaive(n: Long): Long =
    if (n <= 1) 1 else n * factorialNaive(n - 1)  // tailrec here would be a no-op warning
// factorialNaive(100_000) risks StackOverflowError: real recursion, real stack growth
โ„น๏ธ
If you mark a function tailrec and its recursive call isn't actually in tail position, the compiler emits a warning and quietly falls back to ordinary (non-optimized) recursion rather than failing to compile โ€” always check for that warning after adding the modifier, since a silently-ignored one means you didn't get the optimization you thought you did.
๐Ÿ”’

const

const val declares a value the compiler can fully resolve at compile time โ€” a string or primitive literal, or a simple expression of them โ€” and inlines directly into every call site that reads it, the same way a C preprocessor macro or a Go untyped constant would. This is a stronger guarantee than a regular top-level val, which is still just a runtime-initialized property read through a getter, even when its value never actually changes.

const val vs val const
// const val: resolved at compile time, inlined at every usage โ€”
// must be a top-level or companion object property, and can only
// hold a String or primitive type, never a computed/runtime value
const val MAX_RETRIES = 3
const val API_VERSION = "v2"

// Regular val: still a runtime property with a getter behind it,
// even though this particular value also never changes
val maxRetriesComputed = 1 + 2  // legal as val; NOT legal as const (needs a true compile-time constant)

// val requiring runtime evaluation can never be const, at all
val startupTime = System.currentTimeMillis()
โš ๏ธ
Because a const val's value is inlined at compile time into every module that reads it, changing its value in a library and shipping a new version doesn't take effect for consumers until they recompile against the new version โ€” a stale, previously-compiled call site still has the old value baked in. This is the same binary-compatibility caveat Java's static final constants have always carried.
โ™ป๏ธ

Allocation-Aware Patterns

Most everyday Kotlin code shouldn't be written around allocation concerns at all โ€” clarity wins by default, and the JVM's generational garbage collector is very good at cheaply reclaiming short-lived objects. The patterns below are worth knowing specifically for code that profiling has already flagged as allocation-heavy, not as a blanket style to apply everywhere.

buildString over repeated string concatenation buildString
// Each += on a String allocates a brand-new String; in a loop,
// that's one throwaway allocation per iteration
var result = ""
for (i in 1..1000) {
    result += "$i, "   // allocates a new String every single time
}

// buildString wraps a single, reused StringBuilder โ€”
// one final allocation instead of one per append
val better = buildString {
    for (i in 1..1000) {
        append(i).append(", ")
    }
}
Pre-sizing collections you can size upfront ArrayList(capacity)
// mutableListOf() with unknown final size may trigger several
// internal resize-and-copy operations as it grows
val results = mutableListOf<Int>()
for (i in 1..10_000) results.add(i * i)

// Pre-sizing when the final count is known avoids those internal
// resizes entirely
val presized = ArrayList<Int>(10_000)
for (i in 1..10_000) presized.add(i * i)
๐Ÿ’ก
Profile before optimizing. Every pattern on this page trades some readability or flexibility for a performance property that only matters once you've measured a real bottleneck โ€” applying them preemptively across a codebase, without evidence they're needed, usually just makes the code harder to read for no measurable gain.
๐Ÿ“‹

Quick Reference

Concept Syntax Notes
Inline functioninline fun f(block: () -> Unit)No lambda object, no virtual call; larger bytecode
Zero-cost wrapper@JvmInline value classBoxed when used as a generic arg or through an interface
Unboxed primitive arrayIntArray / DoubleArray / LongArrayBacked by a real primitive array, avoids per-element boxing
Boxed generic collectionList<Int>Every element is a boxed Integer object
Lazy chained operationslist.asSequence().filter{}.map{}No intermediate list per step; pays off on long chains
Tail-call optimizationtailrec fun f(...)Recursive call must be the very last operation
Compile-time constantconst val X = 3Inlined at every call site; top-level/companion only
Efficient string buildingbuildString { append(...) }One StringBuilder instead of repeated += allocations
Pre-sized collectionArrayList<T>(knownSize)Avoids internal resize-and-copy as it grows