[譯]Effective Kotlin系列之考慮使用原始型別的陣列優化效能(五)

極客熊貓發表於2018-12-13

翻譯說明:

原標題: Effective Kotlin: Consider Arrays with primitives for performance critical processing

原文地址: blog.kotlin-academy.com/effective-k…

原文作者: Marcin Moskala

Kotlin底層實現是非常智慧的。在Kotlin中我們不能直接宣告原始型別(也稱原語型別)的,但是當我們不像使用物件例項那樣操作一個變數時,那麼這個變數在底層將轉換成原始型別處理。例如,請看以下示例:

var i = 10
i = i * 2
println(i)
複製程式碼

上述的變數宣告在Kotlin底層是使用了原始型別int.下面這是上述例子在Java中的內部表達:

// Java
int i = 10;
i = i * 2;
System.out.println(i);
複製程式碼

上述使用int的實現到底比使用Integer的實現要快多少呢? 讓我們來看看。我們需要在Java中定義兩種方式函式宣告:

public class PrimitivesJavaBenchmark {

    public int primitiveCount() {
        int a = 1;
        for (int i = 0; i < 1_000_000; i++) {
            a = a + i * 2;
        }
        return a;
    }

    public Integer objectCount() {
        Integer a = 1;
        for (Integer i = 0; i < 1_000_000; i++) {
            a = a + i * 2;
        }
        return a;
    }
}
複製程式碼

當你測試這兩種方法的效能時,您會發現一個巨大的差異。在我的機器中,使用Integer需要4905603ns, 而使用原始型別需要316954ns(這裡是原始碼,自己檢查執行測試)這少了15倍!這是一個巨大的差異!

[譯]Effective Kotlin系列之考慮使用原始型別的陣列優化效能(五)

怎麼會產生如此之大的差異呢? 原始型別比物件型別更加輕量級。在記憶體中原始型別的變數僅僅儲存是一個數值而已,它們沒有物件導向那一整套的記憶體分配過程。當你看到這種差異時,你應該感到慶幸,因為在Kotlin底層實現會盡可能使用原始型別,而且這種底層的優化我們甚至毫無察覺。但是你也應該知道有些情況底層編譯器是不會轉化成原始型別來做優化處理的:

  • 可空型別不能是原始型別。編譯器是很智慧的,儘管是可空型別,可是當它檢測到你沒有對可空型別變數設定null值時,然後它還是會使用原始型別處理的。如果編譯不能確定最終檢測結果,那麼它將預設使用非原始型別。請記住,這是程式碼效能關鍵部分因可空性引入的額外成本。
  • 原始型別不能用於泛型型別引數。

第二個問題顯得尤為重要,因為我們在大部分場景下很少會對程式碼中數值做處理,但是我們經常會對集合中的元素做操作。可是問題來了,泛型型別引數不能使用原始型別,但是每個泛型集合都只能使用非原始型別了。例如:

  • Kotlin中的List<Int>等價於Java中的List<Integer>(注意下: 這個地方有點問題,糾正下原文作者的一個小錯誤,實際上是Kotlin中的MutableList<Int>等價於Java中的List<Integer>,但是作者這裡主要想表明在Kotlin中作為泛型型別引數Int型別情況下等同於Java中的包裝器型別Integer而不是原始型別int)
  • Kotlin中的Set<Double>等價於Java中的Set<Double>(注意下: 這個地方有點問題,糾正下原文作者的一個小錯誤,實際上是Kotlin中的MutableSet<Double>等價於Java中的Set<Double>,但是作者這裡主要想表明在Kotlin中作為泛型型別引數Double型別情況下等同於Java中的包裝器型別Double而不是原始型別double)

當我們需要運算元據集合,這將是一筆很大的效能開銷。但是也是有解決方案的, 因為Java集合允許使用原始型別。

// Java
int[] a = { 1,2,3,4 };
複製程式碼

如果在Java中可以使用原始型別的陣列,那麼在Kotlin也是可以使用原始型別的陣列的。為此,我們需要使用一種特殊的陣列型別來表示具有不同原始型別的陣列: IntArrayLongArrayShortArrayDoubleArrayFloatArray或者CharArray. 讓我們使用IntArray,看看與List <Int>相比對程式碼的效能影響:

open class InlineFilterBenchmark {

    lateinit var list: List<Int>
    lateinit var array: IntArray

    @Setup
    fun init() {
        list = List(1_000_000) { it }
        array = IntArray(1_000_000) { it }
    }

    @Benchmark
    fun averageOnIntList(): Double {
        return list.average()
    }

    @Benchmark
    fun averageOnIntArray(): Double {
        return array.average()
    }
}
複製程式碼

儘管差異不是特別大,但是也是差異也是非常明顯的。例如,因為在底層實現上IntArray是使用原始型別的,所以IntArray陣列的average()函式會比List<Int>集合執行效率高了約25%左右。(這裡是原始碼,自己檢查執行測試)

具有原始型別的陣列也會比集合更加輕量級。進行測量時,您會發現IntArray上面分配了400000016個位元組,而List<Int>分配了2000006944個位元組。大概是5倍的差距。

