9102年了,還不知道Android爲什麼卡?

原文鏈接:https://juejin.im/post/5d4bdb23e51d453c2577b747

導讀

最近華爲方舟編譯器要開源了,筆者去看了下發佈會PPT,發現作爲一名Android開發者,PPT中所介紹的知識點我居然不能完全看懂???於是乎惡補了下PPT中的內容,整理成本文。
本文將用通俗的語言從底層介紹Android卡頓的歷史原因和谷歌與之鬥爭的過程
閱讀完這篇文章後你將

  1. 理解計算機是如何解讀我們所寫的程序並執行相應功能的
  2. 瞭解Android虛擬機的進化史
  3. 從底層瞭解造成Android卡頓的三大原因

一、基礎概念

首先我們需要補習下一些基礎概念,來理解計算機是如何解讀我們所寫的程序並執行相應功能的。

1.編譯&解釋

某些編程語言(如Java)的源代碼通過編譯-解釋的流程可被計算機讀懂

先上一段Java代碼:

public static void main(String[] args){
    print('Hello World')
}

這是所有程序員的第一課,只需要寫完這段代碼並執行,電腦或手機就會打印出Hello World。
那麼問題來了,英文是人類世界的語言,計算機(CPU)是怎麼理解英文的呢?

衆所周知,0和1是計算機世界的語言,可以說計算機只認識0和1。
那麼我們只需要把上面那段英文代碼只通過0和1表達給計算機,就可以讓計算機讀懂並執行。

在這裏插入圖片描述
結合上圖,Java源代碼通過編譯變成字節碼,然後字節碼按照模版中的規則解釋爲機器碼。

2.機器碼&字節碼

  • 機器碼

機器碼就是能被CPU直接解讀並執行的語言。

但是如果使用上圖中生成的機器碼跑在另外一臺計算機中,很可能就會運行失敗。
這是因爲不同的計算機,能夠解讀的機器碼可能不同。通俗而言就是能在A電腦上運行的機器碼,放到B電腦上就可能就不好使了。
舉個🌰,中國人A認識中文,英語;俄國人B認識俄語,英語。這時他兩同時做一張中文試卷,B大概連寫名字的地方都找不到。
所以這時候我們需要字節碼。

  • 字節碼

中國人A看不懂俄文試卷,俄國人B看不懂中文試卷,但是大家都看得懂英文試卷。

字節碼就是個中間碼,Java能編譯爲字節碼,同一份字節碼能按照指定模版的規則解釋爲指定的機器碼。

字節碼的好處:

  1. 實現了跨平臺,一份源代碼只需要編譯成一份字節碼,然後根據不同的模版將字節碼解釋成當前計算機認識的機器碼,這就是Java所說的“編譯一次,到處運行”。
  2. 同一份源碼被編譯成的字節碼大小遠遠小於機器碼。
    在這裏插入圖片描述

3.編譯語言&解釋語言

  • 編譯語言

我們熟知的C/C++語言,是編譯語言,即程序員編譯之後可以一步到位(編譯成機器碼),可以被CPU直接解讀並執行。

在這裏插入圖片描述

可能有人會問,既然上文中說過字節碼有種種好處,爲什麼不使用字節碼呢?

這是因爲每種編程語言設計的初衷不同,有些是爲了跨平臺而設計的,如Java,但有些是針對某個指定機器或某批指定型號的機器設計的。

舉個🌰,蘋果公司開發的OC語言和Swift語言,就是針對自家產品設計的,我纔不管你其他人的產品呢。所以OC或Swift語言設計初衷之一就是快,可直接編譯爲機器碼使iPhone或iPad解讀並執行。這也是爲什麼蘋果手機的應用比安卓手機應用大的主要原因。這更是爲什麼蘋果手機更流暢的原因之一!(沒有中間商賺差價)

  • 編譯-解釋語言
    拿開發Android的語言Java爲例,Java是編譯-解釋語言,即程序員編譯之後不可以直接編譯爲機器碼,而是會編譯成字節碼(在Java程序中爲.class文件,在Android程序中爲.dex文件)。然後我們需要將字節碼再解釋成機器碼,使之能被CPU解讀。
    這第二次解釋,即從字節碼解釋成機器碼的過程,是程序安裝或運行後,在Java虛擬機中實現的。

