【譯】探索Kotlin帶來的隱性成本(一)

注:來自Medium上的一位Android工程師所寫,做者從字節碼的層面分析了kotlin一些隱性的性能成本,以及若是避免這些。這個文章有3個部分,這是第一部分。英文原版 medium.com/@BladeCoder…java

Lambda表達式和伴隨對象

在2016年, Jake Wharton針對Java的隱性成本進行了一系列有趣的談話。在同一時期,他也開始倡導使用Kotlin語言進行Android開發,但幾乎沒有提到該語言在開發中的隱藏成本,除了推薦使用內聯函數。如今,Kotlin在Android Studio 3.0中獲得Google的正式支持,我認爲經過研究它產生的字節碼來寫一些這方面的文章是一個不錯的主意。數據庫

Kotlin是一種現代化的編程語言,與Java相比,它具備更多的語法糖,並且在後臺還有更多的「黑魔法」,可是其中有一些性能的成本是不可忽略的,尤爲是針對一些低端的Android設備。編程

這裏並非反對Kotlin,我很是喜歡這個語言由於它極大的提升了開發效率。但我也認爲一個好的開發人員須要知道語言的內部工做原理,以便更明智地使用它。kotlin是很是強大的,有這樣一種名言:「能力越大,責任也越大」。緩存

這些文章盡基於Kotlin 1.1的JVM / Android實現,而不是Javascript實現。bash

Kotlin字節碼分析工具

這個工具會爲你將kotlin代碼轉換爲字節碼文件,在Android Studio中安裝了Kotlin插件後,選擇「Show Kotlin Bytecode」打開當前類的字節碼文件,而後,能夠點擊「Decompile」按鈕閱讀等效的Java代碼。
(譯者注:在Android studio中 首先選中你要打開的類,而後 tools->kotlin->Show Kotlin Bytecod 便可查看當前類編譯的字節碼文件。)app

高階函數和Lambda表達式

Kotlin能夠爲變量分配函數,並能夠將這些函數做爲參數傳遞給其餘函數。接受其餘函數做爲參數的函數被稱爲高階函數。
kotlin函數可用經過帶有::符號的函數名來引用(譯者注:通俗點講Kotlin 中雙冒號操做符 表示把一個方法當作一個參數,傳遞到另外一個方法中進行使用),或者直接聲明爲匿名函數,或使用lambda表達式語法,lambda表達式是描述函數的最簡潔的方式。編程語言

Kotlin是爲Java 6/7和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不能直接支持lambda表達式,那麼他們如何翻譯成字節碼呢?如你所料,lambda和匿名函數會被編譯爲函數對象。

函數對象

下面這段代碼是上述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));
   }

   public final int invoke(@NotNull Database it) {
      Intrinsics.checkParameterIsNotNull(it, "it");
      return it.delete("Customers", null, null);
   }
}複製代碼

在Android dex文件中,每一個lambda表達式編譯爲函數後,應用的方法總數會增長3到4個。

這樣作的好處是這些函數對象的實例只有在使用時纔會被建立,這意味着:

  • 對於捕獲lambda表達式,每次將lambda做爲參數傳遞時,都將建立一個新函數實例,執行完畢後被當作垃圾回收;
  • 對於非捕獲lambda表達式(純函數),將會建立一個單例的函數實例以便之後複用。

(譯者注:當Lambda表達式訪問一個定義在表達式體外的非靜態變量或者對象時,這個Lambda表達式稱爲「捕獲的」。好比,下面這個lambda表達式捕捉了變量x:
int x = 5; return y -> x + y; 爲了保證這個lambda表達式聲明是正確的,被它捕獲的變量必須是「有效final」的。因此要麼它們須要用final修飾符號標記,要麼保證它們在賦值後不能被改變。)

因爲示例中的調用者代碼使用的是非捕獲lambda表達式的形式,所以它會被編譯爲單例,而不是內部類。

this.transaction(db, (Function1)MyClass$myMethod$1.INSTANCE);複製代碼

建議:若是使用捕獲lambda表達式,避免重複調用高階函數以便減小垃圾收集器的壓力。

裝箱開銷

在java8 中大約有43個特殊的函數接口用來最大限度的避免裝箱和拆箱操做,而kotlin編譯的函數對象僅實現了通用接口,同時使用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上這會對性能形成不可忽視的影響。

在上面被編譯過的代碼中,你能夠看到結果被包裝成了Integer對象,可是最後調用代碼又當即對齊進行了拆箱操做(譯者注:調用代碼最後返回的是Int)。

建議:看成爲參數的函數中的輸入輸出值涉及到基本數據類型時,請謹慎調用高階函數,頻繁的調用將會給系統帶來更大的壓力。

