Android CPU, Compilers, D8 & R8

此爲譯文,原文:Android CPU, Compilers, D8 & R8 – ProAndroidDevjava

設想你被分配了一項重要的太空探索任務。你須要建造一艘很是可靠的飛船。你可能會選擇普通的 YT-1300 運輸機,它很是常見,你也基本知道如何操做它。然而你老是夢想着開一個更牛逼的傢伙,你本身已經偷偷訓練了好久,事實上千年隼號纔是你真正的目標,但這個升級版的飛船要求你像 Han Solo 那樣技術嫺熟!android

最近,Google 對編譯器的改進讓我很感興趣,例如 R8 以及 Gradle 構建過程。我想是時候深刻了解而後跟大家分享一下這些改進。可是首先,讓咱們從基礎聊起。數組

當你讀完這篇文章:緩存

  • 你會了解 JVM 以及它和 Android 的關係
  • 你將學會閱讀字節碼
  • 你將對 Android 的編譯系統有個大概的瞭解
  • 你將知道 AOT 和 JIT 是什麼,它們與 R8 是如何聯繫起來的
  • 額外的,你也會了解一些星球大戰的東西 😉

因此,沏上一杯咖啡,拿上光劍和小餅乾,讓咱們開始吧。安全

CPU & JVM

每個手機上,都有一個 CPU,它很是小,倒是全部運算髮生的地方。markdown

在最開始的時候,CPU 只能處理簡單的數學運算,好比加減乘除。通過這麼多年的發展,CPU 已經進化到能夠處理很是複雜的運算,好比圖片處理、音頻解碼等等。目前最知名的移動處理器是高通生產的驍龍系列。網絡

但高通並非惟一的 CPU 製造商。其餘製造商生產的 CPU 有些架構與高通相同,有些卻不同。這裏我要說:「歡迎來到地獄!」。若是你曾經開發過 C++/C,你就會知道 native code 須要爲全部支持的架構編譯一份,好比 ARM、ARM6四、X8六、X6四、MIPS 等等。架構

做爲一個 Android 開發者,一般你的應用須要支持多種多樣的設備,而這些設備背後的 CPU 架構不盡相同。這基本上意味着你須要爲每一種架構編譯一個 so 文件。說實話,這可一點都很差玩兒。不過別擔憂,我不會一直這麼抱怨 C++開發有多麼很差玩兒的。app

JVM 完美解決了這個問題。JVM 在硬件上面添加了一層抽象。經過這種方式,你的應用程序就能夠經過 Java 的接口來使用 CPU ,而你也不用去爲了避免同的 CPU 架構作適配,也不用爲了 Mac 上不同凡響的藍牙驅動而煩惱。工具

javac 編譯器將你的 Java 代碼編譯爲字節碼(.class 文件),而後你的代碼就能夠直接在 Java 虛擬機上運行,而不用關心底層操做系統的差別。做爲一個應用開發者,你不用去關心設備硬件、操做系統、內存、CPU 的差別,只須要關注業務邏輯,想法設法讓你的用戶開心就行了。

JVM 內部

JVM 有三個主要的區域。

  1. ClassLoader - 主要職責是加載編譯後的字節碼(.class 文件),連接,檢測損壞的字節碼,定位並初始化靜態變量和靜態代碼
  2. Runtime Data - 負責全部的程序數據:棧,方法變量,固然還有咱們都很是熟悉的堆
  3. Execution Engine - 負責執行已經加載的代碼並清理不在須要的垃圾(GC)

到這裏爲止,你已經建造了一個基本的載人飛船,耶!但它估計飛的不會太遠。讓咱們繼續深挖,看看還有那些能夠升級的選項。我想你應該準備好學習 Execution Engine 中的解釋器和 JIT 編譯器了。

Interpreter & JIT

這兩個傢伙在一塊兒工做,每當咱們運行咱們的程序,解釋器都須要將字節碼解釋爲機器碼再運行。這麼作最主要的一個缺點就是當一個方法須要屢次執行的時候,每次執行都須要進行解釋。想象一下,每當一個帝國士兵被克隆出來的時候,你都須要教他如何格鬥、如何握槍、如何征服一個星球,這得多痛苦啊。

JIT 編譯器就是用來解決這個問題的。執行引擎仍是使用解釋器解析代碼,但不一樣的是,當它發現有重複執行的代碼時,它會切換爲 JIT 編譯器,JIT 編譯器會將這些重複的代碼編譯爲本地機器代碼,而當一樣的方法再次被調用時,已經被編譯好的本地機器代碼就會被直接運行,從而提高系統的性能。這些重複執行的代碼也被稱爲「熱代碼(Hot code)」。

