引言
Kotlin
是一個非常 yes 的語言,從 null安全 ,支援 方法擴充 與 屬性擴充,到 内聯方法、内聯類 等,使用Kotlin變得越來越簡單舒服。但程式設計從來不是一件簡單的工作,所有簡潔都是建立在複雜的底層實作上。那些看似簡單的kt代碼,内部往往隐藏着不容忽視的記憶體開銷。
介于此,本篇将根據個人開發經驗,聊一聊
Kotlin
中那些隐藏的記憶體陷阱,也希望每一個同學都能在 性能 與 優雅 之間找到合适的平衡。
本篇定位簡單🔖,主要通過示例+相應位元組碼分析的方式,對日常開發非常有幫助。
導航
學完本篇,你将了解到以下内容:
- 密封類構造函數傳值的使用細節;
- 内聯函數,你應該注意的地方;
- 伴生對象隐藏的性能問題;
-
, 沒你想的那麼簡單;
lazy
-
!= 建構者模式;
apply
- 關于
的使用細節。
arrayOf()
好了,讓我們開始吧! 🐊
密封類的小細節
密封類用來表示受限的類繼承結構:當一個值為有限幾種的類型、而不能有任何其他類型時。在某種意義上,他們是枚舉類的擴充:枚舉類型的值集合也是受限的,但每個枚舉常量隻存在一個執行個體,而密封類的一個子類可以有可包含狀态的多個執行個體。摘自Kotlin中文文檔
關于它用法,我們具體不再做贅述。
密封類雖然非常實用,經常能成為我們多type的絕佳搭配,但其中卻藏着一些使用的小細節,比如 構造函數傳值所導緻的損耗問題。
錯誤示例
如題, 我們有一個公用的屬性
sum
,為了便于複用,我們将其抽離到
Fruit
類構造函數中,讓子類便于初始化時傳入,而不用重複顯式聲明。
上述代碼看着似乎沒什麼問題?按照傳統的操作習慣,我們也很容易寫出這種代碼。
如果我們此時來看一下位元組碼:
不難發現,無論是子類Apple還是父類Fruit,他們都生成了
getSum()
與
setSum()
方法 與
sum
字段,而且,父類的
sum
完全處于浪費階段,我們根本沒法用到。😵💫
顯然這并不是我們願意看到的,我們接下來對其進行改造一下。
改造實踐
我們對上述示例進行稍微改造,如下所示:
如題,我們将sum變量定義為了一個抽象變量,進而讓子類自行實作。對比位元組碼可以發現,相比最開始的示例,我們的父類
Fruit
中減少了一個
sum
變量的損耗。
那有沒有方法能不能把
getsum()
和
setSum()
也一起移除呢?🙅♂️
答案是可以,我們利用 接口 改造即可,如下所示:
如上所示,我們增加了一個名為
IFruit
的接口,并讓 密封父類 實作了這個接口,子類預設在構造函數中實作該屬性即可。
觀察位元組碼可發現,我們的父類一幹二淨,無論是從包大小還是性能,我們都避免了沒必要的損耗。
内聯很好,但别太長
inline
,翻譯過來為 内聯 ,在
Kotlin
中,一般建議用于
高階函數
中,目的是用來彌補其運作時的 額外開銷。
其原理也比較簡單,在調用時将我們的代碼移動到調用處使用,進而降低方法調用時的 棧幀 層級。
棧幀: 指的是虛拟機在進行方法調用和方法執行時的資料結構,每一個棧幀裡都包含了相應的資料,比如 局部參數,操作數棧等等。
Jvm在執行方法時,每執行一個方法會産生一個棧幀,随後将其儲存到我們目前線程所對應的棧裡,方法執行完畢時再将此方法出棧,
是以内聯後就相當于省了一個棧幀調用。
如果上述描述中,你隻記住了後半句,降低棧幀 ,那麼此時你可能已經陷入了一個使用陷阱?
錯誤示例
如下截圖中所示,我們随便建立了一個方法,并增加了
inline
關鍵字:
觀察截圖會發現,此時IDE已經給出了提示,它建議你移除
inline
, Why? 為什麼呢?🥲
不是說内聯可以提高性能嗎,那麼不應該任何方法都應該加 inline
提高性能嗎?(就是這麼倔強🤌🏼)
上面我們提到了,内聯是會将代碼移動到調用處,降低 一層棧幀,但這個性能提升真的大嗎?
再仔細想想,移動到調用處,移動到調用處。這是什麼概念呢?
假設我們某個方法裡代碼隻有兩行(我想不會有人會某個方法隻有一行吧🥲),這個方法又被好幾處調用,内聯是提高了調用性能,畢竟節省了一次棧幀,再加上方法行數少(暫時抛棄虛拟機優化這個底層條件)。
但如果方法裡代碼有幾十行?每次調用都會把代碼内聯過來,那調用處豈不💥,帶來的包大小影響某種程度上要比内聯成本更高😵💫!
如下圖所示,我們對上述示例做一個論證:
Jvm: 我謝謝你。
推薦示例
我們在文章最開始提到了,Kotlin
inline
,一般建議用于
高階函數(lambda)
中。為什麼呢?
如下示例:
轉成位元組碼後,可以發現,
tryKtx()
被建立為了一個匿名内部類
(Simple$test|1)
。每次調用時,相當于需要建立匿名類的執行個體對象,進而導緻二次調用的性能損耗。
那如果我們給其增加
inline
呢?🤖,反編譯後相應的 java代碼 如下:
具體對比圖如上所示,不難發現,我們的調用處已經被替換為原方法,相應的
lambda
也被消除了,進而顯著減少了性能損耗。
Tips
如果檢視官方庫相應的代碼,如下所示,比如
with
:
不難發現,
inline
的大多數場景僅且在 高階函數 并且 方法行數較短 時适用。因為對于普通方法,jvm本身對其就會進行優化,是以
inline
在普通方法上的的意義幾乎聊勝于無。
總結如下:
- 因為内聯函數會将方法函數移動到調用處,會增加調用處的代碼量,是以對于較長的方法應該避免使用;
- 内聯函數應該用于使用了 高階函數(lambda) 的方法,而不是普通方法。
伴生對象,也許真的不需要
在
Kotlin
中,我們不能像
Java
一樣,随便定義一個靜态方法或者靜态屬性。此時
companion object
(伴生對象)就會派上用場。
我們常常會用于定義一個
key
或者
TAG
,類似于我們在
Java
中定義一個靜态的
Key
。其使用起來也很簡單,如下所示:
class Book {
companion object {
val SUM_MAX: Int = 13
}
}
這是一段普通的代碼,我們在
Book
類中增加了一個伴生對象,其中有一個靜态的字段 SUM_MAX。
上述代碼看着似乎沒什麼問題,但如果我們将其轉為位元組碼後再看一看:
不難發現,僅僅隻是想增加一個 靜态變量 ,結果憑空增加了一個 靜态對象 以及多增加了 get() 方法,這個成本可能遠超出一個 靜态參數 的價值。
const
抛開前者不談(靜态對象),那麼我們有沒有什麼方法能讓編譯器少生成一個
get()
方法呢(非private)?
注意觀察IDE提示,IDE會建議我們增加一個
const
的參數,如下所示:
companion object {
const val SUM_MAX: Int = 13
}
增加了
const
後,相應的
get()
方法也會消失掉,進而節省了一個
get()
方法。
,在
const
中,用于修飾編譯時已知的
Kotlin
(隻讀,類似final) 标注的屬性。
val
- 隻能用于頂層的class中,比如
或者
object class
;
companion object
- 隻能用于基本類型;
- 不會生成get()方法。
JvmField
如果我們 某個字段不是
val
标注呢,其是
var
(可變)修飾的呢,并且這個字段要對外暴漏(非private)。
此時不難猜測,相應的位元組碼後肯定會同時生成 set與get 方法。
此時就可以使用
@JvmField
來進行修飾。
如下所示:
class Book {
companion object {
@JvmField
var sum: Int = 0
}
}
相應的位元組碼如下:
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
直接作為傳回值,這種方式固然看着優雅,性能也幾乎沒有差别。但這種場景而言,如果我們注意到其位元組碼,會發現其并不是最佳之選。
示例
如題,我們存在一個示例Builder,并在其中添加了兩個方法,即 addTitle(),與 addSecondTitle() 。後者以
apply
作為傳回值,代碼可讀性非常好,相比前者,在
kotlin
中其顯得非常優雅。
但如果我們去看一眼位元組碼呢?
如上所示,使用了
apply
後,我們的位元組碼中增加了多餘步驟,相比不使用的,包大小會有一點影響,性能上幾乎毫無差距。
Tips
apply
很好用,但需要區分場景。其可以改善我們在
kotlin
語義下的程式設計體驗,但同時也不是任何場景都需要其。
如果你的方法中需要對某個對象操作多次,比如調用其方法或者屬性,那麼此時可以使用
apply
,反之,如果次數過少,其實你并不需要
apply
的優雅。
警惕,lazy 的使用方式
lazy
,中文譯名為延遲初始化,顧名思義,用于延遲初始化一些資訊。
作用也相對直接,如果我們有某個對象或字段,我們可能隻想使用時再初始化,此時就可以先聲明,等到使用時再去初始化,并且這個初始化過程預設也是線程安全(不特定使用NONE)。這樣的好處就是性能優勢,我們不必應用或者頁面加載時就初始化一切,相比過往的 var xx = null ,這種方式一定程度上也更加便捷。
相應的,lazy一共有三種模式,即:
-
(同步鎖,預設實作)SYNCHRONIZED
-
(CAS)PUBLICATION
-
(不作處理)PUBLICATION
lazy
雖然使用簡單,但在
Android
的開發背景下,
lazy
經常容易使用不當🤦🏻♂️,也是以常常會出現為了[便利] 而造成的性能隐患。
示例如下:
如上所示,我們延遲初始化了一個點選事件,友善在
onCreate()
中進行設定 點選事件 以及後續複用。
上述示例雖然看着似乎沒什麼問題。但放在這樣的場景下,這個
mClickListener
本身的意義也許并不大。為什麼這樣說?
- 上述使用了 預設的lazy ,即同步鎖,而Android預設線程為
,目前操作方法又是
UI線程
,即目前本身就是線程安全。此時依然使用 lazy(sys) ,即浪費了一定初始化性能。
onCreate()
- MainActivity初始化時,會先在 構造函數 中初始化
對象,即
lazy
對應的
SYNCHRONIZED
。也就是說,我們一開始就已經多生成了一個對象。然後僅僅是為了一個點選事件,内部又會進行包裝一次。
SynchronizedLazyImpl
相似的場景有很多,如果你的lazy是用于 Android生命周期元件 ,再加上本身會在
onCreate()
等中進行調用,那麼很可能完全沒有必要延遲初始化。
關于 arrayOf() 的使用細節
對于
arrayOf
,我們一般經常用于初始化一個數組,但其也隐藏着一些使用細節。
通常來說,對于基本類型的數組,建議使用預設已提供的函數比如,
intArrayOf()
等等,進而便于提升性能。
至于原因,我們下面來分析,如下所示:
fun test() {
arrayOf(1, 2, 3)
}
fun testNoInteger() {
intArrayOf(1, 2, 3)
}
我們提供了兩個方法,前者是預設方法,後者是帶優化的方法,具體位元組碼如下:
如題,不難發現,前者使用的是
java
中的 包裝類型 ,使用時還需要經曆 拆箱 與 裝箱 ,而後者是非包裝類型,進而免除了這一操作,進而節省性能。
什麼是裝箱與拆箱?
背景:Java 中,萬物皆對象,而八大基本類型不是對象,是以 Java 為每種基本類型都提供了相應的包裝類型。
裝箱就是指将基本類型轉為包裝類型,拆箱則是将包裝類型轉為基本類型。
總結
本篇中,我們以日常開發的視角,去探尋了
Kotlin
中那些 [隐藏] 的記憶體陷阱。
仔細回想,上述的不恰當用法都是建立在 [不熟練] 的背景下。
Kotlin
本身的各種便利沒有任何問題,其使得我們的 代碼可讀性 與 開發舒适度 增強了太多。但如果同時,我們還能注意到其背後的實作,也是不是就能在 性能與優雅 之間找到了一種平衡。