天天看點

Kotlin | 這些隐藏的記憶體陷阱,你應該熟記于心

引言

​Kotlin​

​ 是一個非常 yes 的語言,從 null安全 ,支援 方法擴充 與 屬性擴充,到 内聯方法、内聯類 等,使用Kotlin變得越來越簡單舒服。但程式設計從來不是一件簡單的工作,所有簡潔都是建立在複雜的底層實作上。那些看似簡單的kt代碼,内部往往隐藏着不容忽視的記憶體開銷。

介于此,本篇将根據個人開發經驗,聊一聊 ​

​Kotlin​

​ 中那些隐藏的記憶體陷阱,也希望每一個同學都能在 性能 與 優雅 之間找到合适的平衡。

本篇定位簡單🔖,主要通過示例+相應位元組碼分析的方式,對日常開發非常有幫助。

導航

學完本篇,你将了解到以下内容:

  1. 密封類構造函數傳值的使用細節;
  2. 内聯函數,你應該注意的地方;
  3. 伴生對象隐藏的性能問題;
  4. ​lazy​

    ​, 沒你想的那麼簡單;
  5. ​apply​

    ​!= 建構者模式;
  6. 關于 ​

    ​arrayOf()​

    ​ 的使用細節。

好了,讓我們開始吧! 🐊

密封類的小細節

密封類用來表示受限的類繼承結構:當一個值為有限幾種的類型、而不能有任何其他類型時。在某種意義上,他們是枚舉類的擴充:枚舉類型的值集合也是受限的,但每個枚舉常量隻存在一個執行個體,而密封類的一個子類可以有可包含狀态的多個執行個體。摘自Kotlin中文文檔

關于它用法,我們具體不再做贅述。

密封類雖然非常實用,經常能成為我們多type的絕佳搭配,但其中卻藏着一些使用的小細節,比如 構造函數傳值所導緻的損耗問題。

錯誤示例

Kotlin | 這些隐藏的記憶體陷阱,你應該熟記于心

如題, 我們有一個公用的屬性 ​

​sum​

​​ ,為了便于複用,我們将其抽離到 ​

​Fruit​

​ 類構造函數中,讓子類便于初始化時傳入,而不用重複顯式聲明。

上述代碼看着似乎沒什麼問題?按照傳統的操作習慣,我們也很容易寫出這種代碼。

如果我們此時來看一下位元組碼:

Kotlin | 這些隐藏的記憶體陷阱,你應該熟記于心

不難發現,無論是子類Apple還是父類Fruit,他們都生成了 ​

​getSum()​

​​ 與 ​

​setSum()​

​​ 方法 與 ​

​sum​

​​ 字段,而且,父類的 ​

​sum​

​ 完全處于浪費階段,我們根本沒法用到。😵‍💫

顯然這并不是我們願意看到的,我們接下來對其進行改造一下。

改造實踐

我們對上述示例進行稍微改造,如下所示:

Kotlin | 這些隐藏的記憶體陷阱,你應該熟記于心

如題,我們将sum變量定義為了一個抽象變量,進而讓子類自行實作。對比位元組碼可以發現,相比最開始的示例,我們的父類 ​

​Fruit​

​​ 中減少了一個 ​

​sum​

​ 變量的損耗。

那有沒有方法能不能把 ​

​getsum()​

​​ 和 ​

​setSum()​

​ 也一起移除呢?🙅‍♂️

答案是可以,我們利用 接口 改造即可,如下所示:

Kotlin | 這些隐藏的記憶體陷阱,你應該熟記于心

如上所示,我們增加了一個名為 ​

​IFruit​

​ 的接口,并讓 密封父類 實作了這個接口,子類預設在構造函數中實作該屬性即可。

觀察位元組碼可發現,我們的父類一幹二淨,無論是從包大小還是性能,我們都避免了沒必要的損耗。

内聯很好,但别太長

​inline​

​ ,翻譯過來為 内聯 ,在 ​

​Kotlin​

​​ 中,一般建議用于 ​

​高階函數​

​ 中,目的是用來彌補其運作時的 額外開銷。