這一切又是怎麼跟 Android 關聯起來的呢?

Java 虛擬機的設計一直以來都是面向有無限電量和幾乎無限存儲的設備。

而 Android 設備則很不相同。首先電池容量有限,全部的程序都須要爲了有限的資源競爭。其次內存的大小有限,存儲空間也頗有限(跟其餘的 JVM 運行設備相比,簡直是小的可憐)。所以,當 Google 決定在移動設備上使用 JVM 的時候,他們作了不少的改動 - 包括 java 代碼編譯爲字節碼的過程以及字節碼的結構等等。下面咱們用代碼來講明這些變化。

public int method(int i1, int i2) {
    int i3 = i1 * i2;
    return i3 * 2;
}
複製代碼

當這段 Java 代碼使用普通的 javac 編譯器編譯爲字節碼後,看起來大概是這樣的:

可是當咱們使用 Android 的編譯器(Dex Compiler)進行編譯時,字節碼看起來是這樣的:

之因此有這樣的區別是由於普通的 Java 字節碼是以棧爲基礎的(全部的變量都存儲在棧中),而 dex 格式的字節碼是是寄存器爲基礎的(全部的變量都存儲在寄存器中)。後者更加高效而且須要更少的空間。運行 Dex 字節碼的 Android 虛擬機被稱爲 Dalvik.

Davik 虛擬機只能加載和運行使用 Dex 編譯器生成的字節碼,與普通的 JVM 相似,也使用瞭解釋器和 JIT 編譯器。

你有沒有意識到你的飛船已經能夠在真空中飛行了呢?它得到了極大的提高,因此你須要提升本身的技能才能掌控它。確保你帶夠了小餅乾,你的大腦可能也須要一些糖分的補充。

字節碼?

字節碼其實就是 Java 代碼翻譯爲 JVM 可以理解的代碼。閱讀字節碼其實很是簡單,來看看這個:

每一條指令都由操做碼和寄存器(或者常量)組成。這裏有一個安卓支持的操做碼的完整列表

與 Java 類型相同的類型:

  • I - int
  • J - long
  • Z - boolean
  • D - double
  • F - float
  • S - short
  • C - char
  • V - void(用做返回值)

類類型會使用完整路徑: Ljava/lang/Object;

