Jan 26 2026, Monday

Kotlin Intrinsics on Android

The Kotlin programming language is fully interoperable with Java. It does this by effectively generating JVM bytecode. As far as the Android Runtime ( ART) is concerned it's all just byte code.

Note: ART does not execute JVM byte code. It executes dex instead which in turn is produced by D8 / R8 during the compilation step.

A (massively) simplified version of the compilation pipeline looks like the the following.

Kotlin (kt) -> Java byte-code (.class) -> D8/R8 -> dex

Java Interoperability

Let's say we are building an SDK in Kotlin which includes the following function as part of the public API.

fun printInput(input: String) {
    formatAndPrint(input)
}

// An internal format and print function.
internal fun formatAndPrint(input: Any) {

}

Modeling of non-null types

Kotlin supports many things Java does not, including modeling null-ness in the type system.
The corresponding bytecode for the above APIs looks something like:

public final class DemoKt {
    public static final void printInput(@NotNull String input) {
        Intrinsics.checkNotNullParameter(input, "input");
        formatAndPrint(input);
    }

    public static final void formatAndPrint(@NotNull Object input) {
        Intrinsics.checkNotNullParameter(input, "input");
    }
}

You might be wondering why there is a call to Intrinsics.checkNotNull(...)?

Here is what the implementation does.

@SuppressWarnings({"unused", "WeakerAccess"})
public class Intrinsics {
    private Intrinsics() {
    }

    // More methods ...

    public static void checkNotNull(Object object, String message) {
        if (object == null) {
            throwJavaNpe(message);
        }
    }
}

What the Kotlin compiler is trying to do is to provide a helpful error message if someone were to pass null as an argument to printInput. However, that can only happen when the caller is using Java. Remember, Java users can also compile against your library.

The core problem is that Kotlin is much more precise about the null-ness of types where as Java cannot be.

The Problem

If the application and the library are both using Kotlin, then everything the compiler is doing by sprinkling Intrinsics checks is pure overhead. Worse still, given the compiler is being precise about the variable names in the error message we end up adding more and more strings into the dex string pool.

Note: Null checks are not the only category of problems that we have Intrinsics for. Unsafe casts for e.g. are another category of problems that they protect against. The Kotlin compiler also lets you pass in additional compiler flags to get rid of some of these Intrisics. You are effectively opting out of runtime safety checks entirely.

Solving the problem with R8

If you are using R8 (to optimize the byte code) then you could use the following snippet of configuration and request that R8 strip out every single call to Intrinsics.

-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
  public static void checkNotNull(...); // and other similar methods
}

While this technique solves the problem and can be employed by the eventual application developer,
you don't want to do this when distributing the library. Developers would be extremely confused by the lack of error messages.

There are also other classes similar to Intrinsics that do the same thing. The JDK has Objects.requireNotNull(…), and other popular Java libraries like Guava do their own thing. These classes are everywhere!

Ideally, what we want is to be able to treat all of this as dead code, without the application developer needing to understand all of this. Ideally, we preserve the semantics of these checks without the overhead (and the specific error messages).

R8 + Android Gradle Plugin 9.0 = 🔥

Starting Android Gradle Plugin 9.0, R8 automatically takes care of this problem without the application developer needing to intervene at all. Here is the patch that added this feature.

Just toggle the version of R8 being used in Compiler Explorer, and look at the generated byte code before and after.

public class Person(val name: String)

with R8 8.7.18 the generated byte code looks like:

.class public final LPerson;
.super Ljava/lang/Object;
.source "SourceFile"

# instance fields
.field public final a:Ljava/lang/String;

# direct methods
.method public constructor <init>(Ljava/lang/String;)V
    .registers 3
    #@0
    const-string v0, "name"
    #@2
    invoke-static {p1, v0}, Lkotlin/jvm/internal/Intrinsics;->checkNotNullParameter(Ljava/lang/Object;Ljava/lang/String;)V
    #@5
    .line 13
    invoke-direct {p0}, Ljava/lang/Object;-><init>()V
    #@8
    iput-object p1, p0, LPerson;->a:Ljava/lang/String;
    #@a
    return-void
.end method

with the latest R8 release, this turns into:

.class public final LPerson;
.super Ljava/lang/Object;
.source "SourceFile"

# instance fields
.field public final a:Ljava/lang/String;

# direct methods
.method public constructor <init>(Ljava/lang/String;)V
    .registers 2
    #@0
    invoke-virtual {p1}, Ljava/lang/Object;->getClass()Ljava/lang/Class;
    #@3
    .line 13
    invoke-direct {p0}, Ljava/lang/Object;-><init>()V
    #@6
    iput-object p1, p0, LPerson;->a:Ljava/lang/String;
    #@8
    return-void
.end method

The Fix

The actual change in the generated byte code is:

// From
invoke-static {p1, v0}, Lkotlin/jvm/internal/Intrinsics;->checkNotNullParameter(Ljava/lang/Object;Ljava/lang/String;)V

// To
invoke-virtual {p1}, Ljava/lang/Object;->getClass()Ljava/lang/Class;

The reason this works is that if the parameter p1 was null, then calling p1.getClass() will throw a NullPointerException, without all the additional overhead of dispatching a call to Intrinsics. Additionally, the Android Runtime has an intrinsic to compile the call to getClass() to extremely efficient assembly code. We also end up eliminating the String constants from the pool entirely.

While we do lose out on the helpful error message, we gain significantly in performance.

Here are the results from a Compose PokedexScrollBenchmark.

Kotlin Instrinsics

You can clearly see the positive shift in frame durations after the change. ( Compose can produce frames with lower latency).

Conclusion

As Android developers, we often worry about the Kotlin tax—the extra code generated to support language features. With AGP 9.0, R8 effectively eliminates the overhead of null-safety intrinsics, giving us Kotlin's type safety at compile time and better performance at runtime.