07 · Performance at Scale¶
Kotlin compiles to JVM bytecode, so most performance work is really JVM
performance work — but Kotlin's own abstractions (boxed collections,
Sequence vs. eager collection operators, data class allocations) add a
layer of decisions that matter once code runs millions of times instead
of a few. This module measures the difference with real benchmarks
rather than folklore.
Primitive loops vs. boxed collections¶
fun sumLoop(n: Int): Long {
var total = 0L
for (i in 0 until n) total += i.toLong()
return total
}
fun sumBoxed(n: Int): Long {
val list = ArrayList<Long>()
for (i in 0 until n) list.add(i.toLong())
var total = 0L
for (x in list) total += x
return total
}
Timed with a warmup (to let the JIT compile hot paths before measuring — the first few calls to any JVM method run interpreted and are much slower):
$ kotlinc Bench.kt -include-runtime -d bench.jar
$ java -jar bench.jar
primitive result=12499997500000 time=1.78ms
boxed-list result=12499997500000 time=132.38ms
Same result, 74x slower for the boxed version on this run. The
ArrayList<Long> path pays twice: once to box every Long into a heap
object (Kotlin's Long maps to Java's primitive long only when it can
prove one is never nullable and never stored in a generic container —
ArrayList<Long> forces boxing), and again for the list's own array
growth and indirection. The primitive loop stays entirely on the stack
as raw long values the whole time.
Sequence vs. eager collection chains¶
val list = (1..1_000_000).toList()
var listOps = 0
val listResult = list
.map { listOps++; it * 2 }
.filter { listOps++; it % 3 == 0 }
.first()
var seqOps = 0
val seqResult = list.asSequence()
.map { seqOps++; it * 2 }
.filter { seqOps++; it % 3 == 0 }
.first()
$ kotlinc Seq.kt -include-runtime -d seq.jar && java -jar seq.jar
list: result=6 ops=2000000
sequence: result=6 ops=6
.map().filter().first() on a List runs map over all 1,000,000
elements, builds a full intermediate list, then runs filter over all
1,000,000 of those, then takes the first match — 2,000,000 total
operations for a chain that only needed to look at one element.
asSequence() makes every step lazy and per-element: first() pulls one
element through map then filter, finds it matches, and stops — 6
total operations (3 elements × 2 steps) to find the same answer. The
crossover point matters: for small collections or when every element is
consumed anyway (no early exit like first/take), Sequence's
per-element overhead (an object wrapping each step) can make it slower
than a plain List chain. It's an optimization for large collections
with early termination, not a blanket replacement for .map().
Data classes and allocation pressure¶
Every data class instance is a heap allocation. Code creating millions
of short-lived data class instances per second — say, wrapping every row
of a large result set in a data class Row(...) just to pass it one
function call deeper — puts real pressure on the garbage collector. Two
mitigations that matter in practice:
value class(formerlyinline class) wraps a single value with zero runtime allocation in most cases — the wrapper is erased at compile time and the underlying primitive/reference is used directly, as long as it isn't boxed for a generic type or nullable use.value class UserId(val raw: Long)gives type safety (aUserIdcan't be passed where a rawLongfor, say, an order ID is expected) without the allocation cost of a full class.- Reusing objects in a hot loop instead of allocating fresh ones — common in interpreters, parsers, and game loops — trades code clarity for fewer GC pauses; only worth it once profiling shows allocation is the actual bottleneck.
Profiling before optimizing¶
Guessing which function is slow and rewriting it is a common way to make
code uglier without making it faster. async-profiler (flame graphs) or
even the simpler measureNanoTime/measureTimeMillis from
kotlin.system around suspected hot paths turn "I think this is slow"
into "this function is 80% of wall-clock time" before any code changes.
JMH (Java Microbenchmark Harness) is the standard tool for
production-grade microbenchmarks — it handles JIT warmup, dead-code
elimination by the optimizer, and statistical noise automatically, which
hand-rolled measureNanoTime loops like this module's do not.
Traps¶
- Micro-benchmarking without warmup measures the JIT compiler, not your code. The first calls to any function run through the interpreter or C1 (client) compiler before HotSpot's C2 promotes hot methods to fully optimized machine code — a benchmark that times a single cold call can be 10-100x pessimistic compared to steady-state performance.
it % 3 == 0style filters can hide autoboxing when the receiver type isInt?/generic rather than primitiveInt— nullable numeric types are always boxed, so aList<Int?>pays the boxing cost ofArrayList<Long>above even though it "looks like" plainInts.- String concatenation in a loop (
str += x) allocates a newStringevery iteration becauseStringis immutable —StringBuilder(which Kotlin's compiler uses automatically for simple"$a$b"template concatenation, but not for+=inside a loop) avoids the quadratic-time trap of repeated full-string copies. Sequencewith aComparator-heavysortedBystill has to materialize the whole sequence to sort it — laziness only helps operations that can proceed element-by-element (map,filter,take,first);sortedBy,groupBy, andtoListall force full evaluation regardless of whether the source is aListorSequence.
How It Actually Works¶
value class UserId(val raw: Long) achieves "type safety with zero
allocation" through a compiler trick similar in spirit to inline
functions: at most call sites, the compiler erases the wrapper entirely
and substitutes the raw underlying value (Long) directly — a
function parameter typed UserId compiles, wherever possible, to a plain
long parameter, with the UserId type existing only during
compile-time type-checking to stop you from accidentally passing a raw
Long meant for something else (an OrderId, say) where a UserId is
expected. This is why it's called "inline" historically: like an inline
function's lambda, the wrapper class's instance mostly disappears from
the compiled code, leaving only the value it wrapped. The compiler does
have to fall back to actually boxing a value class into a real heap
object in specific situations it can't erase — storing it in a generic
collection (List<UserId>), returning it as UserId? (nullable value
classes need a real object to represent null distinctly from the wrapped
value), or passing it through a lambda/interface where erasure would change
the method signature in a way that breaks binary compatibility — so the
"zero allocation" guarantee is real but conditional, and worth checking with
javap when a value class's performance matters somewhere hot.
The JIT-warmup point in the benchmarks throughout this module has a
mechanical explanation too: the JVM starts every method running in the
interpreter, executing bytecode instruction-by-instruction, and only
promotes a method to compiled machine code (via C1, then the more
aggressively optimizing C2) once it's been invoked enough times to cross the
JIT's hotness thresholds — which is also when optimizations like inlining,
escape analysis (which can eliminate an allocation entirely if the JIT
proves an object never escapes a method, sometimes turning even a boxed
Long allocation into a stack-only value under the hood), and loop
unrolling kick in. Measuring a method's first few calls times the slow
interpreted path, not the steady-state compiled code production code
actually runs under — which is exactly why every credible microbenchmark,
including JMH, discards a warmup period before recording numbers.
Cheat sheet¶
| Concern | Approach |
|---|---|
| Avoid boxing in hot numeric loops | Primitive arrays/loops, not ArrayList<Long> |
| Large collection + early exit | asSequence() before .first()/.take() |
| Small collection, consumes everything | Plain List chain — Sequence overhead not worth it |
| Type-safe wrapper, zero allocation | value class |
| Measure before optimizing | measureNanoTime, or JMH for rigor |
| Repeated string building in a loop | StringBuilder, not += |
Exercise¶
Extend Bench.kt with a third variant, sumPrimitiveArray, that fills
an IntArray (not ArrayList<Long>) and sums it. Time all three with
the same warmup pattern and confirm the ranking is
primitive < primitiveArray < boxed-list; then explain in a comment why
IntArray avoids boxing that ArrayList<Int> cannot.