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:
- Inline classes wrap an underlying value.
- When the code is compiled, instances of the inline class get replaced with the underlying value.
- 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!
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
.
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!
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
.
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.
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 aList<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:
- The underlying type of the inline class itself might or might not be nullable
- 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:
- The nullability of the underlying type
- The nullability at the usage site, and
- The effective compiled code at the usage site
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 effectivelyvoid greet(String name)
- then it simply wouldn’t be possible to represent all three of these branches. It wouldn’t be clear whether a nullname
should printWho's there?
orHello, 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.
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
andboolean
on the JVM can’t actually benull
. 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 asInteger
, which (unlikeint
) 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 theKotlin Bytecode
tool window, click theDecompile
button.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
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!
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-argumentmain()
function is wrapped by the traditionalmain(String[] args)
version. But this rough example code gives you the basic idea for the sake of understanding autoboxing. ↩︎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 likegreet-SP8wLPk()
. ↩︎