其原理也比較簡單,在調用時将我們的代碼移動到調用處使用,進而降低方法調用時的 棧幀 層級。

棧幀: 指的是虛拟機在進行方法調用和方法執行時的資料結構,每一個棧幀裡都包含了相應的資料,比如 局部參數,操作數棧等等。

Jvm在執行方法時,每執行一個方法會産生一個棧幀,随後将其儲存到我們目前線程所對應的棧裡,方法執行完畢時再将此方法出棧,

是以内聯後就相當于省了一個棧幀調用。

如果上述描述中,你隻記住了後半句,降低棧幀 ,那麼此時你可能已經陷入了一個使用陷阱?

錯誤示例

如下截圖中所示,我們随便建立了一個方法,并增加了 ​

​inline​

​ 關鍵字:

Kotlin | 這些隐藏的記憶體陷阱,你應該熟記于心

觀察截圖會發現,此時IDE已經給出了提示,它建議你移除 ​

​inline​

​ , Why? 為什麼呢?🥲

不是說内聯可以提高性能嗎,那麼不應該任何方法都應該加 ​

​inline​

​ 提高性能嗎?(就是這麼倔強🤌🏼)

上面我們提到了,内聯是會将代碼移動到調用處,降低 一層棧幀,但這個性能提升真的大嗎?

再仔細想想,移動到調用處,移動到調用處。這是什麼概念呢?

假設我們某個方法裡代碼隻有兩行(我想不會有人會某個方法隻有一行吧🥲),這個方法又被好幾處調用,内聯是提高了調用性能,畢竟節省了一次棧幀,再加上方法行數少(暫時抛棄虛拟機優化這個底層條件)。

但如果方法裡代碼有幾十行?每次調用都會把代碼内聯過來,那調用處豈不💥,帶來的包大小影響某種程度上要比内聯成本更高😵‍💫!

如下圖所示,我們對上述示例做一個論證:

Kotlin | 這些隐藏的記憶體陷阱,你應該熟記于心
Jvm: 我謝謝你。

推薦示例

我們在文章最開始提到了,Kotlin ​

​inline​

​​ ,一般建議用于 ​

​高階函數(lambda)​

​ 中。為什麼呢?

如下示例:

Kotlin | 這些隐藏的記憶體陷阱,你應該熟記于心

轉成位元組碼後,可以發現,​

​tryKtx()​

​​ 被建立為了一個匿名内部類 ​

​(Simple$test|1)​

​ 。每次調用時,相當于需要建立匿名類的執行個體對象,進而導緻二次調用的性能損耗。

那如果我們給其增加 ​

​inline​

​ 呢?🤖,反編譯後相應的 java代碼 如下:

Kotlin | 這些隐藏的記憶體陷阱,你應該熟記于心

具體對比圖如上所示,不難發現,我們的調用處已經被替換為原方法,相應的 ​

​lambda​

​ 也被消除了,進而顯著減少了性能損耗。

Tips

如果檢視官方庫相應的代碼,如下所示,比如 ​

​with​

​ :

Kotlin | 這些隐藏的記憶體陷阱,你應該熟記于心

不難發現,​

​inline​

​ 的大多數場景僅且在 高階函數 并且 方法行數較短 時适用。因為對于普通方法,jvm本身對其就會進行優化,是以 ​

​inline​

​ 在普通方法上的的意義幾乎聊勝于無。

總結如下:

  • 因為内聯函數會将方法函數移動到調用處,會增加調用處的代碼量,是以對于較長的方法應該避免使用;
  • 内聯函數應該用于使用了 高階函數(lambda) 的方法,而不是普通方法。

伴生對象,也許真的不需要

在 ​

​Kotlin​

​​ 中,我們不能像 ​

​Java​

​​ 一樣,随便定義一個靜态方法或者靜态屬性。此時 ​

​companion object​

​(伴生對象)就會派上用場。

我們常常會用于定義一個 ​

​key​

​​ 或者 ​

​TAG​

​​ ,類似于我們在 ​

​Java​

​​ 中定義一個靜态的 ​

​Key​

​。其使用起來也很簡單,如下所示:

class Book {
    companion object {
        val SUM_MAX: Int = 13
    }
}      

