[譯]探索Kotlin中隱藏的性能開銷-Part 1

翻譯說明:html

原標題: Exploring Kotlin’s hidden costs — Part 1java

原文地址: medium.com/@BladeCoder…android

原文做者: Christophe Beyls算法

在2016年,Jake Wharton大神就Java中隱藏性能開銷進行了一系列有趣的討論。大概就在同一時期,他也開始提倡使用Kotlin語言進行Android開發,但除了推薦使用內聯函數以外,幾乎沒有提到Kotlin這門語言的其餘隱藏性能開銷。既然Kotlin獲得Google在AndroidStudio3中的正式支持,我認爲經過研究生成的字節碼來編寫Kotlin程序是一個不錯的方式。數據庫

Kotlin是一種現代編程語言,與Java相比它具備更多的語法糖,所以,在編譯器的底層有更多的"黑魔法",而後其中一些操做所帶來的性能開銷是不可忽視的,特別是針對低版本低端的Android設備程序開發。express

固然這並非針對Kotlin這門語言: 相反,我很是喜歡這門語言,它提升了工做效率,但我也相信一個優秀的開發人員須要知道這門語言內部工做原理,以便於更加高效和明智地使用它的語法特性。Kotlin很強大,就像一句名言說得那樣:編程

「With great power comes great responsibility.」api

這些文章將僅關注Kotlin 1.1以後的JVM / Android實現,而不是Javascript實現。緩存

Kotlin字節碼檢查器bash

這是查看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可見性。使用publicinternal可見性將會致使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中增長了兩到三個額外的間接級別,而且將爲這些常量中的每個生成兩到三個額外的方法。

一、始終使用const關鍵字聲明基本類型和字符串常量以免這種狀況

二、對於其餘類型的常量,不能使用const,所以若是須要重複訪問常量,可能須要將值緩存在局部變量中

三、此外,更推薦將公有的全局常量存儲在它們本身的對象中而不是伴隨對象中。

這就是第一篇文章的所有內容。但願這能讓你更好地理解使用這些Kotlin功能的含義。請記住這一點,以便編寫更智能高效的代碼,而不會犧牲可讀性和性能。

歡迎繼續閱讀本系列的Part 2: local functions, null safety and varargs.

譯者有話說

關於探索Kotlin中隱藏的性能開銷這一系列文章,早在去年就拜讀過了,一直當心收藏着,但願有時間能把它翻譯出來分享給你們,而後一直沒有時間去作這件事,可是卻一直在TODO List清單中。

關於這個系列文章無論你是Kotlin初學小白,仍是有必定基礎的Kotlin開發者都是對你有好處的,由於它在教你如何寫出更優雅性能更高效的Kotlin代碼,並從原理上帶你分析某些操做不當會致使額外的性能開銷。這篇文章原文還有兩篇,後面會繼續翻譯出來,分享出來給你們。

前方高能預警,即將迎來一波贈書福利

說真的,最近工做真的特別忙,本沒有時間寫文章。可是上週華章主編郭老師找到我,可否再寫篇文章搞個贈書活動,一想到上次給你們的贈書名額本就很少,不少人都沒拿到,因此擠出這週末的時間來寫寫,並把以前壓箱底TODO List這一系列文章翻譯出來分享給你們,二來又能給你們帶來贈書福利。

如何參與贈書福利

歡迎關注Kotlin開發者聯盟,這裏有最新Kotlin技術文章和Kotlin相關書籍贈書活動

老規矩在公衆號本篇文章中留言點贊數最多排名做爲贈書對象,此次贈書書籍仍是上次我推薦的《Kotlin核心編程》,贈書活動截止時間是7月10號晚上8點公佈名單

相關文章
相關標籤/搜索