Recently, while doing development work, I stumbled upon a bug that Kotlin officially acknowledged as an inline class. In the process of understanding the cause of this bug, I was determined to break the sand and learn a wave of JVM bytecode. I learned a lot, so I started to write this article to share the learning process with you. It’s a long article, but I’m sure you’ll find it worth reading patiently.

I heard inline class is cool

Here’s the thing. The leader of the team sent me a wave of Kotlin’s inline class last week, saying it works great and saves memory. So he wrote a sample for me to look at. Those of you who haven’t learned about inline classes should check out the official documentation

Sometimes, business logic needs to create wrappers around a type. However, it introduces a performance overhead at run time due to additional heap memory allocation issues. In addition, if the type being wrapped is a native type, the performance penalty is bad, because native types are usually heavily optimized at run time, yet their wrappers don’t get any special treatment.

Basically, let’s say I define a Password class

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

This data wrapper class is inefficient and takes up memory. Because this class actually only wraps a String of data, but because it is a separate declared class, new Password() also creates a separate instance of this class and places it in the JVM heap.

Wouldn’t it be perfect if there were a way to keep the data class as a separate type without taking up so much space? Inline class is a good choice.

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

Kotlin checks for inline class types at compile time, but at runtime the Runtime contains only String data. (As for why it’s so cool, here’s bytecode analysis)

So since this class works so well, I’m going to give it a try.

The pit of the inline class

You know what they say, try or die. It wasn’t long before I noticed something really weird. Sample code is as follows

I first defined an inline class

inline class ICAny constructor(val value: Any)
Copy the code

This class is simply a wrapper class that wraps a value of any type (Object in the JVM)

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

We also define an interface, and the foo method returns any type.

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

We then implement the interface, and on the return value of the overloaded foo we return the inline class we just defined. Because ICAny is definitely a subclass of Any(Object in the JVM), this method will compile.

And then something amazing happened.

When the following code is called

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

Copy the code

Print False!

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

Foo of class B explicitly returns an instance of ICAny, and even if I do an upward cast, it should not affect the type of the variable foo2 at runtime.

Is there a bytecode problem?

I didn’t know much about bytecode, but my intuition told me to take a look, so I used Intelji’s Kotlin bytecode feature to open the bytecode for this code.

Look, boy, there’s not a single piece of bytecode that has anything to do with the ICAny class, except for the instanceOf method.

My intuition is that since method foo of class B returns an ICAny instance, the code block that calls this method should have a variable that is the ICAny class anyway. The result is that the compiled bytecode is completely devoid of the ICAny class. It’s strange.

Introduction to bytecode

To get to the bottom of it all. I decided TO get started with some bytecode… There is a lot of information about bytecode on the Internet, so I will just share the knowledge related to our bug.

First, bytecode looks a bit like learned assembly language, easier to understand than binary, but more obscure than high-level language, which uses a limited set of instructions to implement high-level language functions. Finally, and most importantly, most JVMS implement bytecode using stacks. Let’s take a closer look at what this stack is using examples.

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

Copy the code

For example, the simple test method above looks like this when transformed into bytecode

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 = 4Copy the code

It looks very complicated, but it’s actually very easy to understand. Let’s look at it one instruction at a time. Which command is stem what of, we refer to the JVM instruction set form en.wikipedia.org/wiki/Java_b…

The first step to L0

ICONST_1, defined in bytecode as

load the int value 1 onto the stack

So the current stack frame has the first piece of data, 1

The second step is ISTORE 1, which is defined in bytecode as

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 operation pops the top number on the stack and assigns the variable index to be 1. What is the variable with index 1? Part 4 of bytecode provides the answer. That’s the variable A

Also, because ISTORE pops the top number on the stack, the stack becomes empty.

The second part of the bytecode is almost identical to the first, except that the assignment variable changes from a to B (note that the ISTORE argument is 2, corresponding to the variable with index 2, which is b)

 L1
    LINENUMBER 4 L1
    ICONST_1
    ISTORE 2

Copy the code

Bytecode Part 3

 L2
    LINENUMBER 5 L2
    ILOAD 1
    ILOAD 2
    IADD
    ISTORE 3

Copy the 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.

That is, the instruction takes the values of variables with index 1 and 2 and puts them at the top of the stack.

So after ILOAD 1 and ILOAD 2, the stack element becomes

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, the instruction pops the two elements at the top of the stack and adds them together. The sum is then added to the stack

The last step

ISTORE 3
Copy the code

That is, we assign the top element of the stack to the variable with index 3, which is C, and finally, C is assigned 2.

This is the basis of bytecode, which handles the return value (or no return value) of each instruction as a stack container. Most of the JVM’s instructions, meanwhile, take 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.

To give you a deeper understanding of how this stack works, I’m going to give you a little homework assignment. Now that you understand the principle of this little assignment, let’s move on. Or do more research. It is important to thoroughly understand how stacks are used in the JVM.

homework

A simple code

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

The bytecode for

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

Copy the code

After executing the NEW directive, we need to use DUP to copy the reference of the newly created object to the top of the stack

Inline class bytecode?

After learning the basics of bytecode, I wondered if I should look at the difference between inline class bytecode and normal class bytecode.

Sure enough, after getting the bytecode for the Inline class, something magical happens.

Take the following inline class as an example

inline class ICAny(val a: Any)
Copy the code

In bytecode, unlike ordinary classes, the inline class constructor is marked private, meaning that external code cannot use the inline class constructor.