這是一段普通的代碼,我們在 ​

​Book​

​ 類中增加了一個伴生對象,其中有一個靜态的字段 SUM_MAX。

上述代碼看着似乎沒什麼問題,但如果我們将其轉為位元組碼後再看一看:

Kotlin | 這些隐藏的記憶體陷阱,你應該熟記于心

不難發現,僅僅隻是想增加一個 靜态變量 ,結果憑空增加了一個 靜态對象 以及多增加了 get() 方法,這個成本可能遠超出一個 靜态參數 的價值。

const

抛開前者不談(靜态對象),那麼我們有沒有什麼方法能讓編譯器少生成一個 ​

​get()​

​ 方法呢(非private)?

注意觀察IDE提示,IDE會建議我們增加一個 ​

​const​

​ 的參數,如下所示:

companion object {
    const val SUM_MAX: Int = 13
}      

增加了 ​

​const​

​​ 後,相應的 ​

​get()​

​​ 方法也會消失掉,進而節省了一個 ​

​get()​

​ 方法。

​const​

​​,在 ​

​Kotlin​

​​ 中,用于修飾編譯時已知的 ​

​val​

​(隻讀,類似final) 标注的屬性。
  • 隻能用于頂層的class中,比如 ​

    ​object class​

    ​​ 或者 ​

    ​companion object​

    ​;
  • 隻能用于基本類型;
  • 不會生成get()方法。

JvmField

如果我們 某個字段不是 ​

​val​

​​ 标注呢,其是 ​

​var​

​ (可變)修飾的呢,并且這個字段要對外暴漏(非private)。

此時不難猜測,相應的位元組碼後肯定會同時生成 set與get 方法。

此時就可以使用 ​

​@JvmField​

​ 來進行修飾。

如下所示:

class Book {
    companion object {
        @JvmField
        var sum: Int = 0
    }
}      

相應的位元組碼如下:

Kotlin | 這些隐藏的記憶體陷阱,你應該熟記于心

Tips

讓我們再回到伴生對象本身,我們真的一定需要它嗎?

對于和業務強關聯的 ​

​key​

​​ 或者 ​

​TAG​

​​ ,可以選擇使用伴生對象,并為其增加 ​

​const val​

​,此時語義上的清晰比記憶體上的損耗更加重要,特别在複雜的業務背景下。

但如果僅用于儲存一些key,那麼完全可以使用 ​

​object Class​

​ 替代,如下所示,将其回歸到一個類中:

object Keys {
    const val DEFAULT_SUM = 10
    const val DEFAULT_MIN = 1
    const val LOGIN_KEY = 99
}      

Apply!=構造者模式

​apply​

​​ 作為開發中的常客,為我們帶來了不少便利。其内部實作也非常簡單,将我們的對象以函數的形式傳回,​

​this​

​ 作為接收者。進而以一種優雅的方式實作對對象方法、屬性的調用。

但經常會看到有不少同學在構造者模式中寫出以下代碼,使用 ​

​apply​

​ 直接作為傳回值,這種方式固然看着優雅,性能也幾乎沒有差别。但這種場景而言,如果我們注意到其位元組碼,會發現其并不是最佳之選。

示例

Kotlin | 這些隐藏的記憶體陷阱,你應該熟記于心

如題,我們存在一個示例Builder,并在其中添加了兩個方法,即 addTitle(),與 addSecondTitle() 。後者以 ​

​apply​

​​ 作為傳回值,代碼可讀性非常好,相比前者,在 ​

​kotlin​

​ 中其顯得非常優雅。

但如果我們去看一眼位元組碼呢?

Kotlin | 這些隐藏的記憶體陷阱,你應該熟記于心

如上所示,使用了 ​

​apply​

​ 後,我們的位元組碼中增加了多餘步驟,相比不使用的,包大小會有一點影響,性能上幾乎毫無差距。

Tips

​apply​

​​ 很好用,但需要區分場景。其可以改善我們在 ​

​kotlin​

​ 語義下的程式設計體驗,但同時也不是任何場景都需要其。

如果你的方法中需要對某個對象操作多次,比如調用其方法或者屬性,那麼此時可以使用 ​

