Please know about Kotlin inline class

Please know about Kotlin inline class

In the recent development work, I accidentally discovered an inline bug officially recognized by Kotlin. In the process of understanding the cause of this bug, I uphold the determination to break the casserole to the end, and even learn a wave of jvm bytecode. Gained a lot, so I started to write this article and share this learning process with everyone. This article is very long, but after reading it patiently, I am sure everyone will find it very worthwhile.

I heard that the inline class is very awkward

The thing is like this. The leader of the team gave me a wave of kotlin inline classes last week, saying that this thing is very useful and saves memory. So Shun wrote a sample for me to see. If you haven t learned about inline classes, you can check out the official documentation.

Sometimes, business logic needs to create a wrapper around a certain type. However, due to additional heap memory allocation issues, it will introduce runtime performance overhead. In addition, if the type being wrapped is a native type, the performance loss is very bad, because native types usually have a lot of optimizations at runtime, but their wrappers have not received any special treatment.

Simply put, for example, I defined a Password class

class Password { private String password; public Password (String p) { this .password = p } } Copy code

This kind of data packaging is very inefficient and occupies memory. Because this class actually only wraps the data of a String, but because it is a separately declared class, if new Password() is used, an instance of this class needs to be created separately and placed in the heap memory of the jvm.

If there is a way to keep this data class as a separate type without taking up too much space , wouldn t it be perfect? Using inline class is a good choice.

inline class Password(val value: String) //There is no real instance object of the'Password' class //At runtime,'securePassword' only contains'String' val securePassword = Password("Don't try this in production") Copy code

Kotlin will check the type of the inline class at compile time, but the runtime only contains String data at runtime. (As for why it is so awkward, I will analyze it through bytecode below)

Now that this class is so easy to use, I will try it.

inline class pit

As the saying goes, try and die. It didn't take long for me to discover a very strange phenomenon. The sample code is as follows

I first defined an inline class

inline class ICAny constructor ( Val value: the Any) Copy Code

This class is just a wrapper class, which wraps a value of any type (Object in jvm)

interface A { fun foo () : Any } Copy code

At the same time define an interface, the foo method returns any type.

class B : A { override fun foo () : ICAny { return ICAny( 1 ) } } Copy code

Then implement this interface, above the return value of the overloaded foo we return to the inline class just defined. Because ICAny is definitely a subclass of Any (Object in jvm), this method can be compiled.

Then something magical happened.

When calling the following code

fun test () { val foo2: Any = (B() as A).foo() println(foo2 is ICAny) } Copy code

The printed result turned out to be False !

In other words, the variable foo2 is not an ICAny class.

This is amazing. The foo of class B already explicitly returns an instance of ICAny. Even if I do an upward transformation, it should not affect the type of the variable foo2 at runtime.

Is there a problem with the bytecode?

Although I don't know bytecode well, my instinct told me that I should take a look at it, so I just used Intelji's kotlin bytecode function to open the bytecode of this code.

At first glance, good guys, apart from the instanceOf method that needs to determine the ICAny class, there is no bytecode related to the ICAny class.

My intuition is that since the foo method of class B returns an instance of the ICAny class, the code block that calls this method must have a variable of this ICAny class. The result is that the compiled bytecode has nothing to do with ICAny at all. It's really strange.

Getting started with bytecode

In order to thoroughly understand what this is. I decided to get started with some knowledge of bytecode. . . . There is a lot of information about bytecode on the Internet, here I will only share the knowledge related to our bug.

First of all, bytecode looks a bit like assembly language that has been learned. It is easier to understand than binary, but more obscure than high-level language, and it uses a limited instruction set to implement high-level language functions. Finally, the most important point is that most JVMs use stacks to implement bytecode. Let's use an example to learn more about what this stack is.

class Test { fun test () { val a = 1 ; val b = 1 ; val c = a + b } } Copy code

For example, the simple test method above, after it becomes bytecode, looks like this

public final test()V L0 LINENUMBER 3 L0 ICONST_1 ISTORE 1 L1 LINENUMBER 4 L1 ICONST_1 ISTORE 2 L2 LINENUMBER 5 L2 ILOAD 1 ILOAD 2 IADD ISTORE 3 L3 LINENUMBER 6 L3 RETURN L4 LOCALVARIABLE c I L3 L4 3 LOCALVARIABLE b I L2 L4 2 LOCALVARIABLE a I L1 L4 1 LOCALVARIABLE this LTest; L0 L4 0 MAXSTACK = 2 MAXLOCALS = 4 Copy code

It looks complicated, but it is actually very easy to understand. Let's look at it one by one. For specific instructions, we refer to this JVM instruction set table en.wikipedia.org/wiki/Java_b...

The first step L0

ICONST_1
, Defined as

load the int value 1 onto the stack

Then the current stack frame has the first data, 1

The second step is

ISTORE 1
, The definition of ISTORE in the bytecode is

