Author profile picture

Inline Classes and Autoboxing

In the last article, we discovered how Kotlin’s inline classes feature allows us to “create the data types that we want without giving up the performance that we need.” We learned that:

  1. Inline classes wrap an underlying value.
  2. When the code is compiled, instances of the inline class get replaced with the underlying value.
  3. This can improve our app’s performance, especially when the underlying type is a primitive.

In some situations, though, inline classes could actually perform more slowly than traditional classes! In this article, we’re going to discover what happens in the compiled code when inline classes are used in different situations - because if we know how to use them effectively, we can get better performance out of them!

Boxing Day Cartoon

Kotlin is multiplatform, so it can compile down to a variety of targets, such as JVM bytecode, JavaScript, and even native platforms. In this article, we’re going to focus particularly on how autoboxing works when Kotlin is compiled for the JVM.

If you’re entirely new to inline classes, you’ll want to start by reading the previous article, An Introduction to Inline Classes. That way you’ll be all caught up and ready to read this one.

Okay, let’s do this!

The Mysteries of Performance

Alan is stoked! After learning about inline classes, he decided to start using them in a game prototype that he’s working on. In order to see just how much better inline classes would perform than traditional classes, he slaps together some code for a scoring system:

interface Amount { val value: Int }
@JvmInline value class Points(override val value: Int) : Amount

private var totalScore = 0L

fun main() {
    repeat(1_000_000) {
        val points = Points(it)

        repeat(10_000) {
            addToScore(points)
        }
    }
}

fun addToScore(amount: Amount) {
    totalScore += amount.value
}

Alan runs a benchmark on this code as it’s written. Then, he removes the words @JvmInline value from the second line, and runs the benchmark again.

To his surprise, running this with @JvmInline value is actually noticeably slower than running it without it!

“What happened?” he wonders.

While inline classes can perform better than traditional classes, it all depends on how we use them - because how we use them determines whether the value is actually inlined in the compiled code.

That’s right - instances of inline classes are not always inlined in the compiled code.

When Inline Classes Are Not Inlined

Let’s take another look at Alan’s code to see if we can figure out why his inline class might not be inlined.

We’ll start with this part:

interface Amount { val value: Int }
@JvmInline value class Points(override val value: Int) : Amount

In this code, the inline class Points implements the interface Amount. This raises an interesting scenario when we call the addToScore() function.

fun addToScore(amount: Amount) {
    totalScore += amount.value
}

The addToScore() function can accept any Amount object. Since Points is a subtype of Amount, we can pass a Points instance to that function.

Basic stuff, right?

But… if instances of our Points class are all inlined - that is, if they are replaced with the underlying integers in the compiled code - then how can addToScore() accept an integer argument? After all, the underlying type of Int doesn’t implement Amount.

UML class diagram showing the relationships between Amount, Points, and Int.

So how can the compiled code possibly send an Int (or more precisely, a Java int) to this function, since integers don’t implement Amount?

The answer is that it can’t!

Int does not implement Amount.

So in this case, rather than using an integer in the compiled code, Kotlin will use an actual class called Points instead. We’ll call this Points class the wrapping type, as opposed to Int, which is the underlying type. By using an instance of Points in the compiled code, Kotlin can safely call the addToScore() function with it now, because that Points class implements Amount.

Points does implement Amount.

So in this particular case, our instance of Points is not inlined.

It’s important to note that this does not mean that the class is never inlined. It only means that it is not inlined at this point in the code. For example, let’s look at Alan’s code and see when Points is inlined and when it’s not.

fun main() {
    repeat(1_000_000) {
        val points = Points(it) // <-- Points is inlined as an Int here

        repeat(10_000) {
            addToScore(points)  // <-- Can't pass Int here, so sends it
                                //     as an instance of Points instead.
        }
    }
}

The compiler will use the underlying type (e.g., Int, which compiles to int) wherever it can, but when that’s not possible, it automatically instantiates an instance of the wrapping type (e.g., Points) and sends that instead. The effective compiled code (in Java) could be envisioned roughly1 like this:

public static void main(String[] arg) {
  for(int i = 0; i < 1000000; i++) {
     int points = i;                     // <--- Inlined here

     for(short k = 0; k < 10000; k++) {
        addToScore(new Points(points));  // <--- Automatic instantiation!
     }
  }
}