​apply​

​​ ,反之,如果次數過少,其實你并不需要 ​

​apply​

​ 的優雅。

警惕,lazy 的使用方式

​lazy​

​,中文譯名為延遲初始化,顧名思義,用于延遲初始化一些資訊。

作用也相對直接,如果我們有某個對象或字段,我們可能隻想使用時再初始化,此時就可以先聲明,等到使用時再去初始化,并且這個初始化過程預設也是線程安全(不特定使用NONE)。這樣的好處就是性能優勢,我們不必應用或者頁面加載時就初始化一切,相比過往的 var xx = null ,這種方式一定程度上也更加便捷。

相應的,lazy一共有三種模式,即:

  • ​SYNCHRONIZED​

    ​(同步鎖,預設實作)
  • ​PUBLICATION​

    ​(CAS)
  • ​PUBLICATION​

    ​(不作處理)

​lazy​

​​ 雖然使用簡單,但在 ​

​Android​

​​ 的開發背景下,​

​lazy​

​ 經常容易使用不當🤦🏻‍♂️,也是以常常會出現為了[便利] 而造成的性能隐患。

示例如下:

Kotlin | 這些隐藏的記憶體陷阱,你應該熟記于心

如上所示,我們延遲初始化了一個點選事件,友善在 ​

​onCreate()​

​ 中進行設定 點選事件 以及後續複用。

上述示例雖然看着似乎沒什麼問題。但放在這樣的場景下,這個 ​

​mClickListener​

​ 本身的意義也許并不大。為什麼這樣說?

  1. 上述使用了 預設的lazy ,即同步鎖,而Android預設線程為 ​

    ​UI線程​

    ​​ ,目前操作方法又是 ​

    ​onCreate()​

    ​ ,即目前本身就是線程安全。此時依然使用 lazy(sys) ,即浪費了一定初始化性能。
  2. MainActivity初始化時,會先在 構造函數 中初始化 ​

    ​lazy​

    ​​ 對象,即 ​

    ​SYNCHRONIZED​

    ​​ 對應的 ​

    ​SynchronizedLazyImpl​

    ​。也就是說,我們一開始就已經多生成了一個對象。然後僅僅是為了一個點選事件,内部又會進行包裝一次。

相似的場景有很多,如果你的lazy是用于 Android生命周期元件 ,再加上本身會在 ​

​onCreate()​

​ 等中進行調用,那麼很可能完全沒有必要延遲初始化。

關于 arrayOf() 的使用細節

對于 ​

​arrayOf​

​ ,我們一般經常用于初始化一個數組,但其也隐藏着一些使用細節。

通常來說,對于基本類型的數組,建議使用預設已提供的函數比如,​

​intArrayOf()​

​ 等等,進而便于提升性能。

至于原因,我們下面來分析,如下所示:

fun test() {
    arrayOf(1, 2, 3)
}

fun testNoInteger() {
    intArrayOf(1, 2, 3)
}      

我們提供了兩個方法,前者是預設方法,後者是帶優化的方法,具體位元組碼如下:

Kotlin | 這些隐藏的記憶體陷阱,你應該熟記于心

如題,不難發現,前者使用的是 ​

​java​

​ 中的 包裝類型 ,使用時還需要經曆 拆箱 與 裝箱 ,而後者是非包裝類型,進而免除了這一操作,進而節省性能。

什麼是裝箱與拆箱?

背景:Java 中,萬物皆對象,而八大基本類型不是對象,是以 Java 為每種基本類型都提供了相應的包裝類型。

裝箱就是指将基本類型轉為包裝類型,拆箱則是将包裝類型轉為基本類型。

總結

本篇中,我們以日常開發的視角,去探尋了 ​

​Kotlin​

​ 中那些 [隐藏] 的記憶體陷阱。

仔細回想,上述的不恰當用法都是建立在 [不熟練] 的背景下。​

​Kotlin​

​ 本身的各種便利沒有任何問題,其使得我們的 代碼可讀性 與 開發舒适度 增強了太多。但如果同時,我們還能注意到其背後的實作,也是不是就能在 性能與優雅 之間找到了一種平衡。