Inline Functions, Revisited
no lambda allocation ยท no virtual callFunctions & 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.
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")
Value Classes
@JvmInline value class ยท zero-cost wrapperWrapping 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.
@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
Boxing on the JVM
Int vs Integer ยท generics force boxingKotlin'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.
// Unboxed: compiles to a primitive int, no allocation val x: Int = 42 fun square(n: Int): Int = n * n // Boxed: Listis 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
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
lazy ยท one pass ยท intermediate allocationChaining 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.
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
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
tail-call optimization ยท no stack growthThe 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.
// 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
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
compile-time constant ยท inlined at use siteconst 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: 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()
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
avoid needless intermediates ยท reuseMost 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.
// 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(", ") } }
// 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)
Quick Reference
Syntax cheat-sheet| Concept | Syntax | Notes |
|---|---|---|
| Inline function | inline fun f(block: () -> Unit) | No lambda object, no virtual call; larger bytecode |
| Zero-cost wrapper | @JvmInline value class | Boxed when used as a generic arg or through an interface |
| Unboxed primitive array | IntArray / DoubleArray / LongArray | Backed by a real primitive array, avoids per-element boxing |
| Boxed generic collection | List<Int> | Every element is a boxed Integer object |
| Lazy chained operations | list.asSequence().filter{}.map{} | No intermediate list per step; pays off on long chains |
| Tail-call optimization | tailrec fun f(...) | Recursive call must be the very last operation |
| Compile-time constant | const val X = 3 | Inlined at every call site; top-level/companion only |
| Efficient string building | buildString { append(...) } | One StringBuilder instead of repeated += allocations |
| Pre-sized collection | ArrayList<T>(knownSize) | Avoids internal resize-and-copy as it grows |