store int value into variable #index, It is popped from the operand stack, and the value of the local variable at index is set to value.

This means that this operation will pop the top number in the stack and assign it to the variable whose index is 1. Which variable is the variable with index 1? The fourth part of the bytecode has already given the answer. Is the variable a

At the same time, because ISTORE will pop the top number of the stack, the stack becomes empty at this time.

The second part of the bytecode is almost the same as the first part, except that the assignment variable is changed from a to b (note that the parameter of ISTORE is 2, which corresponds to the variable with index 2, which is b)

L1 LINENUMBER 4 L1 ICONST_1 ISTORE 2 Copy code

Bytecode third part

L2 LINENUMBER 5 L2 ILOAD 1 ILOAD 2 IADD ISTORE 3 Copy code

The first two instructions are ILOAD, defined as

load an int value from a local variable #index, The value of the local variable at index is pushed onto the operand stack.

In other words, this instruction will get the value of the variables with index 1 and 2, and put the value on the top of the stack.

Then after ILOAD 1 and ILOAD 2, the elements in the stack become

The third instruction is IADD

add two ints, The values are popped from the operand stack. The int result is value1 + value2. The result is pushed onto the operand stack.

In other words, this instruction will pop the two elements at the top of the stack and add them separately, and then put the added sum into the stack, which means that the elements in the stack become

last step

ISTORE 3 copy code

That is, the top element of the stack is assigned to the variable with index 3, which is c, and finally, c is assigned the value 2.

The above is the basis of bytecode, which uses the stack as a container to process the return value of each instruction (or there may be no return value). At the same time, most of the instructions of the JVM get parameters from the top of the stack as input. This design allows the JVM to handle the execution of a method in a single stack.

In order to give you a deeper understanding of how this stack is used, I leave a small assignment here. After understanding the principle of this small assignment, let's continue to look down. Otherwise, just study it more. You must thoroughly understand how to use the stack in the JVM.

operation

A simple code

fun test(){ val a = Object() } Copy code

The bytecode is

LINENUMBER 3 L0 NEW java/lang/Object DUP INVOKESPECIAL java/lang/Object.<init> ()V ASTORE 1 Copy code

Why do I need to use DUP to copy the reference of the new object to the top of the stack after executing the NEW instruction?

The bytecode of the inline class?

After learning the basics of bytecode, I started to wonder if I should study the difference between the bytecode of inline class and the bytecode of ordinary class.

Sure enough, after getting the bytecode of the inline class, something magical appeared.

The following inline class is an example

inline class ICAny (val a: Any ) Copy Code

In bytecode, different from ordinary classes, the constructor of this inline class is marked as private, which means that external code cannot use the constructor of the inline class.

But use in the code

val a = ICAny(1) Copy code

There is no error. It's amazing. . . .

2. the inline class has an additional method called constructor-impl. The name is related to the constructor, but if you look carefully, this method does nothing. After reading the input parameters to the stack with ALOAD, it immediately pops up and returns. (Note that the input type of this method is Object)

With many questions, let's take a look at what the compiler does when we create an inline class instance.

val a = ICAny(1) Copy code

The bytecode corresponding to the above kotlin code is:

L0 LINENUMBER 6 L0 ICONST_1 INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer; INVOKESTATIC com/jetbrains/handson/mpp/myapplication/ICAny.constructor-impl (Ljava/lang/Object;)Ljava/lang/Object; ASTORE 1 Copy code

The magic is that this bytecode has never executed the NEW instruction.

The NEW instruction is used to allocate memory. After NEW, with init (constructor), an object can be initialized.

For example, we create a HashMap:

val a = HashMap <String, String > () Copy Code

The corresponding bytecode is:

L0 LINENUMBER 10 L0 NEW java/util/HashMap DUP INVOKESPECIAL java/util/HashMap.<init> ()V ASTORE 1 Copy code

It can be clearly seen that the bytecode executes the NEW instruction first, dividing the memory. Then the constructor init of HashMap is executed. This is a standard process for creating objects. It's a pity that we can't see this process at all during the creation of inline class. In other words, when we write the code:

val a = ICAny(1) Copy code

At that time, JVM will not open up new heap memory at all. This also explains why the inline class has an advantage in memory, because it just wraps the value from the perspective of compilation and does not create class instances.

But if no class instance is created at all, then if we do the instanceOf operation, wouldn't it not work properly?

fun test(){ val a = ICAny(1) if( a is ICAny){ print("ok") } } Copy code

The bytecode compiled by the bytecode of this code will be optimized by the JVM. The JVM compiler judges that a must be of the ICAny class according to the context, so you can't even see if there is an if in the bytecode, because the compiler After optimization, you will find that if must be true.

Boxing and unboxing of inline class

With doubts, I started to check the design documents of the inline class. Fortunately, Jetbrian makes all these design documents public. In the design document , Jetbrian's engineers explained in detail the type of inline class.