二、造成卡頓的三大因素

今年最新的Android版本已經是10了,其實在這兩年關於Android手機卡頓的聲音已經慢慢低了下去,取而代之的是流暢如iOS之類的聲音。

但是諸如超過iOS的話,還比較少,其實是因爲Android有卡頓有三大歷史原因。起步就比iOS低。

1.虛擬機——解釋過程慢

通過上文描述,我們可以知道,iOS之所以不卡是因爲他一步到位,省略了中間解釋的步驟,直接跟硬件層進行通信。而Android由於沒有一步到位,每次執行都需要實時解釋成機器碼,所以性能較iOS明顯低下。

我們已經明確知道了字節碼(中間商)是造成卡頓的主要元兇之一,我們可否像iOS那樣扔掉字節碼,直接一步到位呢?

明顯不能,因爲iOS搞來搞去就那麼幾個機型。反觀Android方面,光手機就有無數種機型,無數種CPU架構/型號,更別提什麼平板,車載等其他設備了。有那麼多類型的硬件設備代表着就有非常多不同的硬件架構,每種架構都有自己對應的機器碼解釋規則。顯然像iOS那樣一步到位是不現實的。

那怎麼辦呢?既然扔不掉字節碼這個中間商,那我們只能剝削他咯,讓整個解釋的過程快一點,再快一點。而解釋所在的“工廠”在虛擬機內。

接下來就是偉大的Android虛擬機進化之路!

① Andorid 1.0 Dalvik(DVM)+解釋器

DVM是Google開發的Android平臺虛擬機,可讀取.dex的字節碼。
上文中所說的從字節碼解釋成機器碼的過程在Java虛擬機中,在Android平臺中虛擬機指的就是這個DVM。
在Android1.0時期,程序一邊運行,DVM中的解釋器(翻譯機)一邊解釋字節碼。
可想而知,這樣效率絕對低下。一個字,卡。

② Android 2.2 DVM+JIT

其實解決DVM的問題思路很清楚,我們在程序某個功能運行前就解釋就可以了。

在Android2.2時期,聰明的谷歌引入了JIT(Just In Time)機制,直譯就是即時編譯。

舉個🌰,我經常去一家餐館吃飯,老闆已經知道我想吃什麼菜了,在我到之前就把菜準備好了,這樣我就省去了等菜的時間。

JIT就相當於這個聰明的老闆,它會在手機打開APP時,將用戶經常使用的功能記下來。當用戶打開APP的時候立馬將這些內容編譯出來,這樣當用戶打開這些內容時,JIT已經將’菜’準備好了。這樣就提高了整體效率。

雖然JIT挺聰明的,且總體思路清晰理想豐滿,但現實是仍然卡的要死。

存在的問題:

  1. 打開APP的時候會變慢
  2. 每次打開APP都要重複勞動,不能一勞永逸。
  3. 如果我突然點了一盤之前從來沒點過的菜,那我只好等菜了,所以如果用戶打開了JIT沒有準備好的’菜’,就只能等DVM中的解釋器去邊執行邊解釋了。

③ Android 5.0 ART+AOT

聰明的谷歌又想到個方法,既然我們能在打開APP的時候將字節碼編譯成機器碼,那麼我們何不在APP安裝的時候就把字節碼編譯成機器碼呢?這樣每次打開APP也不用重複勞動了,一勞永逸。

這確實是個思路,於是谷歌推出了ART來替代DVM,ART全稱Android Runtime,它在DVM的基礎上做了一些優化,它在應用被安裝的時候就將應用編譯成機器碼,這個過程稱爲AOT(Ahead-Of-Time),即預編譯