正如你所看到那樣,使用具有原始型別的變數或者陣列都是優化效能關鍵部分一種手段。它們需要分配的記憶體更少,並且處理的速度更快。儘管原始型別陣列在大多數情況下作了優化,但是預設情況下可能更多是使用集合而不是陣列。因為集合相比資料更加直觀和更經常使用。但是你也必須記住原始型別的變數和原始型別陣列帶來的效能優化,並且在合適的場景中使用它們。

譯者有話說

這篇Effective Kotlin系列的文章比較簡單,但是也很重要。它指出了我們經常會忽略的原始型別陣列。相信很多人都習慣於使用集合,甚至有的人估計都沒怎麼用過Kotlin中的IntArray、LongArray、FloatArray等,平時不管是什麼場景都使用集合一梭哈。這也很正常,因為集合基本上可以替代陣列出現所有場景,而且集合使用起來更加直觀和方便。但是之前的你可能不知道原來原始型別的陣列可以在某些場景替代集合反而可以優化效能。所以原始型別的陣列是有一定應用場景的,那麼從讀了這篇文章起,請一定要記住這個優化點。關於這篇文章我還想再補充幾點哈:

  • 1、解釋下文章中的原始型別

請注意: 文章中的原始型別(原語型別或基本資料型別)實際上不是Kotlin中的Int、Float、Double、Long等這些型別,原始型別實際上它不對應一個類,就像我們常在Java中說的String不是原始型別,而是引用型別。實際這裡原始型別就是指Java中的int、double、float、long等非引用型別。為什麼說Kotlin中的Int不是原始型別,實際上它更是一種引用型別,一起來看Int的原始碼:

public class Int private constructor() : Number(), Comparable<Int> {
    companion object {
        public const val MIN_VALUE: Int = -2147483648
        public const val MAX_VALUE: Int = 2147483647
        @SinceKotlin("1.3")
        public const val SIZE_BYTES: Int = 4
        @SinceKotlin("1.3")
        public const val SIZE_BITS: Int = 32
    }
複製程式碼

可以明顯看出實際上Int是在Kotlin中定義的一個類,它屬於引用型別,不是原始型別。所以我們平時在Kotlin中是不能直接宣告原始型別的,而所謂原始型別是Kotlin編譯器在底層做的一層內部表達。在Kotlin中宣告Int型別,實際上底層編譯器會根據具體使用情況,智慧推測出是將Int表達為包裝器Integer還是原始型別int。如果不信,請看下面這個解釋的原始碼論證。

  • 2、解釋下文章中的這句話 "儘管是可空型別,可是當它檢測到你沒有對可空型別變數設定null值時,然後它還是會使用原始型別處理的,如果設定null就當做非原始型別處理"

把上面那句話說的通俗就是,宣告一個可空型別Int?變數,如果沒有對它做賦值null的操作,那麼編譯器在底層實現會把這個Int?型別使用原始型別int,如果有賦值null操作就會使用包裝器型別Integer.一起來看個例子

//kotlin定義的原始碼
fun main(args: Array<String>) {
    var number: Int?
    number = 2
    println(number)
}
//反編譯後的Java程式碼
  public static final void main(@NotNull String[] args) {
      Intrinsics.checkParameterIsNotNull(args, "args");
      int number = 2;//可以明顯看到number變數使用的是int原始型別
      System.out.println(number);
 }
複製程式碼

如果把上述例子改為賦值為null

//kotlin定義的原始碼
fun main(args: Array<String>) {
    var number: Int? = null
    number = 2
    println(number)
}
//反編譯後的Java程式碼
  public static final void main(@NotNull String[] args) {
      Intrinsics.checkParameterIsNotNull(args, "args");
      Integer number = (Integer)null;//這裡number變數是使用了Integer包裝器型別
      number = 2;
      int var2 = number;
      System.out.println(var2);
   }
複製程式碼

通過上述程式碼的對比,可以發現Kotlin編譯器是非常智慧的,這也就是解釋了雖然在Kotlin定義的是Int,但是會根據不同的使用情況,最終轉換成結果也不一樣的,所以使用的時候一定要做到心裡有數。

  • 關於使用原始型別陣列的建議

其實我們大多數情況下還是使用集合的,因為陣列使用具有侷限性。那麼什麼時候使用原始型別陣列呢? 元素的型別應該是Int、Float、Double、Long等這些型別,並且長度還是固定的,這種情況更多考慮是原始型別陣列來替代集合的使用,因為它效率更高。其他非這種場景還是建議使用集合。

Kotlin系列文章,歡迎檢視:

Effective Kotlin翻譯系列

原創系列:

翻譯系列:

實戰系列:

[譯]Effective Kotlin系列之考慮使用原始型別的陣列優化效能(五)

歡迎關注Kotlin開發者聯盟,這裡有最新Kotlin技術文章,每週會不定期翻譯一篇Kotlin國外技術文章。如果你也喜歡Kotlin,歡迎加入我們~~~

相關文章