深入理解Android虛擬機及編譯系統

【版權申明】非商業目的註明出處可自由轉載
博文地址:https://blog.csdn.net/ShuSheng0007/article/details/103259918
出自:shusheng007

文章最近一次更新爲2020年5月10日

概述

從接觸Android開發前後也有幾年了,多數時候還是在寫業務代碼,很少去研究總結一下基礎的東西,然而基礎知識才是程序員技術及發展潛力的試金石,根基打牢了,搞啥都快。

今天心中突然有個疑問:一個App從源代碼到安裝文件,再到安裝到設備上,最後呈現在用戶面前,這期間都經歷了什麼?我發現自己不能完全而清楚的知曉其中的細節,於是就去調查了一下,總結在這篇文章裏。
提前打個招呼,本文具有一定的技術難度和廣度,所以需要你具有相當的技術基礎來閱讀,當然我會盡力使用最容易理解的方式去敘述,這也是咱的一貫作風,絕不能丟。一篇博文寫的再牛逼,別人都看不懂,意義也不大,畢竟我寫的不是科研論文。

本文會涉及到如下幾個方面:

  • JVM與Android的關係
  • Java字節碼基礎
  • Android虛擬機,Dalvik與ART
  • Android構建系統
  • Android編譯器
  • App運行原理,理解AOT、JIT、Dex 等相關術語

虛擬機技術

CPU 與JVM

本文讀者應該都知道,程序是運行在設備的CPU上的,然而我們世界是多樣性的,CPU也不例外。現實中存在各種架構的CPU,例如ARM, Arm64, x86, x64, MIPS,架構不同那麼CPU的指令及執行方式也就不同。而我們總是希望我們的程序可以運行在各種CPU上,你的App總不能因爲小米和華爲手機使用了不同的芯片就二選一吧?

那這個問題怎麼解決呢?

最常用的方式就是針對不同的CPU架構,將程序編譯爲對應CPU的機器碼文件。例如你有一款App要同時支持ARMx86架構的手機,那麼你就要爲這兩種手機各編譯一個安裝包,而且他們之間不能互換安裝。

而等到類似於Java這種虛擬機語言出現後,人們就多了一個選擇。在程序和硬件設備之間增加了一個虛擬層,讓程序運行在虛擬層裏,虛擬層運行在硬件上面,那麼程序員再也不用關心各種各樣的CPU架構了,那是虛擬層的事情,這個虛擬層俗稱虛擬機

如下圖所示,在虛擬機的幫助下就可以實現:一次編碼,到處運行的效果,這也是Java當年提出時候的口號。
在這裏插入圖片描述
圖片來源:Android CPU, Compilers, D8 & R8

如果想要詳細瞭解虛擬機的知識,建議閱讀 《深入理解Java虛擬機》這本國人寫的神書,沒想到國人也能寫出如此棒的書。這裏只要知道,java代碼通過javac編譯器編譯成了 ByteCode(字節碼) 文件,而字節碼文件運行在虛擬機上就好了。

Interpreter & JIT

我都知道JVM可以執行字節碼,那麼其是如何執行的呢?

現代虛擬機一般有兩種執行方式,根據具體的使用場景各有側重。例如運行在client端與運行在server端的虛擬機側重就不同。client端更注重響應性,假如一個程序半天啓動不起來,用戶是要罵娘。而server端更注重執行效率,啓動慢點沒關係,反正也不是經常重啓。

  • Interpreter :解釋執行。一邊把字節碼翻譯成當前硬件平臺的機器碼一邊執行。優點是啓動快,缺點就是執行效率低下。Interpreter 對應的編譯器稱稱爲:解釋執行編譯器

  • JIT(Just In Time) :即時編譯。當一些代碼被頻繁執行到時,虛擬機就將其編譯成機器碼。這些被頻繁執行的代碼有個專有名詞:“熱點代碼” ,相信大家最熟悉的JVM就是sun公司的的虛擬機HotSpot,其也是因爲熱點探測技術比較牛逼而得名。JIT 對應的編譯器爲:即時編譯器

值得說明的是此處只觸及到了虛擬機的皮毛,如果有興趣的同學可以查閱相關資料,虛擬機技術的水那可深啊!

下圖描述了JVM執行字節碼的兩種策略
在這裏插入圖片描述

Java字節碼(ByteCode)