但是問題又來了,打開APP是不卡了,但是安裝APP慢的要死,可能有人會說,一個APP又不是會頻繁安裝,可以犧牲下這點時間。但是不好意思,安卓手機每次OTA啓動(即系統版本更新或刷機後)都會重新安裝所有APP,無奈吧!絕望吧!對,還記得那兩年,被安卓版本更新所支配的恐懼嗎!

④ Android 7.0 混合編譯

谷歌最終祭出了終極大招,DVM+JIT不好,ART+AOT又不好。行,我把他們都混合起來,那總可以了吧!

於是谷歌在Android7.0的時候,發佈了混合編譯。即安裝時先不編譯成機器碼,在手機不被使用的時候,AOT偷偷的把能編譯成機器碼的那部分代碼編譯了(至於什麼是能編譯的部分,下文字節碼的編譯模板詳述)。其實就是把之前APP安裝時候乾的活偷偷的在手機空的時候幹了。

如果來不及編譯的話,再把JIT和解釋器這對難兄難弟叫起來,讓他們去編譯或實時解釋。

不得不佩服谷歌這粗暴的解決問題的方式,這樣一來確實Android手機從萬年卡頓慢慢的坑中出來了。

⑤ Android 8.0 改進解釋器

在Android8.0時期,谷歌又盯上了解釋器,其實縱觀上面的問題,根源就是這個解釋器解釋的太慢了!(什麼JIT,AOT,老夫解釋只有一個字,快)那我們何不讓這個解釋器解釋的快一點呢?於是谷歌改進了解釋器,解釋模式執行效率大大提升。

⑥ Android 9.0 改進編譯模板

這個點會在下文字節碼的編譯模板中詳述。

這邊簡單而言就是,在Android9.0上提供了預先放置熱點代碼的方式,應用在安裝的時候就能知道常用代碼會被提前編譯。(借用知乎@weishu大神的原話)

2.JNI——Java和C互相調用慢

JNI又稱爲 Java Native Interface,翻譯過來就是Java原生接口,就是用來跟C/C++代碼交互的。

如果不做Android開發的可能不知道,Android項目裏的代碼除了Java,很有可能還有部分C語言的代碼。

這個時候有個嚴重的問題,首先上圖 (圖片參考方舟編譯器原理PPT):

在這裏插入圖片描述

在開發階段Java源代碼在開發階段打包成.dex文件,C語言直接就是.so庫,因爲C語言本身就是編譯語言。

在用戶手機中,APK中的.dex文件(字節碼)會被解釋爲.oat文件(機器碼)運行在ART虛擬機中,.so庫則爲計算機可以直接運行的二進制代碼(機器碼),兩份機器碼要互相調用肯定是有開銷的。

下面就來闡述下爲什麼兩份機器碼會不同。

這邊需要深入理解字節碼->機器碼的編譯過程,在圖上雖然都被編譯成了機器碼,都能被硬件直接調用,但是兩份機器碼的性能,效率,實現方式相差甚多,這主要是由以下兩個點造成的:

  1. 編程語言不同導致編譯出的字節碼不同導致編譯出的機器碼不同。

    舉個🌰,針對同樣是靜態語言的C和Java,對int a + b 的運算

    C語言可以直接加載內存,在寄存器中計算,這是由於C語言是靜態語言,a和b是確定的int對象。

    在Java中雖然定義對象我們也要明確的指出對象的類型,例如int a = 0,但是Java擁有動態性,Java擁有反射,代理,誰也不敢 保證a在被調用時還是int類型,所以Java的編譯需要考慮上下文關係,即具體情況具體編譯。

    所以連字節碼已經不同了,編譯出的機器碼肯定不同。

  2. 運行環境不同導致編譯出的機器碼不同

    圖中明顯看到由Java編譯而來的機器碼包裹在ART中,ART全稱Android RunTime,即安卓運行環境,跟虛擬機差不多是一個意思。而C語言所在的運行環境不在ART中。

    RunTime提供了基本的輸入輸出或是內存管理等支持,如果要在兩個不同的RunTime中互相調用,則必然有額外開銷。

    舉個🌰,由於Java有GC(垃圾回收機制),在Java中的一個對象地址不是固定的,有可能被GC挪動了。即在ART環境中跑的機器碼中的對象的地址不固定。可是C語言哪管那麼多幺蛾子,C就直接問Java要一個對象的地址,但萬一這個對象地址被挪動了,那就完蛋了。解決方案有兩個:

    • 把這個對象在C裏再拷一份。很明顯這造成了很大的開銷。
    • 告訴ART,我要用這個對象了,GC這個對象的地址你不能動!你先一邊呆着去。這樣相對而言開銷倒是小了,但如果這個地址如果一直不能被回收的話,可能造成OOM。

