Kotlin 內聯類 inline class請了解一下

最近在做開發的工作中,意外發現了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的調用棧,最後拓展到字節碼對協變多態的支持,可以說收穫真的很多。希望這個學習方式和過程可以給更多的朋友一些啓發,當我們遇到問題的時候,需要做到知其然,還要知其所以然,這麼多年的經驗告訴我,掌握好一門學科的基礎是可以讓之後的工作事半功倍的。與大家共勉!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章