數組類型使用 [ 前綴,後面跟着具體的類型: [I, [Ljava/lang/Object;, [[I

當一個方法有多個參數時,這些參數類型能夠直接拼接在一塊兒,咱們來練習一下:

obtainStyledAttributes(Landroid/util/AttributeSet;[III)

obtainStyledAttributes 顯然就是方法名了,Landroid/util/AttributeSet; 第一個參數就是 AttributeSet 類了,[I 第二個參數就是一個 Integer 類型的數組了,後面又連續跟着兩個 I 說明還有兩個 Integer 類型的參數。

據此能夠推斷出對應的 Java 方法聲明爲: obtainStyledAttributes(AttributeSet set, int[] attrs, int defStyleAttr, int defStyleRes)

Yaay! 如今你已經掌握了基本的概念,讓咱們繼續練習:

.method swap([II)V ;swap(int[] array, int i)
 .registers 6
 aget v0, p1, p2 ; v0=p1[p2]
 add-int/lit8 v1, p2, 0x1 ; v1=p2+1
 aget v2, p1, v1 ; v2=p1[v1]
 aput v2, p1, p2 ; p1[p2]=v2
 aput v0, p1, v1 ; p1[v1]=v0
return-void
.end method
複製代碼

對應的 Java 代碼以下:

void swap(int array[], int i) {
int temp = array[i];
    array[i] = array[i+1];
    array[i+1] = temp;
}
複製代碼

等一下,第六個寄存器在哪兒?!眼神不錯,當一個方法是一個對象實例的一部分的時候,它有一個默認的參數 this,老是存儲在寄存器 p0 中。而若是一個方法是靜態方法的話,p0 參數有別的意義(不指代 this)。

讓咱們來看另外的一個例子。

const/16 v0, 0x8                      ;int[] size 8
new-array v0, v0, [I                  ;v0 = new int[]
fill-array-data v0, :array_12         ;fill data
nop
:array_12
.array-data 4
        0x4
        0x7
        0x1
        0x8
        0xa
        0x2
        0x1
        0x5
.end array-data
複製代碼

對應的 java 代碼是

int array[] = {
 4, 7, 1, 8, 10, 2, 1, 5
};
複製代碼

最後一個。

new-instance p1, Lcom/android/academy/DexExample;
           
 ;p1 = new DexExample();
invoke-direct {p1}, Lcom/android/academy/DexExample;-><init>()V
            ;calling to constructor: public DexExample(){ ... }
const/4 v1, 0x5          ;v1=5
invoke-virtual {p1, v0, v1}, Lcom/android/academy/DexExample;->swap([II)V .           ;p1.swap(v0,v1)
複製代碼

對應的 Java 代碼是

DexExample dex = new DexExample();
dex.swap(array,5);
複製代碼

如今你有了閱讀字節碼的超能力,恭喜恭喜!

在咱們開始進入 D8 和 R8 以前,咱們還須要回到 Android JVM 也就是 Dalvik 的主題上,就像是想要充理解第一部星戰,咱們須要先觀看第四部同樣,你懂的。

Android build process

.java 和 .kt 代碼文件被 Java 編譯器和 Kotlin 編譯器協做編譯爲 .class 文件,這些文件又被編譯爲 .dex 文件,最終被打包進 .apk 文件。

當你從 play store 下載一個應用的時候,你下載的就是包含了全部 .dex 以及資源的 apk 安裝包,並被安裝到設備上。當你從 launcher 上點擊一個應用圖標的時候,系統就會啓動一個新的 Dalvik 進程,並將應用包含的 dex 代碼加載進來,這些代碼進一步在運行時被 Interpreter 解釋器解釋或者被 JIT 編譯器編譯。而後你就看到了應用的頁面了。

如今你已經有一個像樣的貨船了!能夠起航了!哦不對!你是一個有追求的專業飛行員,你想要的是一個更高級的宇宙飛船,那咱們就繼續來升級吧!

ART

Dalvik 曾經是一個很不錯的解決方案,然而它也有很多侷限性。因此呢,google 後來又推出了一個優化後的 Java 虛擬機,叫 ART。ART 與 Dalvik 的主要區別是,它不是在運行時進行解釋和 JIT 編譯,而是直接運行的提早編譯好的 .oat 文件,所以得到了更好更快的運行速度。爲了提早編譯好 .oat 二進制文件,ART 使用了 AOT 編譯器(AOT 是 Ahead of Time 的縮寫)

那麼,到底什麼是 .oat 二進制文件呢?

當你從應用商店下載並安裝一個應用的時候,除了解壓縮 .apk 文件,系統也會對 .dex 文件進行編譯,生成 .oat 文件。

因此當你點擊應用圖標的時候,ART 直接加載 .oat 文件並運行,而不須要任何的解釋和 JIT 步驟。

聽起來很不錯,但看起來咱們的宇宙飛船升級的並不怎麼順利啊。

  • 如前所述,.dex 編譯爲 .oat 是安裝應用過程當中的一部分,這就致使了安裝或者更新應用變得速度暴慢。另外每當安卓系統有升級,就會有一到兩個小時的時間會用來 「Optimizing app」,是可忍孰不可忍,特別是對於當時的 Nexus 用戶,每月都有一次安全升級,真是太痛苦了。
  • 全部的 .dex 文件都被編譯爲 .oat 文件,即便有些應用代碼幾乎不怎麼被用戶使用,好比設置頁面、反饋頁面等等,因此能夠說咱們浪費了大量的磁盤空間,對於低端小容量的手機來講尤爲是個問題。

可是在銀河系中,老是會有絕地武士前來拯救世界。Google 的工程師想出了一個絕妙的點子來解決問題,充分利用了 Interpreter/JIT/AOT 的優勢。

  1. 最開始安裝的時候並無 .oat 文件生成,當你第一次運行應用的時候,ART 會使用解釋器來解釋執行 .dex 代碼
  2. 當 Hot Code 被發現的時候,ART 會調用 JIT 來對代碼進行編譯
  3. 使用 JIT 編譯過的代碼以及編譯選項會存儲在緩存中,之後每次執行一樣的代碼就會使用這裏的緩存
  4. 當設備空閒的時候(屏幕熄滅而且在充電),全部的 Hot Code 會被 AOT 編譯器使用緩存的編譯選項編譯爲 .oat 文件
  5. 當你再次運行應用的時候,位於 .oat 文件的代碼會被直接執行,從而得到更好的性能,而若是要執行的代碼不在 .oat 文件中,則回到第一步

平均來講,優化 80% 的應用代碼須要運行 8 次應用。

而後,接着是更進一步的優化 - 爲何不在相同的設備之間共享編譯選項呢?事實上,Google 就是這麼作的。

當一個設備空閒而且鏈接到一個 WIFI 網絡的時候,它會將自身的編譯 profile 經過 paly service 共享給 google,當有別的用戶使用一樣配置的的設備從 play store 下載同一個應用的時候,也會同時下載編譯 profile 用來指導 AOT 將常常運行的代碼編譯爲 .oat 存儲。這樣一來,用戶第一次運行的時候就已是優化好的應用啦。

那這一切跟 R8 有什麼關係呢?

Google 的老夥計們付出了巨大的努力來改進編譯速度,實際上咱們這些努力確實也收到了不錯的效果,然而,然而,然而,Dalvik/ART 支持的 opcodes 是很是受限的,在瞭解了前面的內容以後,你應該明白了爲何。

Java 7-8-9 等等新引入的語言特性並不能直接就能用在 Android 開發中,基本上如今的全部的 Android 開發者還在被困在 Java 6 SE 上。

爲了讓咱們能使用上 Java 8 的特性,Google 使用了 Transformation 來增長了一步編譯過程叫 desugaring,其實就是將咱們代碼裏使用的 java 8 新特性翻譯爲 Dalvik/ART 可以識別的 java 6 字節碼。這不可避免會致使一個問題 - 更長的編譯時間。

Dope8

爲了解決這個問題,在 Android Studio 3.2 中,Google 使用 D8 替換了舊的 dx 編譯器。D8 的主要改進是消除 desuguaring 的過程,讓其成爲 dex 編譯的一部分,從而加快編譯速度。

能快多少呢?根據項目的不一樣表現也不同。在咱們的小項目中,編譯 100 次取平均大概會比不用 d8 快 2s.

這裏還有一個關於 D8 名字由來的趣事兒,對呀,爲何叫 D8 呢?【D 和 8 分別表明什麼呢?能讓人產生聯想的多是 Google V8 js 引擎,但並無關係】

到這裏還不是所有。

android.enableR8 = true (experimental AS 3.3)

R8 是 D8 的意外收穫,他們的 codebase 是同樣的,但 R8 解決了更多的痛點。與 D8 同樣,R8 容許咱們開發使用 Java 8 的特性,並運行在老的 Davalik/ART 虛擬機中,但不只僅如此。

R8 幫助使用正確的 opcodes

做爲 Android 開發的痛苦之一就是碎片化。Android 設備的種類及其龐大,上次我查看 Play Store 的時候,上面顯示有超過兩萬種設備(說到這裏羨慕的瞅一瞅只須要支持 2.5 臺設備的 iOS 開發同窗們)甚至有些廠商會修改 JIT 編譯器的工做機制。這就致使了有一部分設備行爲變得很奇怪。

R8 的對 .dex 最大的優化就是它只保留了咱們指定支持的設備所能理解的 opcodes,就像下圖所示的那樣。【這裏說的是在某些設備上某些指令會致使崩潰,看起來是會作一些處理】

R8 replace Proguard?

Proguard 也是編譯過程當中一個 transformation 的步驟,固然也會影響編譯時間。爲了解決這個問題,R8 在 dex 過程當中也會作相似的事情(好比優化、混淆、清理無用的類),而避免了多一步 transformation.

須要注意的是,R8 並不能徹底取代 Proguard,它目前仍是一個實驗性質的只支持一部分 Proguard 功能的工具。能夠從這裏瞭解更多。

R8 對 Kotlin 更友好

開發者喜歡 Kotlin,這門神奇的語言讓咱們能夠書寫更優雅易讀易維護的代碼,然而,Kotlin 生成的字節碼指令比對應的 Java 版本要多一些。

咱們來用 Java 8 的 lambda 語句進行一下測試:

class MathLambda {
    interface NumericTest {
        boolean computeTest(int n);
    }

    void doSomething(NumericTest numericTest) {
        numericTest.computeTest(10);
    }
}

private void java8ShowCase() {
        MathLambda math = new MathLambda();
        math.doSomething((n) -> (n % 2) == 0);
}
複製代碼

R8 生成的指令比 dx 生成的指令要少一些。

接下來是等價的 Kotlin 版本的代碼:

fun mathLambda() {
    *doSomething***{**n: Int **->**n % 2 == 0 **}**
}

fun doSomething(numericTest: (Int) -> Boolean) {
    numericTest(10)
}
複製代碼

很明顯的,Kotlin 版本的指令比 Java 版本的多了很多,但 R8 也相比 dx 有更一步的優化。

對於咱們的 app 來講,跑 100 次的結果以下:

時間少了 13s,少了 1122 方法,apk 的體積也減小了 348KB,爆炸!

須要提醒你的是 R8 依然是實驗性的,Google 的工程師正在努力將它推向 Production Ready. 你想盡快開上千年隼嗎,別坐着不動了,幫咱們一塊兒加把勁兒,試試 R8,並嘗試提交一個 bug 吧!

相關文章
相關標籤/搜索