Java字節碼是Java虛擬機規範裏的一套指令集,Java虛擬機可以執行由其按照.class文件結構構成的文件,字節碼由操作符與參數組成。
Dalvik字節碼是JVM字節碼的子集,但是JVM執行的.class文件是基於棧的,而Dalvik/ART執行的.dex文件是基於寄存器的,所以不可以混用。

Android 虛擬機

上面叨叨了半天JVM就是因爲它是Android虛擬機的基礎,Android基本是將Java那套東西照搬到了移動設備上。然而因爲移動設備資源受限的特殊性,例如電池、內存、CUP的運算能力及功耗等都是受限的,造成了其與Java虛擬機還是有很大的不同的。

有人說Android割裂了Java生態系統,個人覺得是有道理的,因爲Java的標準字節碼文件.class文件是不能直接在Android虛擬機上運行的。
雖然字節碼指令是一樣的,但是可執行的文件格式卻不一樣,JVM執行的是**.class文件,而Android虛擬機執行的是.dex**文件。

Android 虛擬機前後共有兩套

  • Dalvik:Android 在4.4 版本上同時提供了Dalvik與ART, 但是Dalvik是作爲默認執行環境的,我們的源代碼最終會編譯爲.dex文件,然後運行在Dalvik上,.dex 表示 Dalvik EXecutable,Dalvik 的原理可以類比JVM。 Android5.0 以後就被完全廢棄了。
  • ART: 其是Android Runtim 的縮寫。
    Android 從5.0以後就完全使用了新的虛擬機ART,其與Dalvik有很大的區別。其推出的目的主要是爲了提高Android的執行效率,減少卡頓現象。那它是怎麼做的呢?ART 採用了一種叫AOT (ahead of time) 來代替目前的在 Runtime 時的 Interpreter 與 JIT。ART 不是等到App運行的時候纔去運行dex文件,而是在App安裝的時候就通過 AOT編譯器.dex文件編譯爲對應的.oat二進制文件,當用戶點擊App的啓動圖標時,ART直接加載.oat文件去執行。其中那個.oat文件是一個**ELF**文件,已經是當前機器的可執行的文件了。

從前面的分析可以看出,ART 通過安裝時將.dex預編譯爲機器的可執行文件,省去Dalvik在運行時才解釋或者即時編譯的過程而提高執行效率,詳情待接下來在分析Android編譯器時再說。

Android 編譯流程

一個App從源代碼到.apk安裝文件都經歷了哪些過程呢?

下圖非常清楚的描述了這一過程:
在這裏插入圖片描述

  1. 通過AAPT(Android Assets Packing Tool) 編譯資源文件,將資源文件打包編譯並生成生成R.java文件,就是放各種資源Id的那個文件。
  2. 通過Java編譯器javac.java 源代碼文件編譯爲.class字節碼文件
  3. 通過Dalvik 編譯器 將.class文件轉化爲.dex文件
  4. 通過Apk builder 將打包後的資源與.dex文件一起生成APK文件。

作爲一個合格的Android開發者,我認爲你應該要記住上面的流程。

Android 程序執行流程

這裏分老式的 Delvik 和新式的ART 兩種情況說明。

Delvik 下App的安裝和啓動流程

f h n

因爲只有Android4.4 以下的OS才使用Dalvik,而市場上此版本及以下的設備已經很少了,所以無需過於關注相關知識了,但是也應該有所瞭解,因爲後面的ART也是爲了解決Dalvik存在的問題才提出的。

如上圖所示,在Dalvik虛擬機上首次啓動App一定是使用Interpreter解釋執行的,期間會探測熱點代碼,使用JIT編譯執行,如果我沒記錯的話,JIT應該是在Android2.2之後加入的,可見最早期的Android很緩慢。

ART 下App的安裝和啓動流程

前面說過,ART是在App安裝的時候將.apk文件減壓,並將.dex文件預編譯爲.oat可執行文件,當App啓動的時候就不需要在Runtime解釋執行了,但是這種方式也有它自己的缺點。

  1. 增加了App的安裝時間,這個很容易理解,因爲多了一個預編譯過程。
  2. 增加了App所需要的安裝空間,與Dalvik相比,手機上多了一份.oat文件。特別是無論一個App的某一功能是否被使用到以及被使用的頻率如何,例如一個App的某個功能用戶幾乎不會去打開,ART都將其編譯爲.oat文件就顯得有點低效了。