(此處參考知乎@張鐸在華爲公佈的方舟編譯器到底對安卓軟件生態會有多大影響?中的回答)

3.字節碼的編譯模板——未針對具體APP進行優化

我們舉個🌰來理解編譯模版,“Hello
world”可以被翻譯爲“你好,世界”,同樣也可以被翻譯爲“世界,你好”,這個差別就是編譯模版不同導致的,

①. 統一的編譯模版(vm模版)

字節碼可以通過不同的編譯模版被編譯爲機器碼,而編譯模版的不同將直接導致編譯完後的機器碼性能大相徑庭。
在這裏插入圖片描述

在安卓中,ART有一套規定的,統一的編譯模版,暫且稱爲VM模版,這套模版雖算不上差勁,但也算不上優秀。

因爲它是谷歌爸爸搞出來的,肯定算不上差勁,但由於沒有針對每一個APP進行特定的優化,所以也算不上優秀。

②. vm模版存在的問題

問題就存在於沒有針對每一個APP進行優化。

在上文谷歌對於Android2.2的虛擬機優化中已經講到過,那時候谷歌使用JIT將用戶常用的功能記下來(熱點代碼),當用戶打開APP的時候立馬將這些內容編譯出來,即優先編譯熱點代碼。

但是到了Android7.0的混合編譯時代,由於AOT的存在,這個功能被弱化了,這時JIT記錄下的熱點代碼並非是持久化的。AOT的編譯優先級遵循於vm模版,AOT根據模板的內容將一些字節碼優先編譯爲機器碼。

那麼這個時候就產生了一個問題。

先舉個🌰,一家中餐館的招牌菜是番茄炒蛋,那麼番茄炒蛋的備菜肯定很足,但是顧客A特立獨行,他偏偏不要吃番茄炒蛋,他每次都點一個冷門的牛排套餐,那這時候只能讓顧客等着老闆將牛排套餐做完。

如果一個APP的熱點代碼(如首頁),剛好遊離於VM模板之外,那麼AOT就其實形同虛設了。(比如vm模版優先編譯名稱不大於15個字符的類和方法,但是首頁的類名剛好高於15個字符。此處僅爲舉例並沒有實際論證過)

下面用首頁和設置頁來舉例:由於遵循vm模版,AOT因爲某個原因沒有優先編譯首頁部分代碼,而轉而去編譯了不太重要的設置頁代碼:

在這裏插入圖片描述

上圖的流程說明了在特殊情況下,AOT編譯實則不起作用,完全是靠解釋器和JIT在進行實時編譯,整個編譯方案退步到了Android2.2時期。

③. 聰明的ART

雖然這個問題存在,但並不是特別嚴重。因爲ART並沒有我說的那麼笨。在之後應用使用過程中,ART會記錄並學習用戶的使用習慣(保存熱點代碼),然後更新針對當前APP的定製化vm模版,不斷的補充熱點代碼,補充定製化模版。

