Skip to content

Do Kotlin Lambdas Cause Performance Overhead?

Every lambda you pass to a higher-order function in Kotlin creates an object allocation. This simple fact caught me off guard when I first started digging into Kotlin’s performance characteristics.

Let me show you what I mean. When you write code like this:

[LambdaExample.kt]
fun processItems(items: List<Int>, predicate: (Int) -> Boolean) {
items.forEach { item ->
if (predicate(item)) println(item)
}
}
processItems(listOf(1, 2, 3)) { it > 1 }

You’re not just passing a function reference. The compiler generates an entire anonymous class implementing Function1<Integer, Boolean>, and every call to processItems creates a new instance of that class.

So yes, Kotlin lambdas do cause performance overhead through object allocation and virtual dispatch. But here’s the good news: the inline keyword eliminates this cost entirely.

In this post, I’ll show you exactly how lambdas compile to bytecode, when the overhead matters, and how inline functions solve the problem.

How Lambdas Compile Under the Hood

When you pass a lambda to a function, the Kotlin compiler doesn’t just pass a function pointer. It generates an anonymous class that implements one of the FunctionN interfaces from the Kotlin standard library.

Lambda to Function Object Transformation

The lambda { it > 1 } compiles to something like this in Java:

[Decompiled
// Function1 interface generated for lambda
public final class MainActivity$onCreate$1 implements Function1<Integer, Boolean> {
@Override
public Boolean invoke(Integer item) {
return item > 1;
}
}
// Every call creates a new instance
processItems(items, new MainActivity$onCreate$1());

This happens because lambda parameters compile to FunctionN interfaces (Function0, Function1, Function2, etc.) based on how many parameters they accept. Each lambda invocation creates an anonymous class instance, and calling the lambda requires invoking the invoke() method through virtual dispatch.

Capturing Variables Increases Cost

What makes this more interesting is how capturing external variables affects performance. When I looked at the decompiled bytecode, I found that:

  • Lambdas capturing external variables create new instances every time they’re used
  • Non-capturing lambdas can use singleton instances since they don’t depend on external state
[CapturingVsNonCapturing.kt]
// Capturing lambda - new instance each time
fun processItems(items: List<Int>, threshold: Int) {
items.forEach { item -> // Captures 'threshold'
if (item > threshold) println(item)
}
}
// Non-capturing lambda - singleton possible
fun processItems(items: List<Int>) {
items.forEach { item -> // Captures nothing
if (item > 5) println(item)
}
}

The capturing version is more expensive because each lambda instance needs to hold references to the captured variables, increasing memory overhead and allocation frequency.

The Performance Impact

The overhead comes from three sources:

  1. Object allocation on the heap - Each lambda creates a Function object (typically 24-32 bytes)
  2. GC pressure - Short-lived objects increase garbage collection frequency
  3. Virtual dispatch - The invoke() call requires method lookup instead of direct execution

When This Matters

I’ve found that lambda overhead matters in these scenarios:

  • Tight loops calling higher-order functions repeatedly
  • Hot path operations like animation frames or event handlers
  • Collection transformations on large datasets
  • Reactive streams using Flow or RxJava chains

When it doesn’t matter:

  • One-time setup code running at initialization
  • UI event handlers called infrequently by user interaction
  • Business logic outside performance-critical paths

Performance Impact by Scenario

ScenarioAllocations/secGC PressureRecommendation
Single call1NegligibleStandard function
Loop (1000x)1,000LowConsider inline
Animation frame (60fps)60MediumUse inline
Collection filter (10k items)10,000HighUse inline

The key insight is that the overhead scales with frequency. A lambda called once is negligible. A lambda called 10,000 times in a tight loop becomes a performance bottleneck.

Inline Functions: The Optimization

The inline keyword tells the Kotlin compiler to copy the function body to every call site instead of generating a function call. This means the lambda body is also inlined, eliminating both the Function object allocation and the virtual dispatch overhead.

How Inline Works

Consider this inline function:

[InlineExample.kt]
inline fun <T> measureTime(block: () -> T): T {
val start = System.nanoTime()
val result = block()
val end = System.nanoTime()
println("Time: ${end - start}ns")
return result
}
measureTime {
performExpensiveOperation()
}

After compilation (decompiled to Java), this becomes:

[Decompiled
long start = System.nanoTime();
performExpensiveOperation(); // Direct call, no lambda
long end = System.nanoTime();
System.out.println("Time: " + (end - start) + "ns");

The compiler inlined both the measureTime function body and the lambda passed to it. No Function object was created, and no virtual dispatch occurred.

Bytecode Comparison

You can see this yourself using IntelliJ’s “Show Kotlin Bytecode” feature:

Non-inline version:

  • INVOKEINTERFACE kotlin/jvm/functions/Function1.invoke
  • NEW instruction for anonymous class
  • Method invocation overhead

Inline version:

  • Direct bytecode instructions at call site
  • No NEW instructions
  • No interface invocations

Standard Library Functions Are Inline

This is why Kotlin’s standard library feels “free” to use. Functions like let, run, apply, also, with, and takeIf are all inline. Collection operations like map, filter, forEach, and flatMap are also inline.

[StandardLibraryInline.kt]
// This allocates NOTHING
user?.let {
println(it.name)
it.age > 18
}
// This allocates NOTHING (loop is unrolled)
listOf(1, 2, 3)
.map { it * 2 }
.filter { it > 2 }
.forEach { println(it) }

When I discovered this, it changed how I thought about writing Kotlin code. The standard library designers already optimized for the common case, so you can use these functions without worrying about performance in most situations.

When to Use Inline

Guidelines

Use inline for:

  • Functions with lambda parameters (higher-order functions)
  • Small utility functions (< 50 lines)
  • Frequently called functions (utilities, helpers)
  • Performance-critical code paths

Don’t use inline for:

  • Large functions (> 50-100 lines)
  • Functions with complex control flow
  • Rarely called functions (initialization, error handlers)
  • Recursive functions (won’t work anyway)

Trade-offs

Benefits:

  • Zero allocation overhead
  • No function call overhead
  • Enables non-local returns (exiting the enclosing function from within a lambda)

Costs:

  • Increased bytecode size (code bloat)
  • Longer compilation times
  • Larger APK/AAR size if overused
  • Can’t access private members of inline classes

Advanced Modifiers

Kotlin provides additional modifiers for fine-grained control:

[AdvancedInline.kt]
// noinline: Prevent specific lambda from being inlined
inline fun processData(
data: List<Int>,
noinline expensiveTransform: (Int) -> Int, // Won't be inlined
inline simpleTransform: (Int) -> Int // Will be inlined
): List<Int> {
return data.map(expensiveTransform).map(simpleTransform)
}
// crossinline: Lambda cannot use non-local returns
inline fun repeatUntilSuccess(crossinline operation: () -> Boolean) {
while (true) {
if (operation()) break // 'break' inside lambda not allowed
}
}
// reified: Type information available at runtime (only works with inline)
inline fun <reified T> findOfType(list: List<Any>): T? {
return list.filterIsInstance<T>().firstOrNull()
}

I use noinline when a lambda is too large to inline or when I want to control code bloat. The crossinline modifier prevents non-local returns, which is useful when the lambda is invoked in a different context. The reified modifier is essential when you need access to type parameters at runtime.

Practical Examples

Standard Library Already Optimized

Here’s something I discovered: the standard library functions you use every day are already inline.

[OptimizedExample.kt]
// GOOD: Zero allocations (filter and map are inline)
fun processLargeDataset(data: List<Int>): List<Int> {
return data.filter { item ->
expensiveCheck(item)
}.map { item ->
transform(item)
}
}

No changes needed. The Kotlin team already optimized these functions for you.

When to Write Your Own Inline

I write custom inline functions when I have utility functions that accept lambdas and are called frequently:

[CustomInline.kt]
// Custom retry logic - good inline candidate
inline fun <T> retryOnFailure(
times: Int = 3,
operation: () -> T
): T? {
repeat(times) {
try {
return operation()
} catch (e: Exception) {
if (it == times - 1) throw e
}
}
return null
}
// Usage - zero overhead
val result = retryOnFailure(3) {
apiCall()
}

This eliminates the lambda allocation overhead on every retry attempt.

Measuring Impact

I’ve found these tools useful for measuring the actual performance impact:

Tools:

  • Android Profiler - Allocation tracking in real-time
  • IntelliJ bytecode viewer - See what the compiler generates
  • kotlinx-benchmark - Microbenchmarking for JVM
  • JMH - Java Microbenchmark Harness

Quick test:

[PerformanceTest.kt]
// Run allocation profiler on this
repeat(100_000) {
listOf(1, 2, 3).map { it * 2 } // Should show 0 allocations
}
// Compare with non-inline version
fun nonInlineMap(list: List<Int>, transform: (Int) -> Int): List<Int> {
return list.map(transform)
}
repeat(100_000) {
nonInlineMap(listOf(1, 2, 3)) { it * 2 } // 100,000 allocations
}

When I ran this comparison, the inline version showed zero allocations, while the non-inline version allocated 100,000 Function objects. The performance difference was significant in tight loops.

Conclusion

Yes, Kotlin lambdas cause overhead through object allocation and virtual dispatch. But the Kotlin standard library uses inline to eliminate this cost in the most common scenarios.

Key takeaways:

  • Understand the compilation model: lambdas become Function objects
  • Use inline for small functions with lambda parameters
  • Trust the standard library (it’s already optimized)
  • Measure performance impact before optimizing

Write your own higher-order functions as inline by default, but profile your code to identify actual bottlenecks. Premature optimization is still the root of all evil, but understanding the cost model helps you make informed decisions.

When you’re working on hot paths or performance-critical code, the inline modifier is a powerful tool that eliminates lambda overhead entirely. Use it wisely, and your Kotlin code will be both expressive and efficient.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments