kotlin - 你真的瞭解 by lazy嗎

語言: CN / TW / HK

背景

kotlin中的語法糖by lazy相信都有用過,但是這裡面的祕密卻很少有人深究下去,還有網上充斥著大量的文章,卻很少能說到本質的點上,所以本文以位元組碼的視角,揭開by lazy的祕密。

一個例子

``` class LazyClassTest {

val lazyTest :Test by lazy {
    Log.i("hello","初始化") 1
    Test()
}

fun test(){
    Log.i("hello","$lazyTest")
    Log.i("hello","$lazyTest")
}

} ``` 如果執行test方法,請問代號為1的log會輸出幾次呢?答案是1次,明明我們在test方法中執行了兩次lazyTest的獲取,這其中有什麼不為人知的事情嗎!?其實這是kotlin在編譯的時候給我們施加了魔法。

編譯器背後的事情

為了看清楚編譯器的事情,我們直接檢視編譯後的位元組碼,這裡貼出來,後面解釋

``` 刪除不必要的資訊 // access flags 0x18 final static INNERCLASS com/example/newtestproject/LazyClassTest$lazyTest$2 null null

// access flags 0x12 private final Lkotlin/Lazy; lazyTest$delegate @Lorg/jetbrains/annotations/NotNull;() // invisible

// access flags 0x1 public ()V L0 LINENUMBER 5 L0 ALOAD 0 INVOKESPECIAL java/lang/Object. ()V L1 LINENUMBER 7 L1 ALOAD 0 GETSTATIC com/example/newtestproject/LazyClassTest$lazyTest$2.INSTANCE : Lcom/example/newtestproject/LazyClassTest$lazyTest$2; CHECKCAST kotlin/jvm/functions/Function0 INVOKESTATIC kotlin/LazyKt.lazy (Lkotlin/jvm/functions/Function0;)Lkotlin/Lazy; PUTFIELD com/example/newtestproject/LazyClassTest.lazyTest$delegate : Lkotlin/Lazy; L2 LINENUMBER 5 L2 RETURN L3 LOCALVARIABLE this Lcom/example/newtestproject/LazyClassTest; L0 L3 0 MAXSTACK = 2 MAXLOCALS = 1

// access flags 0x11 public final getLazyTest()Lcom/example/newtestproject/Test; @Lorg/jetbrains/annotations/NotNull;() // invisible L0 LINENUMBER 7 L0 ALOAD 0 GETFIELD com/example/newtestproject/LazyClassTest.lazyTest$delegate : Lkotlin/Lazy; ASTORE 1 ALOAD 1 INVOKEINTERFACE kotlin/Lazy.getValue ()Ljava/lang/Object; (itf) CHECKCAST com/example/newtestproject/Test L1 LINENUMBER 7 L1 ARETURN L2 LOCALVARIABLE this Lcom/example/newtestproject/LazyClassTest; L0 L2 0 MAXSTACK = 1 MAXLOCALS = 2

// access flags 0x11 public final test()V L0 LINENUMBER 13 L0 LDC "hello" ALOAD 0 INVOKEVIRTUAL com/example/newtestproject/LazyClassTest.getLazyTest ()Lcom/example/newtestproject/Test; INVOKESTATIC java/lang/String.valueOf (Ljava/lang/Object;)Ljava/lang/String; INVOKESTATIC android/util/Log.i (Ljava/lang/String;Ljava/lang/String;)I POP L1 LINENUMBER 14 L1 LDC "hello" ALOAD 0 INVOKEVIRTUAL com/example/newtestproject/LazyClassTest.getLazyTest ()Lcom/example/newtestproject/Test; INVOKESTATIC java/lang/String.valueOf (Ljava/lang/Object;)Ljava/lang/String; INVOKESTATIC android/util/Log.i (Ljava/lang/String;Ljava/lang/String;)I POP L2 LINENUMBER 15 L2 RETURN L3 LOCALVARIABLE this Lcom/example/newtestproject/LazyClassTest; L0 L3 0 MAXSTACK = 2 MAXLOCALS = 1 ``` 我們驚訝的發現,原本的類中居然多出了一個內部類com/example/newtestproject/LazyClassTest$lazyTest$2,命名這麼長!沒錯,它就是編譯的時候生成的“魔法的種子”,那麼這裡內部類有什麼特別的地方嗎?位元組碼層面是看不出來的,因為這個這只是編譯時期的內容,我們在虛擬機器執行的時候來看,它其實是一個實現了一個介面是Lazy的內部類

``` public interface Lazy { public abstract val value: T

public abstract fun isInitialized(): kotlin.Boolean

} ```

lazy背後的延時載入

為什麼用了lazy就有懶載入的效果呢?其實關鍵就是這個,我們在init階段可以看到

getstatic 'com/example/newtestproject/LazyClassTest$lazyTest$2.INSTANCE','Lcom/example/newtestproject/LazyClassTest$lazyTest$2;' checkcast 'kotlin/jvm/functions/Function0' INVOKESTATIC kotlin/LazyKt.lazy (Lkotlin/jvm/functions/Function0;)Lkotlin/Lazy; putfield 'com/example/newtestproject/LazyClassTest.lazyTest$delegate','Lkotlin/Lazy;' 在初始化的時候,只是呼叫了kotlin/LazyKt.lazy類的一個靜態方法,針對屬性複製的putfield指令,也只是對LazyClassTest.lazyTest$delegate這個內部類的一個Lkotlin/Lazy物件進行賦值,看起來其實跟我們的lazyTest變數毫無關係。真相是lazyTest具體的賦值操作被隱藏了而已。從這裡就可以看到,為什麼lazy是如何實現延時載入的!本質就是在初始化的時候只是生成一個內部類,不進行任何對目標物件進行賦值操作罷了!

獲取操作

我們再觀察一下對於lazyTest變數的訪問操作,從位元組碼看到,每次對變數的獲取都呼叫了LazyClassTest的getLazyTest方法!這個也是編譯器生成的方法,具體可以看到

public final com.example.newtestproject.Test getLazyTest() { aload 0 getfield 'com/example/newtestproject/LazyClassTest.lazyTest$delegate','Lkotlin/Lazy;' astore 1 aload 1 INVOKEINTERFACE kotlin/Lazy.getValue ()Ljava/lang/Object; (itf) checkcast 'com/example/newtestproject/Test' areturn } 天吶!我們越來越接近終點了,首先是通過getfield指令獲取了一個Lkotlin/Lazy變數,這個不就是上面我們賦值的東西嗎!然後呼叫了一個普通的方法getValue就結束了,也就是說,每次對lazyTest變數的訪問,都間接轉發到了一個編譯時生成的內部類中的一個特殊屬性所呼叫的方法!看到這個,讀者可能會思考,既然每次訪問都是呼叫同一個方法,為什麼我們by lazy時宣告的lambad會只執行一次呢?編譯時的位元組碼已經不能給我們帶來答案了,這個因為像java虛擬機器這種,關於具體類的呼叫會在執行時確定這個特性所帶來的(區別於c/cpp)。

執行時的魔法

那好,我們還有最後一個神奇,就是debug,我們最終會發現,在執行時by lazy的呼叫,其實最終都會轉到如下程式碼的執行

``` private class SynchronizedLazyImpl(initializer: () -> T, lock: Any? = null) : Lazy, Serializable { private var initializer: (() -> T)? = initializer @Volatile private var _value: Any? = UNINITIALIZED_VALUE // final field is required to enable safe publication of constructed instance private val lock = lock ?: this

override val value: T
    get() {
        val _v1 = _value
        if (_v1 !== UNINITIALIZED_VALUE) {
            @Suppress("UNCHECKED_CAST")
            return _v1 as T
        }

        return synchronized(lock) {
            val _v2 = _value
            if (_v2 !== UNINITIALIZED_VALUE) {
                @Suppress("UNCHECKED_CAST") (_v2 as T)
            } else {
                val typedValue = initializer!!()
                _value = typedValue
                initializer = null
                typedValue
            }
        }
    }

``` 這個類位於LazyJVM中(kotlin1.5.10),我們就找到最終的祕密了,原來一開始的時候變數就是UNINITIALIZED_VALUE,經過一次賦值操作後,就會變成實際的T所指代的型別,下次再訪問的時候,就直接滿足if條件返回了!所以這就是一次賦值的祕密!還有我們可以看到,預設的by lazy操作第一次賦值時,是採用了synchronized進行了加鎖操作!

總結

我們已經全方位揭祕了by lazy的魔法面紗,相信也對這個語法糖有了自己更深的理解,之所以寫這篇文,是因為好多網上資料要麼是含糊不清要麼是無法解釋本質,這裡作為一個記錄分享

往期更精彩:

想要實現一個hook庫嗎,快看過來https://juejin.cn/post/7100086790639337508