In the compiled code, you can imagine the Points class as being just a box that wraps that underlying Int value.

An Int being boxed up by a Points.

Because the compiler automatically puts the value into that box for us, we call this autoboxing.

And now we know why Alan’s code was running slower when using an inline class. Every time that addToScore() was called, a new instance of Points was being instantiated, so there was a heap allocation slowing things down on every iteration of that inner loop - for a total of 10 billion times. (Contrast that with using a traditional class instead, where the heap allocation only happened in the outer loop for a total of just 1 million times).

This autoboxing is often helpful - it can be necessary for maintaining type safety, for example. It also comes with the usual performance cost that happens whenever we create a new object that goes on the heap. This means that, as developers, it’s important for us to know when to expect Kotlin to autobox, so that we can make smarter decisions about how to use our inline classes.

So, let’s check out some cases when autoboxing can happen!

Autoboxing when Referenced as a Super Type

As we saw, autoboxing happened when we passed a Points object to a function that expected Amount, an interface that Points implemented.

Even if your inline class doesn’t implement an interface, it’s good to keep this in mind, because just like regular classes, all inline classes are subtypes of Any. And when you plug an instance of an inline class into a variable or parameter of type Any, you can expect autoboxing to happen.

For example, let’s say we’ve got an interface for a service that can log stuff:

interface LogService {
    fun log(any: Any)
}

Since the log() function accepts an argument of type Any, when you send an instance of Points to it, that value will be autoboxed.

val points = Points(5)
logService.log(points) // <--- Autoboxing happens here

So to summarize - autoboxing can happen when you use an instance of an inline class where a super type is expected.

Autoboxing and Generics

Autoboxing also happens when you use inline classes with generics. Here are a few examples:

val points = Points(5)

// Autoboxing happens here, when putting `points` into a list:

val scoreAudit = listOf(points)


// It also happens when you call this function with it:

fun <T> log(item: T) {
    println(item)
}

log(points)

It’s a good thing that Kotlin boxes it up for us when using generics, or else we’d run into typing issues in the compiled code. For example, similar to our earlier case, it wouldn’t be safe to add an integer to a MutableList<Amount>, because integers don’t implement Amount.

And, it would get even more complicated once we consider Java interop. For example:

  • If Java got a hold of a List<Points> as a List<Integer>, should it be able to send that list back to this Kotlin function?

    fun receive(list: List<Int>)
    
  • What about Java sending it to this Kotlin function?

    fun receive(list: List<Amount>)
    
  • Should Java be able to construct its own list of integers and send it to this Kotlin function?

    fun receive(list: List<Points>)
    

Instead, Kotlin avoids these problems by boxing up inline classes when they’re used with generics.

So we’ve seen how super types and generics can lead to autoboxing. We’ve got one more fascinating case to consider - nullability!

Autoboxing and Nullability

Autoboxing can happen when nulls get involved. The rules are a tad different depending on whether the underlying type is a reference type or primitive, so let’s tackle them one at a time.

Reference Types

When we’re talking about nullability of inline classes, there are two places that could be nullable:

  1. The underlying type of the inline class itself might or might not be nullable
  2. The place where you’re using the inline class might or might not use it as a nullable type

For example:

// 1. The underlying type itself can be nullable (`String?`)
@JvmInline value class Nickname(val value: String?)

// 2. The usage can be nullable (`Nickname?`)
fun logNickname(nickname: Nickname?) {
    // ...
}

Since we have two spots, and each of them might or might not be nullable, that gives us a total of four (22 = 4) scenarios to consider. Let’s geek out and make a truth table for those four scenarios!

For each one, we’ll consider:

  1. The nullability of the underlying type
  2. The nullability at the usage site, and
  3. The effective compiled code at the usage site
Illustrated truth table demonstrating how nullability of the underlying reference type and usage type affect whether the type is inlined at that part of the code.

The good news is that, when the underlying type is a reference type, most of the time, the usage site will be compiled to use the underlying type. And that means the underlying value will be used without any boxing happening.

There’s just a single autoboxing case here that we need to watch out for - when both the underlying type and the usage type are nullable.

Why can’t the underlying type be used in this case?