But use it in code

    val a = ICAny(1)
Copy the code

But there is no error. Amazing…

There is a method called constructor-impl in the inline class. The name refers to the constructor, but this method does nothing but use ALOAD to read the input parameter onto the stack and pop it back immediately.

With that in mind, let’s take a look at what the compiler does when we create an inline class instance.

 val a = ICAny(1)
Copy the code

The bytecode corresponding to the kotlin code above 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 1Copy the code

The magic is that this bytecode never executes the NEW instruction at all.

The NEW instruction is used to allocate memory. NEW is followed by init (constructor) to complete the initialization of an object.

Let’s say we create a HashMap:

 val a = HashMap<String,String>()
Copy the code

The corresponding bytecode is:

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

It is obvious that the bytecode executes the NEW instruction first, dividing the memory. The HashMap constructor init is then executed. This is a standard procedure for creating an object, but unfortunately we don’t see it at all from inline class creation. That is, when we write code:

val a = ICAny(1)
Copy the code

The JVM does not open up new heap memory at all. This also explains why inline class has an advantage in memory, as it simply wraps values from a compilation perspective and does not create class instances.

But if we don’t create a class instance at all, wouldn’t it work if we did instanceOf?

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

The bytecode compiled from this code will be optimized by the JVM, and the JVM compiler will determine from the context that a must be ICAny, so you won’t even see if in the bytecode, because the compiler will find that if must be true.

Unboxing an inline class

Puzzled, I started looking at the inline class design document. Fortunately, Jetbrian makes these design documents public. In the design document, jetbrian’s engineers explained in detail the inline class type problem.

The original article describes it this way

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.

Inline classes are boxed and unboxed, just like Integer and int classes. The compiler converts these two types when necessary by boxing/unboxing them.

So for an inline class, when do I unpack and when do I pack? The answer has been given above:

inline class is boxed when it is used as another type

Inline class is boxed when it is used as another type at Runtime.

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

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

If this is a bit convoluted, let’s use a simple example to illustrate:

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

In the above code, the JVM compiler can statically analyze the context at compile time and conclude that A must be an ICAny class, which would qualify as unbox. Since the compiler already gets the type information during the static analysis phase, we can use the unboxed Inline class, meaning that the bytecode does not generate a new ICAny instance. And that fits with our previous analysis.

But suppose we modify the way we use it:

    fun test() {
        val a = ICAny(1)
        bar(a)
    }

    private fun bar(a: Any) {
        if (a is ICAny) {
            print("ok")
        }
    }

Copy the code

Added a method called bar whose input is Any, the JVM Object class. The bytecode compiled from this code needs to be crated

Like Primitive Type, ICAny uses the NEW command to create a NEW instance of a class

To summarize, when using inline class, if the current code can infer from the context that the variable must be of the inline class type, the compiler can optimize the code to save memory by not generating new instances of the class. However, if the context does not deduce whether the variable is inline class, the compiler will call the boxing method, create a new instance of the inline class, and allocate memory to the inline class instance, thus achieving the so-called memory saving purpose.

The official examples are as follows

It’s worth noting that generics also cause inline classes to be boated, because generics are the same as Kotlin’s Any, which is Object in JVM bytecode.

As a reminder, if your code can’t determine an inline class type from context, it probably doesn’t make sense to use inline class…

What causes the inline class bug

With the basics behind us, we can finally begin to understand why the bug mentioned at the beginning of this article occurred. Kotlin officials have realized this bug and the cause of the bug explained in detail: youtrack.jetbrains.com/issue/KT-30… (Here I really appreciate the style of Jetbrian’s engineers, which can be said to be very detailed).

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

Both Kotlin and Java support polymorphism/covariant in the JVM. 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 the code

This compilation is perfectly ok, because ICAny can be seen as inheriting from Object, so Class B is the entity Class that inherits from interface A, and the return value of the overridden method can be inherited from the return value of the interface Class method.

In class B’s bytecode, the compiler generates a bridge method to make the overridden Foo method return the String class, but the method signature maintains the type of the parent class.

The JVM relies on the bridge method to implement the covariant inheritance relationship.

But when we get to inline class, we have a big problem. For inline classes, some entity classes fail to generate bridge methods because the compiler will default to the inline class as Object.

Such as:

interface A {
    fun foo(): Any
}

class B : A {
    override fun foo(): ICAny {
        return ICAny(4)
    }
}

Copy the code

Because the ICAny class is of type Object in the JVM, and Any is of type Object, the compiler automatically assumes that the overridden method returns the same value as interface, so no ICAny bridge method is generated.

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

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

Copy the code

Because B does not have an ICAny bridge method, and because we forced the transition from B to class A in the code, the static analysis would also assume that foo() returned Any, which would result in the variable foo2 not being boxed, so the type would remain Object, and the above code would print False.

The solution to this problem is simply to add a bridge method to the inline class.

This bug was discovered in Kotlin 1.3 and fixed in 1.4. But given that the majority of Android app development is still using 1.3, the pit may be here to stay.

After upgrading to kotlin1.5, open the bytecode tool to see that the bridge method has been added:

conclusion

In the process of understanding the cause and solution of this bug, I started to try to understand bytecode, and learn the call stack of JVM. Finally, I expanded to bytecode support for covariant polymorphism, and 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 and why. So many years of experience tells me that mastering the basis of a discipline can make the work in the future get twice the result with half the effort. With everyone!