Google的那些天才工程師既然發現了問題,那肯定就會去想辦法優化的。技術的每一次進步,都是站在前面技術的肩膀上的,這一次也不例外,Google的工程師將 InterpreterJITAOT 三種技術相結合來優化這個過程。

  1. 首次安裝一個App的時候,AOT不將.dex文件編譯爲.oat文件,系統通過Interpreter的方式來啓動App.
  2. 當在App運行過程中探測到了熱點代碼 “hot code”,就使用JIT編譯
  3. 這些通過JIT編譯的平臺代碼及編譯配置文件都會被存儲在緩存中,加快下次訪問的速度
  4. 當設備處於空閒時(例如充電時),AOT 編譯器就會啓動,依據編譯配置文件將熱點代碼編譯爲.oat可執行文件
  5. 當再次運行App時,ART就可以直接運行.oat文件了

下圖清楚的說明了上面的過程
在這裏插入圖片描述

一款App經過8輪這樣的處理基本上就優化好了。Google的工程師沒有止步於此,他們仍然在思考,爲什麼不把編譯配置文件共享呢,那樣同款設備就不必自己產生這個文件了?

按照這一思路Google通過Google Play 對這一過程做了更高級的優化:

  1. 當某一款安裝了App(假如是叫《神算天下》的一個App)的設備(例如是 Mi10)處於空閒及WIFI聯網的情況
  2. Mi10產生的編譯配置文件上傳到Google Play
  3. 其他用戶在Mi10上從Google Play 安裝此App的時候會獲取到此編譯配置文件
  4. AOT按照編譯配置文件.dex 文件選擇性的編譯爲.oat

這樣用戶在首次安裝的時候,AOT就會完成精準的預編譯,那麼App的啓動及運行就會很流暢。但是很遺憾,這個優化距離我們國內還很遙遠。。。原因衆所周知(也許國內的四大廠商的應用商店也在做吧,這個我沒有調查,誰知道可以在評論中告訴我).

Android編譯器

到此你已經對App的安裝和運行有了一個全局的認識了,已經很好了。如果你想繼續深入技術細節就繼續往下看吧,保你收穫滿滿。

根據前面的介紹,Android 虛擬機是不能直接執行.class文件的,而只能執行.dex文件,所以就必須有一個將Java字節碼.class文件轉化爲Dalvik字節碼.dex文件的的編譯器。

Dex Compiler

衆所周知,Android最爲人詬病的就是其碎片化,其中系統版本碎片化也很嚴重,往往是市場上各種版本的Android系統長期共存,版本收縮速度堪稱龜速,甚至有的設備至出廠後就不能夠升級,近兩年稍有改善。所以一款App往往要同時支持很多版本,那就要求Dex編譯器編譯出來的.dex字節碼可以同時運行在多個版本的Dalvik/ART上。

Android第一版發佈時候使用的是JDK6,即只支持Java6的字節碼指令集。但是到現在Java已經發展到Java14了,期間增加了新的字節碼,增加了新的語言特性以及新的API。Android生態系統總不能一直讓開發者使用Java6來開發吧,那樣估計開發者要起來反抗了?所以Google需要想辦法支持Java7、8、9…

脫糖 Desugaring

什麼是脫糖呢?這個詞是來至語法糖。由於我們老Android版本上的執行環境Dalvik/ART不支持新的語言特性而我們又想要使用,源代碼使用了語法糖,那麼編譯的時候就需要脫糖,例如Lambdas表達式。

Google 在實現脫糖這個功能時也經歷了各種嘗試,如果有興趣可以查看 Jake Wharton的這篇博客 Android’s Java 8 Support

在這裏插入圖片描述
上圖展示了使用舊的dex編譯器的編譯過程,我們可以發現使用Kotlin是不需要脫糖這一步的。隨着Android的發展Dex編譯器當然也會發展,下面介紹一下最新的兩個編譯器。

