翻譯說明:
原標題: Exploring Kotlin’s hidden costs — Part 1
原文地址: medium.com/@BladeCoder…
原文作者: Christophe Beyls
在2016年,Jake Wharton大神就Java中隱藏效能開銷進行了一系列有趣的討論。大概就在同一時期,他也開始提倡使用Kotlin語言進行Android開發,但除了推薦使用行內函數之外,幾乎沒有提到Kotlin這門語言的其他隱藏效能開銷。既然Kotlin得到Google在AndroidStudio3中的正式支援,我認為通過研究生成的位元組碼來編寫Kotlin程式是一個不錯的方式。
Kotlin是一種現代程式語言,與Java相比它具有更多的語法糖,因此,在編譯器的底層有更多的"黑魔法",然後其中一些操作所帶來的效能開銷是不可忽視的,特別是針對低版本低端的Android裝置程式開發。
當然這並不是針對Kotlin這門語言: 相反,我非常喜歡這門語言,它提高了工作效率,但我也相信一個優秀的開發人員需要知道這門語言內部工作原理,以便於更加高效和明智地使用它的語法特性。Kotlin很強大,就像一句名言說得那樣:
“With great power comes great responsibility.”
這些文章將僅關注Kotlin 1.1之後的JVM / Android實現,而不是Javascript實現。
Kotlin位元組碼檢查器
這是檢視Kotlin程式碼如何轉化為位元組碼的首選工具。在AndroidStudio中安裝好Kotlin外掛後,選擇"Show Kotlin Bytecode"來開啟一個皮膚就會顯示當前類的位元組碼。你還可以點選"Decompile"按鈕來檢視反編譯後對應的Java程式碼
特別是,每次涉及到以下有關Kotlin語法特性,我都會使用到它:
- 原始型別的裝箱,如何分配短期物件
- 例項化程式碼中不直接可見的額外物件
- 生成額外的方法。正如你所知道的那樣,在Android應用程式中,單個dex檔案允許的方法數量是有限的,如果超過限制,一般就需要配置multidex, 但是該方式存在限制和效能的損失。
有關效能基準測試的說明
我特地選擇不釋出任何微基準測試,因為它們中的大多數都是沒有意義的,存在缺陷,或者兩者兼而有之,並且不能應用於所有程式碼變體和執行時環境。當相關程式碼用於迴圈或巢狀迴圈時,通常會造成很大效能開銷。
此外,執行時間不是唯一測量的標準,還必須要考慮分配增加的執行記憶體使用量,因為最終必須回收所有分配的記憶體,垃圾收集的成本取決於許多因素,如可用記憶體和平臺上使用的GC演算法。
簡而言之:如果你想知道Kotlin某些操作是否具有明顯的速度或記憶體影響,請在自己的目標平臺上測量程式碼。
高階函式和Lambda表示式
Kotlin支援將函式賦值給一個變數並把它們作為函式引數傳遞給其他函式。接受其他函式作為引數的函式稱為高階函式
Kotlin函式可以通過它的字首::
的宣告引用,或者直接在程式碼塊內部作為匿名函式宣告,或者使用lambda表示式語法,這是描述函式最緊湊方法。
Kotlin中最具有吸引力語法特性之一就是為Java 6/7 JVM和Android提供lambda表示式的支援。
考慮以下函式例項,該函式在資料庫事務中執行任意操作並返回受影響的行數:
fun transaction(db: Database, body: (Database) -> Int): Int {
db.beginTransaction()
try {
val result = body(db)
db.setTransactionSuccessful()
return result
} finally {
db.endTransaction()
}
}
複製程式碼
我們可以通過使用類似於Groovy的語法將lambda表示式作為最後一個引數傳遞來呼叫此函式:
val deletedRows = transaction(db) {
it.delete("Customers", null, null)
}
複製程式碼
但是Java 6 JVM不直接支援lambda表示式。那麼它們如何轉換為位元組碼?正如你所預料的,lambdas和匿名函式被編譯為Function物件。
Function物件
這是反編譯上面的lambda表示式後的Java程式碼.
class MyClass$myMethod$1 implements Function1 {
// $FF: synthetic method
// $FF: bridge method
public Object invoke(Object var1) {
return Integer.valueOf(this.invoke((Database)var1));//被裝箱成Integer物件,這個下一節會具體講到
}
public final int invoke(@NotNull Database it) {
Intrinsics.checkParameterIsNotNull(it, "it");
return it.delete("Customers", null, null);
}
}
複製程式碼
在你的Android dex檔案中,編譯為Function物件的每個lambda表示式實際上會為總方法計數新增3或4個方法。
值得高興的是這些Function物件的新例項並不是每種情況都會建立,僅在必要的時候建立。所以這就意味著你在實際使用中,需要知道什麼情況下會建立Function物件的新例項以便於給你的程式帶來更好的效能:
- 對於捕獲表示式情況,每次將lambda作為引數傳遞,然後執行後進行垃圾回收,就會每次建立一個新的Function例項;
- 對於非捕獲表示式(也即是純函式)情況,將在下次呼叫期間建立並複用單例函式例項。
由於我們上述的例子呼叫者程式碼使用的是非捕獲lambda,因此它會被編譯為單例而不是內部類。
this.transaction(db, (Function1)MyClass$myMethod$1.INSTANCE);
複製程式碼
如果要呼叫 捕獲lambda 來減少對垃圾收集器的壓力,請避免重複呼叫標準(非內聯)高階函式。
裝箱帶來的效能開銷
與Java8相反的是,Java8大約有43中不同的特殊函式介面,以儘可能避免裝箱和拆箱, 而Kotlin編譯的Function物件僅僅實現完全通用的介面,有效地將Object型別用於任何的輸入或輸出值。
/** A function that takes 1 argument. */
public interface Function1<in P1, out R> : Function<R> {
/** Invokes the function with the specified argument. */
public operator fun invoke(p1: P1): R
}
複製程式碼
這就意味著當函式涉及輸入值或返回值是基本型別(如Int或Long)時,呼叫在高階函式中作為引數傳遞的函式實際上將涉及系統的裝箱和拆箱。這可能會對效能上產生不可忽視的影響,特別是在Android上。
在上面例子編譯的lambda中,你可以看到結果被裝箱到Integer物件。然後,呼叫者程式碼將立即將其拆箱。
在編寫涉及使用基本型別作為輸入或輸出值的引數函式的標準(非內聯)高階函式時要小心。反覆呼叫此引數函式將通過裝箱和拆箱操作對垃圾收集器施加更大的壓力。
行內函數來拯救
值得慶幸的是,Kotlin提供了一個很好的語法技巧,可以避免在使用lambda表示式時帶來的額外效能開銷: 將高階函式宣告成 內聯. 這講使得編譯器直接執行呼叫者程式碼中行內函數體中程式碼,完全避免了呼叫帶來的開銷。對於高階函式,其好處甚至更大,因為作為引數傳遞的lambda表示式的主體也將被內聯。實際效果如下:
- 宣告lambda表示式時,不會例項化Function物件
- 沒有裝箱或拆箱操作將應用於基於原始型別的lambda輸入和輸出值
- 沒有方法將新增到總方法計數中
- 不會執行實際的函式呼叫,這可以提高對此使用該函式帶來的CPU佔用效能
在將transaction()函式宣告為內聯之後,我們的呼叫者程式碼的Java高效實現如下:
db.beginTransaction();
try {
int result$iv = db.delete("Customers", null, null);
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
複製程式碼
然後使用這個殺手鐗級別功能時,有一些地方需要注意:
- 行內函數不能直接呼叫自身或通過其他行內函數呼叫自身
- 在類中宣告公有的行內函數只能訪問該類的公有函式和成員變數
- 程式碼的大小會增加。內聯多次引用程式碼較長的函式可以使生成的程式碼更大,如果這個程式碼較長的函式本身引用其他程式碼較長的行內函數,則會更多。
如果可能,將高階函式宣告為行內函數。保持簡短,如果需要,將大段程式碼移動到非行內函數。 你還可以內聯從程式碼的效能關鍵部分呼叫的函式。
我們將在以後的文章中討論行內函數的其他效能優勢
伴生物件
Kotlin類中已經沒有了靜態欄位或方法。取而代之的是在類的伴生物件中宣告與例項無關的欄位和方法.
從其伴生物件訪問私有類欄位
不妨看下這個例子:
class MyClass private constructor() {
private var hello = 0
companion object {
fun newInstance() = MyClass()
}
}
複製程式碼
編譯時,伴生物件會被實現為單例類。這意味著就像任何需要從其他類訪問其私有欄位的Java類一樣,從伴隨物件訪問外部類的私有欄位(或建構函式)將生成其他合成getter和setter方法。對類欄位的每次讀取或寫入訪問都將導致伴隨物件中的靜態方法呼叫。
ALOAD 1
INVOKESTATIC be/myapplication/MyClass.access$getHello$p (Lbe/myapplication/MyClass;)I
ISTORE 2
複製程式碼
在Java中,我們可以通過使用package
可見性來避免生成這些方法。然後在Kotlin中的沒有package
可見性。使用public
和internal
可見性將會導致Kotlin生成預設的getter和setter例項方法,以致於外部可以訪問到這些欄位,然而呼叫例項方法在技術上往往比呼叫靜態方法更為昂貴。
如果需要從伴生物件重複讀取或寫入類欄位,則可以將其值快取在區域性變數中,以避免重複的隱藏方法呼叫。
訪問伴生物件中宣告的常量
在Kotlin中,您通常會在伴生物件內宣告您在類中使用的“靜態”常量。
class MyClass {
companion object {
private val TAG = "TAG"
}
fun helloWorld() {
println(TAG)
}
}
複製程式碼
上述程式碼雖然看起來簡潔明瞭,但是底層實現執行操作就十分的難看。
出於同樣的原因,訪問伴生物件中宣告的私有常量實際上會在伴生物件實現類中生成一個額外的合成getter方法。
GETSTATIC be/myapplication/MyClass.Companion : Lbe/myapplication/MyClass$Companion;
INVOKESTATIC be/myapplication/MyClass$Companion.access$getTAG$p (Lbe/myapplication/MyClass$Companion;)Ljava/lang/String;
ASTORE 1
複製程式碼
但是更糟糕的是,合成方法實際上不會返回值,它呼叫的是一個Kotlin生成的getter例項方法.
ALOAD 0
INVOKESPECIAL be/myapplication/MyClass$Companion.getTAG ()Ljava/lang/String;
ARETURN
複製程式碼
當常量宣告為public而不是private時,此getter方法是公共的並且可以直接呼叫,因此不需要上一步的合成方法。但是Kotlin仍然需要呼叫getter方法來讀取常量.
那麼,我們結束了嗎?沒有! 事實證明,為了儲存常量值,Kotlin編譯器是在主類級別中而不是在伴生物件內生成實際的private static final
的欄位。但是,因為靜態欄位在類中宣告為private,所以需要另一種合成方法來從伴生物件中訪問它。
INVOKESTATIC be/myapplication/MyClass.access$getTAG$cp ()Ljava/lang/String;
ARETURN
複製程式碼
並且該合成方法最後讀取實際值:
GETSTATIC be/myapplication/MyClass.TAG : Ljava/lang/String;
ARETURN
複製程式碼
換句話說,當你從Kotlin類訪問伴生物件中的私有常量欄位時,而不是像Java那樣直接讀取靜態欄位,程式碼實際上是:
- 在伴生物件中呼叫靜態方法
- 它將依次呼叫伴隨物件中的例項方法
- 然後反過來呼叫類中的靜態方法
- 讀取靜態欄位並返回其值
public final class MyClass {
private static final String TAG = "TAG";
public static final Companion companion = new Companion();
// synthetic
public static final String access$getTAG$cp() {
return TAG;
}
public static final class Companion {
private final String getTAG() {
return MyClass.access$getTAG$cp();
}
// synthetic
public static final String access$getTAG$p(Companion c) {
return c.getTAG();
}
}
public final void helloWorld() {
System.out.println(Companion.access$getTAG$p(companion));
}
}
複製程式碼
那麼我們可以獲得更輕量級的位元組碼嗎?是的,但不是在所有情況下。
首先,通過使用const關鍵字將值宣告為編譯時常量,可以完全避免任何方法呼叫。這將直接在呼叫程式碼中有效地內聯值,但是只能將它用於原始型別和字串。
class MyClass {
companion object {
private const val TAG = "TAG"
}
fun helloWorld() {
println(TAG)
}
}
複製程式碼
其次,可以在伴生物件的公共欄位上使用@JvmField註解,以指示編譯器不生成任何getter或setter,並將其作為類中的靜態欄位公開,就像純Java常量一樣。事實上,這個註解是出於Java相容性問題才建立的,如果你不需要從Java程式碼中訪問你這個常量,我不建議你使用這個互操作註解來混亂優雅的Kotlin程式碼。此外,它只能用於公有欄位。在Android開發環境中,可能只會使用此註解來實現Parcelable
物件:
class MyClass() : Parcelable {
companion object {
@JvmField
val CREATOR = creator { MyClass(it) }
}
private constructor(parcel: Parcel) : this()
override fun writeToParcel(dest: Parcel, flags: Int) {}
override fun describeContents() = 0
}
複製程式碼
最後,你還可以使用ProGuard工具優化位元組碼,並希望它將這些鏈式方法呼叫合併在一起,但是無法絕對保證這起到理想中的作用。
從伴隨物件中讀取“靜態”常量,與Java相比,在Kotlin中增加了兩到三個額外的間接級別,並且將為這些常量中的每一個生成兩到三個額外的方法。
1、始終使用const關鍵字宣告基本型別和字串常量以避免這種情況
2、對於其他型別的常量,不能使用const,因此如果需要重複訪問常量,可能需要將值快取在區域性變數中
3、此外,更推薦將公有的全域性常量儲存在它們自己的物件中而不是伴隨物件中。
這就是第一篇文章的全部內容。希望這能讓你更好地理解使用這些Kotlin功能的含義。請記住這一點,以便編寫更智慧高效的程式碼,而不會犧牲可讀性和效能。
歡迎繼續閱讀本系列的Part 2: local functions, null safety and varargs.
譯者有話說
關於探索Kotlin中隱藏的效能開銷這一系列文章,早在去年就拜讀過了,一直小心收藏著,希望有時間能把它翻譯出來分享給大家,然後一直沒有時間去做這件事,但是卻一直在TODO List清單中。
關於這個系列文章不管你是Kotlin初學小白,還是有一定基礎的Kotlin開發者都是對你有好處的,因為它在教你如何寫出更優雅效能更高效的Kotlin程式碼,並從原理上帶你分析某些操作不當會導致額外的效能開銷。這篇文章原文還有兩篇,後面會繼續翻譯出來,分享出來給大家。
前方高能預警,即將迎來一波贈書福利
說真的,最近工作真的特別忙,本沒有時間寫文章。但是上週華章主編郭老師找到我,能否再寫篇文章搞個贈書活動,一想到上次給大家的贈書名額本就不多,很多人都沒拿到,所以擠出這週末的時間來寫寫,並把之前壓箱底TODO List這一系列文章翻譯出來分享給大家,二來又能給大家帶來贈書福利。
如何參與贈書福利
歡迎關注Kotlin開發者聯盟,這裡有最新Kotlin技術文章和Kotlin相關書籍贈書活動
老規矩在公眾號本篇文章中留言點贊數最多排名作為贈書物件,這次贈書書籍還是上次我推薦的《Kotlin核心程式設計》,贈書活動截止時間是7月10號晚上8點公佈名單