這是不是聽起來很熟悉?在手機發布大會上的宣傳語“基於用戶操作習慣進行學習,APP打開速度不斷提高”的部分原理就是這個。

④. 最終大招,一勞永逸

其實要一勞永逸的解決這個問題思路也不難:我們只需要在吃飯前跟老闆提前預定想吃啥就行,讓老闆先準備起來,這樣等我們到了就不用等餐了。

在最新的Android9.0版本中,谷歌推出了這個類似提前預定的功能:編譯系統支持在具有藍圖編譯規則的原生 Android 模塊上使用 Clang 的配置文件引導優化 (PGO)。

說人話:谷歌允許你在開發階段添加一個配置文件,這個配置文件內可指定“熱點代碼”,當應用安裝完後,ART在後臺悄悄編譯APP時,會優先編譯配置文件中指定的“熱點代碼”。

雖然谷歌支持,但是這塊技術對於APP開發人員而言國內資料過於缺乏,普及面不廣。筆者先貼上官方鏈接,以及這篇博客,其中介紹的還是挺詳細的。(隔壁Xcode針對PGO都有UI界面了)

三、解決思路

解決思路總結爲四個字就是:華爲方舟
方舟的解決思路:

  1. 針對虛擬機問題,方舟說:我不要你這個爛虛擬機了,我們裸奔
  2. 針對JNI調用問題,方舟說:我們讓Java在編譯階段跟C一樣直接編譯成機器碼,幹掉虛擬機,跟.so庫直接調用,毫無JNI開銷問題
  3. 針對編譯模版問題,方舟說:我們支持針對不同APP進行不同的編譯優化

總結一下:方舟支持在打包編譯階段針對不同APP進行不同的編譯優化,然後直接打包成機器碼.apk(很可能已經不叫apk了),然後直接運行。

這樣看起來方舟確實解決掉了三大問題,但是,代價呢?

如果按照這個思路,方舟就肯定不止是一個編譯器了,它應該還有一套自己的runtime。當然這些都是後話了。

關於方舟的實現只是大概講了思路,但沒有深入,因爲一來方舟沒開源,二來方舟發佈會PPT營銷層面更多,技術細節缺少,現在奇思妙想完全是紙上談兵,一切還是靜待開源吧。

四、程序員不背卡頓的鍋!

自從發表文章以來,收到了一些反饋,其中有一種聲音是:

造成卡頓的主要原因是垃圾代碼和保活,全家桶等國產軟件的鍋。

對這一點,我不可否認,垃圾代碼,保活策略,全家桶是很噁心。

但是如果要將這些影響上升爲造成卡頓的主要原因,

筆者認爲你們是太看得起自己的垃圾代碼負優化能力了,還是太看不起小米,華爲這些系統生產廠家了,還是覺得天底下的iOS人手水平高Android一個層次呢?

如果一定要說垃圾代碼造成了卡頓,也請去理解下哪些代碼是所謂的垃圾代碼,比如某些代碼造成了內存抖動和GC頻繁回收造成了卡頓,不要就扔下一句,垃圾代碼然後讓程序員背了所有的鍋。都9102年了,別再隨便甩鍋給程序員了!,也請那些這樣認爲的人別再妄自菲薄了!

至於保活,在現在的華爲小米等系統里弄一個全天候保活,互相拉起的進程,大概就會像黑進阿里的黑客一樣,第二天去公司報道吧。

至於一些千元機的卡頓問題,可以瞭解下Google新推的Android Go系統,這個系統下的APP開發要求異常的苛刻。

五、參考資料

  1. 華爲公佈的方舟編譯器到底對安卓軟件生態會有多大影響?
  2. 華爲新貴!方舟編譯器的榮光和使命
  3. 一文看懂華爲方舟編譯器,安卓的一大進步
  4. What does a JVM have to do when calling a native method?
  5. 關於Dalvik、ART、DEX、ODEX、JIT、AOT、OAT
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章