Because when there are two different spots for nulls, you can end up with different code branches depending on which of these two was null. For example, check out this code:

@JvmInline value class Nickname(val value: String?)

fun greet(name: Nickname?) {
    if (name == null) {
        println("Who's there?")
    } else if (name.value == null) {
        println("Hello, there.")
    } else {
        println("Greetings, ${name.value}")
    }
}

fun main() {
    greet(Nickname("Slimbo"))
    greet(Nickname(null))
    greet(null)
}

If the name parameter were to use the underlying type - in other words, if the compiled code were effectively void greet(String name) - then it simply wouldn’t be possible to represent all three of these branches. It wouldn’t be clear whether a null name should print Who's there? or Hello, there.

So instead, the function signature is effectively void greet(Nickname name)2. And that means Kotlin will automatically box up the underlying value as needed whenever we call that function.

Well, that does it for nullable reference types! But what about nullable primitives?

Primitive Types

When the worlds of inline classes, primitive types, and nullability all collide, we get some surprising situations! Just as we saw with reference types above, nullability could apply to either the underlying type or the usage site.

// 1. The underlying type itself can be nullable (`Int?`)
@JvmInline value class Anniversary(val value: Int?)

// 2. The usage can be nullable (`Anniversary?`)
fun celebrate(anniversary: Anniversary?) {
    // ...
}

Let’s build out a truth table, just like we did for the reference types above.

Illustrated truth table demonstrating how nullability of the underlying primitive type and usage type affect whether the type is inlined at that part of the code.

As you can see, the resulting compiled types in the table above are almost the same as they were for reference types, except for Scenario B. But there’s a lot going on here, so let’s take a moment to walk through each one.

Scenario A. Easy enough. No nullability here at all, so the type is inlined, just as we’d expect.

Scenario B. This one differs from the previous truth table. As you might recall, primitives like int and boolean on the JVM can’t actually be null. So in order to accommodate the null, Kotlin uses the wrapping type here instead, which means autoboxing would happen as needed when you call it.

Scenario C. This one is interesting. In general, when you’ve got a nullable primitive like Int? in Kotlin, it’s represented in the compiled code as the corresponding Java primitive wrapper class - such as Integer, which (unlike int) can accommodate nulls. In Scenario C, the compiled code at the usage site uses the underlying type, which itself just happens to be a Java primitive wrapper class. So on one level, you could say that the value technically is boxed, but that boxing has nothing to do with being an inline class.

Scenario D. Similar to what we saw with the reference types above, Kotlin will use the wrapping type when both the underlying type and the usage are nullable. Again, this allows for different code paths depending on which one of those is null.

Other Things to Keep in Mind

We’ve covered the main situations that can cause autoboxing. As you work with inline classes, you might find it helpful to decompile the bytecode of your Kotlin files to see what’s happening under the hood.

To do this in IntelliJ or Android Studio, just go to Tools -> Kotlin -> Show Kotlin Bytecode, then in the Kotlin Bytecode tool window, click the Decompile button.

Screen shot of menu for decompiling Kotlin bytecode.

Also, remember that there are lots of things at many levels that can affect performance. Even with a solid understanding of autoboxing, things like compiler optimizations (both by the Kotlin compiler and the JIT compiler) can cause surprising differences from our expectations. The only way to truly know the real-life performance impact of our coding decisions is to actually run tests with a benchmarking tool, such as JMH.

Summary

Cartoon boxes.

In this article, we’ve explored some performance implications of inline classes, learning about when autoboxing can happen. We’ve seen that how an inline class is used has an impact on its performance, including usages that involve:

  • Super Types
  • Generics
  • Nullability

Now that we know this, we can make smarter decisions to get the most out of our inline class performance!


  1. I’ve simplified this quite a bit for clarity. Technically, the for loops look a little different, and there are some wrapper functions that get called to handle object construction. And a no-argument main() function is wrapped by the traditional main(String[] args) version. But this rough example code gives you the basic idea for the sake of understanding autoboxing. [return]
  2. Technically, public function names that include inline classes in the signature get mangled into a name that can’t be accessed in regular Java code. So the greet() function would be renamed to something like greet-SP8wLPk(). [return]

Share this article:

  • Share on Twitter
  • Share on Facebook
  • Share on Reddit