最近在做开发的工作中,意外发现了kotlin官方承认的一个内联类的bug。在理解这个bug产生的原因的过程中,我秉承着打破砂锅问到底的决心,竟然顺势学习了一波jvm字节码。收获颇丰,于是便开始着手写下这篇文章和大家分享一下这个学习的过程。这篇文章很长,但是耐心看完,我相信大家肯定会觉得很值。
听说inline class很屌
事情是这样的。团队的领头大哥上周给我安利了一波kotlin的内联类,说这玩意好用的很,节约内存。于是顺手写了一个sample给我看看。还没了解过内联类(inline class)的可以看看官方文档
有时候,业务逻辑需要围绕某种类型创建包装器。然而,由于额外的堆内存分配问题,它会引入运行时的性能开销。此外,如果被包装的类型是原生类型,性能的损失是很糟糕的,因为原生类型通常在运行时就进行了大量优化,然而他们的包装器却没有得到任何特殊的处理。
简单来说,就是比如我定义了一个Password类
class Password{
private String password;
public Password(String p){
this.password = p
}
}
这种数据包装类效率很低,而且占内存。因为这个类实际上只包装了一个String的数据,但是因为他是一个单独声明的类,所以如果new Password()的话还需要单独给这个类创建一个实例,放在jvm的heap 内存里。
如果有一种办法,既可以让这个数据类保持它单独的类型,又不那么占空间,那岂不是完美?用inline class就是一个很好的选择。
inline class Password(val value: String)
// 不存在 'Password' 类的真实实例对象
// 在运行时,'securePassword' 仅仅包含 'String'
val securePassword = Password("Don't try this in production")
Kotlin会在编译的时候检查inline class的类型,但是在运行时runtime仅仅包含String数据。(至于它为啥这么屌,下面会通过字节码分析)
那既然这个类这么好用,我就开始试试了。
inline class的坑
俗话说得好,试试就逝世。没多久我就发现一个很奇葩的现象。示例代码如下
我先定义了一个inline class
inline class ICAny constructor(val value: Any)
这个类仅仅是一个包装类,包装一个任意类型的value(在jvm里面就是Object)
interface A {
fun foo(): Any
}
同时定义一个interface, foo方法返回任意类型。
class B : A {
override fun foo(): ICAny {
return ICAny(1)
}
}
接着实现这个interface,在重载的foo的返回值上面我们返回刚刚定义的inline class类。因为ICAny肯定是Any(在jvm里面是Object)的子类,所以这个方法是能够通过编译的。
接下来神奇的事情发生了。
在调用下面的代码的时候
fun test(){
val foo2: Any = (B() as A).foo()
println(foo2 is ICAny)
}
打印结果竟然是False!
也就是说,foo2这个变量,不是ICAny类。
这就很神奇了,class B的foo已经是明确的返回一个ICAny的实例了,哪怕我做一个向上转型,也不应该影响foo2这个变量在运行时的类型啊。
字节码有问题么?
虽然我不太懂字节码,但是我的直觉告诉我应该顺便看一眼,于是我便随手使用Intelji的kotlin字节码功能,打开了这段代码的字节码。
一看,好家伙,除了instanceOf这个方法需要判断ICAny类之外,没有一段字节码和ICAny类有关。
我的直觉是,既然B类的foo方法返回的是ICAny类实例,那调用这个方法的代码块怎么也得有一个变量是这个ICAny类吧。结果是编译好的字节码竟然完全没有ICAny类什么事。着实奇怪。
字节码入门
为了彻底搞明白这到底是为啥。我决定要开始入门一些字节码的知识。。。。网上关于字节码的资料很多,这里我就只分享一下和我们这次bug有关的知识。
首先字节码看起来有点像学过的汇编语言一样,比二进制要容易懂,但是又比高级语言晦涩一些,而且都是用有限的指令集实现高级语言功能。最后,最重要的一点,大部分JVM都是用栈来实现字节码的。我们接下来用例子详细了解一下这个栈到底是啥。
class Test {
fun test(){
val a = 1;
val b = 1;
val c = a + b
}
}
比如上面这个简单的test方法,变成字节码之后,长这个样子
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
看起来好像很复杂,其实非常容易理解,我们一个指令一个指令的看。具体哪个指令是干什么的,我们参照这个JVM指令集表格 https://en.wikipedia.org/wiki/Java_bytecode_instruction_listings
第一步L0
ICONST_1
,在字节码里面定义为
load the int value 1 onto the stack
那么当前的栈帧就有了第一个数据,1
第二步是 ISTORE 1
,在字节码里面ISTORE的定义为
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.
意思就是这个操作会把栈中的顶端数字pop出来,然后赋予index为1的变量。那index为1的变量是哪个变量?字节码的第四部分已经给出了答案。就是变量 a
同时,因为ISTORE会pop栈顶数字,此时栈变空了。
字节码的第二部分和第一部分几乎一模一样,只是赋值变量从a变成了b(注意ISTORE的参数是2,对应index为2的变量,就是b)
L1
LINENUMBER 4 L1
ICONST_1
ISTORE 2
字节码第三部分
L2
LINENUMBER 5 L2
ILOAD 1
ILOAD 2
IADD
ISTORE 3
第一二个指令是ILOAD,定义为
load an int value from a local variable #index, The value of the local variable at index is pushed onto the operand stack.
也就是说,这个指令会获取index为1和2的变量的值,并且把值放入栈顶。
那么经过ILOAD 1和ILOAD 2之后,栈内元素变成了
第三个指令是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.
也就是说,这个指令会把栈顶的两个元素分别pop出来并且相加,相加的和再放入栈中,也就是说此时栈内元素变成了
最后一步
ISTORE 3
也就是把栈顶元素赋值给index为3的变量,也就是c, 最后,c被赋值为2.
以上,就是字节码的基础,它以栈为容器,处理每个指令的返回值(也可能没有返回值)。同时,JVM的大部分指令,都是从栈顶获取参数作为输入。这个设计,使得JVM可以在单个栈里面处理一个方法的运行。
为了能让大家更深刻的理解这个栈的使用方式,我这里留一个小作业。理解了这个小作业的原理,咱再继续往下看。不然就多研究一下。务必彻底理解透彻JVM中栈的使用方法才行。
作业
一个简单的代码
fun test(){
val a = Object()
}
字节码为
LINENUMBER 3 L0
NEW java/lang/Object
DUP
INVOKESPECIAL java/lang/Object.<init> ()V
ASTORE 1
请问为什么在执行完NEW指令之后,需要使用DUP来复制刚刚NEW出来对象的reference到栈顶
inline class的字节码?
在学习完字节码基础之后,我就开始琢磨一下,是不是该研究一下inline class的字节码和普通类的字节码有啥不同。
果然,在得到inline class的字节码之后,神奇的东西出现了。
以下面这个inline class为例子
inline class ICAny(val a: Any)
字节码中,区别于普通的类,这个inline class的构造函数标记为了private,也就是外部代码不能使用inline class的构造函数。
但是在代码中使用
val a = ICAny(1)
却是没有错误的。很神奇。。。。
第二,inline class多了一个叫constructor-impl的方法,看名字和构造函数有关,但是仔细看,这个方法啥也没干,就是用ALOAD把输入的参数读取到栈之后,又马上弹出返回了.(注意该方法的输入类型是Object)
带着诸多疑问,我们来看看当我们创建一个inline class实例的时候,编译器到底做了啥。
val a = ICAny(1)
上面这段kotlin代码对应的字节码是:
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
神奇的地方就在于,这段字节码完全没有执行过NEW指令。
NEW指令是用来分配内存的。NEW之后配合init(构造函数)可以完成一个对象的初始化。
比如我们创建一个HashMap:
val a = HashMap<String,String>()
对应的字节码是:
L0
LINENUMBER 10 L0
NEW java/util/HashMap
DUP
INVOKESPECIAL java/util/HashMap.<init> ()V
ASTORE 1
可以很明显的看出来,字节码先执行NEW指令,划分了内存。然后再执行了HashMap的构造函数init。这是一个创建对象的标准流程,很可惜的是从inline class的创建过程中我们完全看不到这个过程。也就是说,当我们写出代码:
val a = ICAny(1)
的时候,JVM压根都不会开辟新的堆内存。这也解释了为啥inline class在内存上有优势,因为它只是从编译的角度把值给包装起来,不会创建类实例。
但是如果压根都不创建类实例,那如果我们做instanceOf的操作,岂不是不能正常工作?
fun test(){
val a = ICAny(1)
if( a is ICAny){
print("ok")
}
}
这段代码的字节码编译出来的字节码会被JVM优化,JVM编译器根据上下文判断a肯定是ICAny类,所以在字节码中你甚至都看不到有if的出现,因为编译器优化之后会发现if一定是true。
inline class的装箱拆箱
带着疑惑,我开始查看inline class的设计文档。幸运的是,jetbrian对这些设计文档都是公开的。在设计文档 中,jetbrian的工程师详细的解释了关于inline class的类型问题。
原文是这样描述的
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 class也需要装箱拆箱,就和Integer类和int类型一样。在有需要的时候编译器会对这两种类型做转换,转换的过程就是装箱/拆箱。
那对于inline class来说,什么时候需要拆箱,什么时候需要装箱呢?上文已经给出了解答:
inline class is boxed when it is used as another type
当inline class在runtime的时候被当成另一种类型使用的时候,就会装箱。
Unboxed inline class is used when value is statically known to be inline class
当inline class 在静态分析中被认为是当做inline class本身执行的时候,就不需要装箱。
可能这样说有点绕口,我们用一个简单的例子来阐明:
fun test(){
val a = ICAny(1)
if( a is ICAny){
print("ok")
}
}
上面这段代码中,JVM编译器在编译阶段就可以通过上下文的静态分析得出a一定是ICAny类,这种情况就符合unbox的条件。因为编译器在静态分析阶段就已经获取了类型信息,我们就可以使用拆箱的inline class,也就是字节码不会生成一个新的ICAny实例。这样也符合我们之前分析。
但是假如我们修改一下使用方式:
fun test() {
val a = ICAny(1)
bar(a)
}
private fun bar(a: Any) {
if (a is ICAny) {
print("ok")
}
}
加入了一个叫bar的方法,该方法的输入是Any,也就是JVM中的Object类。这段代码编译出来的字节码,就需要装箱操作了
ICAny的装箱操作方法,和primitive type类似,其实就是执行NEW指令,创建一个新的类实例
总结一下,当使用inline class的时候,如果当前代码根据上下文可以推断出变量一定是inline class类型,编译器就可以优化代码,不生成新的类实例,从而达到节省内存空间的目的。但是如果通过上下文推断不出来变量是否是inline class,编译器就会调用装箱方法,创建新的inline class类实例,划分内存空间给inline class实例,也就达不到所谓的节省内存的目的了。
官方给出的例子如下
其中值得注意的是泛型也会让inline class产生装箱,因为泛型其实和kotlin的Any是一样的性质,在JVM字节码中都是Object。
这也给大家提了个醒,如果你的代码不能通过上下文判断inline class类型,那使用inline class可能并没啥卵用。。。。
inline class的bug是什么原因产生的
在了解完基础知识之后,我们终于可以开始理解为什么在文章开始时候提到的bug会发生了。Kotlin官方已经意识到这个bug并且把bug产生的原因详细解释了一下: https://youtrack.jetbrains.com/issue/KT-30419 (在这里非常欣赏jetbrian的工程师的作风,可以说是写的非常详细了)。
这里稍微解释一下给看不太懂英文的小伙伴:
在JVM中,kotlin和java都是支持多态/协变的。比如在下面这个继承关系中:
interface A {
fun foo(): Any
}
class B : A {
override fun foo(): String { // Covariant override, return type is more specialized than in the parent
return ""
}
}
这样的代码编译是完全ok的,因为ICAny可以看做是继承了Object类,所以Class B作为继承A接口的实体类,重写的方法的返回值可以是和接口类方法的返回值呈继承关系的.
在class B的字节码中,编译器会生成一个桥接方法(bridge method)来让重写的foo方法返回String类,但是同时方法签名维持父类的类型。
JVM正是依靠着桥接方法,实现了继承关系的协变。
但是到了inline class这里,就出大问题了。对于inline class来说,因为编译器会默认将其当做Object类型,会导致某些实体类没法生成桥接方法的bug。
比如:
interface A {
fun foo(): Any
}
class B : A {
override fun foo(): ICAny {
return ICAny(4)
}
}
因为ICAny类在JVM中是Object类型,Any也是Object类型,编译器就会自动认为重写方法的返回值是和interface一样,所以不会生成ICAny的桥接方法。
所以回到我们文章开头的bug代码中
val foo2: Any = (B() as A).foo()
println(foo2 is ICAny)
因为B没有ICAny类型的桥接方法,加上在代码中我们强制转型把B转成了A类,所以静态分析也会认为foo()方法的返回值是Any,就会导致foo2变量不会被装箱,所以类型就一直是Object,以上代码的打印结果也就是False了。
所以相应的,解决这个bug的办法也很简单,就是给inline class加上桥接方法就好了!
这个bug在kotlin 1.3的时候被发现,在1.4被fix。但是鉴于大部分安卓应用开发还在使用1.3,这个坑可能还会长期存在。
升级到kotlin1.5之后,打开字节码工具可以发现桥接方法已经被加上啦:
总结
在理解这个bug的原因和解决方法的过程中,我开始尝试了解字节码,同时学习JVM的调用栈,最后拓展到字节码对协变多态的支持,可以说收获真的很多。希望这个学习方式和过程可以给更多的朋友一些启发,当我们遇到问题的时候,需要做到知其然,还要知其所以然,这么多年的经验告诉我,掌握好一门学科的基础是可以让之后的工作事半功倍的。与大家共勉!