脫糖包括兩個方面

  • Java 新語言特性的支持
    就是新版本Java 引入的語言特性。 例如 lambda表達式、接口的默認方法以及方法引用等,這些是被最先支持的。支持的方式是通過將這些語言特性還原爲對應的老式寫法。

    這個在插件 Android gradle plugin 3.0.0 ,對應爲Android studio 3.0 以上就支持了。

    android {
      ...
      compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
      }
    }
    
  • Java 新語言 Api的支持
    對新版本API的支持。例如Java 8 新引入的java.util.stream 已經新的時間api java.time。這個不好弄了,因爲老版本Android攜帶的jre根本沒有這套東西,怎麼辦呢?
    編譯器(D8/R8)幫你實現一套,然後打包成.dex文件加到你的apk中,然後讓你的代碼使用這裏面的實現。

    這個在插件Android gradle plugin 4.0.0 ,對應爲Android studio 4.0以上才支持。在你的modul中gradle 配置如下代碼即可

    android {
      defaultConfig {
        // Required when setting minSdkVersion to 20 or lower
        multiDexEnabled true
      }
    
      compileOptions {
        // Flag to enable support for the new language APIs
        coreLibraryDesugaringEnabled true
        // Sets Java compatibility to Java 8
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
      }
    }
    
    dependencies {
      coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.5'
    }
    

D8

D8全稱是Dope8,咱也不知道是應該翻譯爲笨蛋8號呢,還是酷斃了8號,最好還是別翻譯了,D8挺好聽。其是Android最新的Dex編譯器,替換了老的Dex編譯器。

以前脫糖這一個過程是作爲編譯的一個單獨步驟進行的,編譯爲.class後就是脫糖,脫糖後執行ProGuard,然後再編譯爲.dex文件。D8將脫糖和編譯爲.dex文件這兩步合併爲一步來執行了,可以看到脫糖已經在ProGuard之後完成了,D8脫糖後的字節碼精確度和執行效率都更高

在這裏插入圖片描述

R8

R8是基於D8的,可以認爲R8是D8的一種高級執行模式,其與D8最大的區別是對D8產生Dalvik字節碼的過程進行了優化。

D8將Java字節碼轉換爲Dalvik字節碼過程是:先將Java字節碼轉換爲 intermediate representation(IR),然後再將IR輸出爲Dalvik 字節碼,從IR到Dalvik字節碼的過程中基本不做優化,而R8在此過程中會進行優化。

Java生態一般使用ProGuard對字節碼進行優化,而R8將ProGuard這一步的功能給整合到了Dalvik字節碼生成環節中了,不管是ProGuard還是R8主要從下面幾個方面進行優化:

  • 收縮 :去掉沒有使用的類,方法,字段等等
  • 代碼優化:從指令層面上優化,例如指令重排等
  • 混淆 :將類名稱,方法名稱等混淆爲無意義的名稱。

在我最開始接觸Android開發的時候(我以前是寫C#的,爲了做Android學了Java),一直以爲ProGuard的唯一作用就是爲了混淆代碼,足見一個人剛入門一個行當的時候是多麼的無知。
在這裏插入圖片描述

R8 除了完成類似ProGuard的功能外還有一個針對Android生態系統的改進,即根據設備虛擬機和API版本來產生相應的Dalvik字節碼
什麼意思呢?我們接下來簡單的聊一下:

這個問題主要還是由於Android系統的碎片化造成的。最初Android定義了一套Dalvik 字節碼指令,並提供了一個dex 編譯器,而這個編譯器一直沒有使用其中的某些Dalvik指令,例如not-int,其他的手機開發商一看官方的dex編譯器都不使用這些指令,那麼就懶得支持了(在設備的運行環境Dalvik下支持)。但是當Google 提供新的dex編譯器D8的時候,又起用了那些指令。這就尷尬了,使用D8編譯的App在老的設備上就崩潰了,因爲那個設備的虛擬機壓根就不認識D8產生的某些字節碼指令。所以D8就需要根據虛擬機版本及API版本來確定產生的相應的字節碼,說來說去就是要向下兼容。如果市場上永遠只有一個Android版本,根本就不會有這些屁事,但是我們我們是成年人,我們的承認現實!

ProGuard 與 R8的優化功能誰更厲害呢?毫無疑問是ProGuard,這是廣大開發者費了15年的時間不斷優化的結果,而R8只有Google在搞,而且時間很短,而且只在Android生態系統中使用。 如果僅侷限於Android生態系統討論的話,R8隨着不斷的發展應該會比ProGuard 更適合。

總結

本文圖片均出自 Android CPU, Compilers, D8 & R8

本文只從宏觀的角度闡述了一下Android編譯相關的問題,如果讀者對某個部分感興趣,應該從字節碼層面進行具體的研究。

如果你都看到這裏了,我相信本文值得你一讚,給美文點贊是咱程序員的美德。

參考文章:
Android CPU, Compilers, D8 & R8
Android’s Java 8 Support
ProGuard vs R8

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章