The original description is like this

Rules for boxing are pretty the same as for primitive types and can be formulated as follows: inline class is boxed when it is used as another type. Unboxed inline class is used when value is statically known to be inline class.

It probably means that the inline class also needs to be boxed and unboxed, just like the Integer class and the int type. The compiler will convert these two types when necessary, and the conversion process is boxing/unboxing.

For the inline class, when do you need to unbox it and when do you need to pack it? The answer has been given above:

inline class is boxed when it is used as another type

When the inline class is used as another type at runtime, it will be boxed.

Unboxed inline class is used when value is statically known to be inline class

When the inline class is considered to be executed as the inline class itself in the static analysis, boxing is not required.

It may be a bit convoluted to say this, let's use a simple example to illustrate:

fun test(){ val a = ICAny(1) if( a is ICAny){ print("ok") } } Copy code

In the above code, the JVM compiler can determine that a must be an ICAny class through static analysis of the context at the compilation stage. In this case, it meets the condition of unbox. Because the compiler has already obtained the type information during the static analysis phase, we can use the unboxed inline class, that is, the bytecode will not generate a new ICAny instance. This is in line with our previous analysis.

But if we modify the usage:

fun test() { val a = ICAny(1) bar(a) } private fun bar(a: Any) { if (a is ICAny) { print("ok") } } Copy code

A method called bar is added. The input of this method is Any, which is the Object class in the JVM. The bytecode compiled by this code needs to be boxed

ICAny's boxing operation method is similar to primitive type, in fact, it executes the NEW instruction to create a new class instance

To sum up, when using inline class, if the current code can infer that the variable must be of the inline class type based on the context, the compiler can optimize the code and not generate new class instances, thereby saving memory space. However, if the variable cannot be inferred from the context whether it is an inline class, the compiler will call the boxing method, create a new instance of the inline class, and divide the memory space for the inline class instance, which will not achieve the so-called memory saving purpose .

The official example is as follows

Among them, it is worth noting that generics will also cause inline classes to be boxed, because generics are actually the same in nature as Kotlin's Any, and they are all Objects in the JVM bytecode.

This is also a reminder for everyone, if your code cannot determine the type of inline class by context, then using inline class may not be useful. . . .

What is the cause of the inline class bug

After understanding the basics, we can finally begin to understand why the bug mentioned at the beginning of the article occurred. Kotlin officials are aware of this bug and explain the cause of the bug in detail: youtrack.jetbrains.com/issue/KT-30... (I really appreciate the style of jetbrian engineers here, it can be said that it is written in very detail) .

Here is a little explanation for those who do not understand English:

In the JVM, both Kotlin and Java support polymorphism/covariance. For example, in the following inheritance relationship:

interface A { fun foo(): Any } class B: A { override fun foo(): String {//Covariant override, return type is more specialized than in the parent return "" } } Copy code

This kind of code compilation is completely ok, because ICAny can be regarded as inheriting the Object class, so Class B is an entity class that inherits the A interface, and the return value of the overridden method can be inherited from the return value of the interface class method. of.

Bytecode class B, the compiler generates a bridging method (bridge method) to make foo overridden method returns String class, but the method signatures while maintaining the type of the parent class.

JVM is relying on the bridging method to realize the covariance of inheritance relationship.

But when it comes to inline class, a big problem occurs. For the inline class, because the compiler will treat it as the Object type by default, it will cause some entity classes to fail to generate the bridge method bug.

such as:

interface A { fun foo(): Any } class B: A { override fun foo(): ICAny { return ICAny(4) } } Copy code

Because the ICAny class is of the Object type in the JVM and Any is also of the Object type, the compiler will automatically think that the return value of the overridden method is the same as the interface, so the ICAny bridge method will not be generated.

So back to the bug code at the beginning of our article

val foo2: Any = (B() as A).foo() println(foo2 is ICAny) Copy code

Because B does not have an ICAny type bridge method, and in the code, we cast B to A type, so static analysis will also think that the return value of the foo() method is Any, which will cause the foo2 variable to not be installed. Box, so the type is always Object, and the printing result of the above code is False.

So correspondingly, the solution to this bug is also very simple, just add a bridge method to the inline class!

This bug was discovered in Kotlin 1.3 and fixed in 1.4. But given that most Android application development is still using 1.3, this pit may still exist for a long time.

After upgrading to kotlin1.5, open the bytecode tool and you can find that the bridge method has been added:

summary

In the process of understanding the cause and solution of this bug, I began to try to understand the bytecode, while learning the call stack of the JVM, and finally expanded to bytecode support for covariant polymorphism. It can be said that I have gained a lot. I hope that this learning method and process can give more friends some inspiration. When we encounter problems, we need to know what is happening and why. So many years of experience have taught me to master the foundation of a subject. It can make future work more effective. Encourage everyone!