內聯函數(Inline functions)

幸運的是Kotlin使用了一個很棒的技巧,以免在使用lambda表達式時形成的額外開銷,那就是將高階函數聲明爲內聯。聲明爲內聯的函數會被編譯器直接插入到調用者內部。而這對於高階函數的好處是做爲其參數的lambda表達式也將會被內聯,實際效果是這樣的:

  • 建立lambda時不會再建立函數對象;
  • 對於輸入輸出爲原始數據類型的lambda不在涉及到裝箱和拆箱操做;
  • 應用的方法總數不會再增長;
  • 不會執行實際的函數調用,提升了cpu屢次處理沉重事務的能力。

當把咱們上面的transaction()函數聲明爲內聯類型時,調用者的代碼就會變成下面的形式:

db.beginTransaction();
try {
   int result$iv = db.delete("Customers", null, null);
   db.setTransactionSuccessful();
} finally {
   db.endTransaction();
}複製代碼

注意事項:

  • 內聯函數中不能直接調用它自己或者經過其餘的內聯函數(譯者注:自己確實不可以調用,可是經過其餘內聯貌似能夠啊,難道是理解有誤?,有明白的請在評論區留言指點);
  • public類型的內聯函數只能訪問本類中的public 函數及字段;
  • 代碼體積會變大,屢次內聯一個長功能的函數將會使代碼體積顯著增大,若是這個長代碼段中又引入的其餘長功能代碼,結果會更糟。

建議:可能的話,儘可能將高階函數聲明爲內聯,保持代碼行數爲一個較小的數字,將大塊代碼移動到非內聯函數中。

在之後的文章中咱們將討論內聯函數的其餘性能優點。

伴隨對象

Kotlin類中沒有靜態字段或者方法,這些字段和方法能夠在類中的伴隨對象中聲明。

伴隨對象訪問私有類字段

思考如下代碼:

class MyClass private constructor() {
    private var hello = 0
    companion object {
        fun newInstance() = MyClass()
    }
}複製代碼

編譯時,伴隨對象會被聲明爲單例。這意味着就像任何須要從其餘類訪問本類私有字段的Java類同樣,從伴隨對象訪問外部類的私有字段(或構造函數)將會生成額外的setter和getter方法。對類字段的每一個讀寫操做都將致使伴隨對象中靜態方法的調用。

ALOAD 1
INVOKESTATIC be/myapplication/MyClass.access$getHello$p (Lbe/myapplication/MyClass;)I
ISTORE 2複製代碼

在Java中咱們能夠經過這些字段在包中的可見性來避免生成這些setter或getter方法,可是在kotlin中不存在包的可見性。使用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方法也是public的,能夠直接調用,因此不須要上一步的合成方法。可是Kotlin仍然須要調用getter方法來讀取一個常量。

事實證實,爲了存儲常量kotlin編譯器會在主類中生成一個私有靜態常量字段而不是在伴隨對象中,可是由於靜態字段在類中被聲明爲私有的這就須要須要另外一種合成方法來從伴隨對象訪問它。

INVOKESTATIC be/myapplication/MyClass.access$getTAG$cp ()Ljava/lang/String;
ARETURN複製代碼

最後,該合成方法讀取字段的實際值

GETSTATIC be/myapplication/MyClass.TAG : Ljava/lang/String;
ARETURN複製代碼

換句話說,當你訪問伴隨對象中的私有常量字段時,代碼的執行流程是這樣的:

  • 調用伴隨對象中的靜態方法;
  • 調用伴隨對象中的實例方法;
  • 調用類中的靜態方法;
  • 讀取靜態字段並返回其值。

等效的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)
    }
}複製代碼

其次,能夠在伴隨對象的public字段上使用@JvmField註解來指示編譯器不生成任何getter或setter方法,並將其做爲類中的靜態字段公開,就像純Java常量。實際上,這個註解就是爲了兼容Java的緣由而建立的。此外,它只能用於public字段。

最後,你還可使用ProGuard工具優化字節碼,但這種方式的兼容性較差。

建議:合理使用const關鍵字來聲明原始數據類型和String常量避免讀取這些常量帶來的額外開銷
對於其餘類型的常量若是你須要頻繁的訪問它,請將它緩存在局部變量中
此外,全局公共常量最好存儲在本類對象中而不是伴隨對象中

這就是第一篇文章,但願能夠幫助你更好地理解這些Kotlin功能,理解這一點你纔會在寫出更智能的代碼的同時不會犧牲代碼的可讀性及軟件性能。

相關文章
相關標籤/搜索