轉載于? http://www.uml.org.cn/mobiledev/201211063.asp#2
緊接連載三,我們接下從性能的角度分別分析Android系統為應用程序提供的支撐。
1.4 性能
Android使用Java作為編程語言,這一直被認為是一局雄心萬丈,但兇險異常的險棋。Java的好處是多,前面我們只是列舉了一小部分,但另一種普遍的現象是,Java在圖形編程上的應用環境并不是那么多。除了出于Java編程的目的,我們是否使用過Java編寫的應用程序?我們的傳統手機一般都支持Java ME版本,有多少人用過?我們是否見過Java寫就的很流暢的應用程序?是否有過流行的Java操作系統?答應應該是幾乎為零。通過這些疑問,我們就可以多少了解,Android這個Java移動操作系統難度了,還不用說,我們前面看到各種設計上的考量。
首先,得給Java正名,并非Java有多么嚴重的性能問題,事實上,Java本是一種高效的運行環境。Android在設計上的兩個選擇,一個Linux內核,一個Java編程語言,都是神奇的萬能膠,從服務器、桌面系統、嵌入式平臺,它們都在整個計算機產業里表現了強大的實力,在各個領域里都霸主級的神器(當然,兩者在桌面領域表現都不是很好,都受到了Windows的強大壓力)。從Java可以充當服務器的編程語言,我們就可以知道Java本身的性能是夠強大的。標準的Java虛擬機還有個特點,性能幾乎會與內存與處理器能力成正比,內存越大CPU能力越強,當JIT的威力完全發揮出來時,Java語言的性能會比C/C++寫出來的代碼的性能還要好。
但是,這不是嵌入式環境的特色,嵌入式設備不僅是CPU與內存有限,而且還不能濫用,需要盡可能節省電源的使用。這種特殊的需求下,還需要使用Java虛擬機,就得到一個通用性極佳,但幾乎無用的JAVA ME環境,因為JAVA ME的應用程序與本地代碼的性能差異實在太大了。在iPhone引發的“后PC時代”之前,大家沒得選擇,每臺手機上幾乎都支持,但使用率很低。而我們現在看到的Android系統,性能上是絕對不成問題,即便是面對iPhone這樣大量使用加速引擎的嵌入式設備,也沒有明確差異,這里面竅門在哪里呢?
我們前面也說明了,Java在嵌入式環境里有性能問題,運行時過低的執行效率會引發使用體驗問題,同時還有版權問題,幾乎沒有免費的嵌入式Java解決方案。如果正向去解,困難重重,那我們是否可以嘗試逆向的解法呢?
在計算機工程領域,逆向思維從來都是重要武器之一,當我們在設計、算法上遇到問題時,正向解決過于痛苦時,可以逆向思考一下,往往會有奇效。比如我們做算法優化,拼了命使用各種手法提高性能,收效也不高,這時我們也可以考慮一下降低其他部分的性能。成功案例在Android里就有,在Android環境里,大量使用Thumb2這樣非優化指令,只優化性能需求最強的代碼,這時系統整體的性能反而比全局優化性能更好。我們做安全機制,試驗各種理論模型,最終都失敗于復雜帶來的開銷,而我們反向思考一下,借用進程模型來重新設計,這時我們就得到了“沙盒”這種模型,反而簡便快捷。只要思路開闊,在計算機科學里條條大路通羅馬,應該是沒有解不掉的問題的。
回到Android的Java問題,如果我們正向地順著傳統Java系統的設計思路會走入痛苦之境,何不逆向朝著反Java的思路去嘗試呢?這時還帶來了邊際收益,Java語言的版權問題也繞開了。于是,天才的Android設計者們,得到了基于Dalvik虛擬機方案:
基于寄存器式訪問的Dalvik VM
Java語言天生是用于提升跨平臺能力的,于是在設計與優化時,考慮得更多是如何兼容更多的環境,于它可以運行在服務器級別的環境里,也在運行在嵌入式環境。這種可伸縮的能力,便源自于它使用棧式的虛擬機實現。所謂的棧式虛擬機,就是在Java執行環境在邊解釋邊執行的時候,盡可能使用棧來保存操作數與結果,這樣可設計更精練的虛擬指令的翻譯器,性能很高,但麻煩就在于需要過多地訪問內存,因為棧即內存。
比如,我們寫一個最簡單的Java方法,讀兩個操作數,先加,然后乘以2,最后返回:
public int test01( int i1, int i2 ) {
int i3 = i1 + i2;
return i3 * 2;
}
這樣的代碼,使用Java編譯器生成的.class文件里,最后就會有這樣的偽代碼(虛擬機解析執行的代碼):
0000: iload_1 // 01
0001: iload_2 // 02
0002: iadd
0003: istore_3 // 03
0004: iload_3 // 03
0005: iconst_2 // #+02
0006: imul
0007: ireturn
出于棧式虛擬機的特點,這樣的偽指令追成的結果會操作到棧。比如iload_1, iload_2,iadd,istore_3,這四行,就是分別將參數1,2讀取到棧里,再調用偽指令add將棧進行加操作,然后將結果寫入棧頂,也就是操作數3。這種操作模式,會使我們的虛擬機在偽指令解析器上實現起來簡單,因為簡單而可以實現得高效,因為偽指令必然很少(事實上,通用指令用8位的opcode就可以表達完)。可以再在這種設計基礎上,針對不同平臺進行具體的優化,由于是純軟件的算法,通用性非常好,無論是32位還是64位機器,都可以很靈活地針對處理器特點進行針對性的實現與優化。而且這種虛擬機實現,相當于所有操作都會在棧上有記錄,對每個偽指令(opcode)都相當于一次函數調用,這樣實現JIT就更加容易。
但問題是內存的訪問過多,在嵌入式設備上則會造成性能問題。嵌入式平臺上,出于成本與電池供電的因素,內存是很有限的,Android剛開始時使用的所謂頂級硬件配置,也才不過192MB。雖然現在我們現在的手機動不動就上G,還有2G的怪獸機,但我們也不太可能使用太高的內存總線頻率,頻率高了則功耗也就會更高,而內存總線的功耗限制也使內存的訪問速度并不會太高,與PC環境還是有很大差異的。所以,直到今天,標準Java虛擬的ARM版本也沒有實現,只是使用JAVA ME框架里的一種叫KVM的一種嵌入式版本上的特殊虛擬機。
基于Java虛擬機的實現,于是這時就可以使用逆向思維進行設計,棧式虛擬機的對立面就是寄存器式的。于是Android在系統設計便使用了Dan Bornstein開發的,基于寄存器式結構的虛擬機,命名源自于芬蘭的一個小鎮Dalvik,也就是Dalivk虛擬機。雖然Dalvik虛擬機在實現上有很多技巧的部分,有些甚至還是黑客式的實現,但其核心思想就是寄存器式的實現。
所謂的寄存器式,就是在虛擬機執行過程中,不再依賴于棧的訪問,而轉而盡可能直接使用寄存器進行操作。這跟傳統的編程意義上的基于寄存器式的系統構架還是有概念上的區別,即便是我們的標準的棧式的標準Java虛擬機,在RISC體系里,我們也會在代碼執行被優化成大量使用寄存器。而這里所指的寄存器,是指執行過程里的一種算法上的思路,就是不依賴于棧式的內存訪問,而通過偽指令(opcode)里的虛擬寄存器來進行翻譯,這種虛擬寄存器會在運行態被轉義成空閑的寄存器,進行直接數操作。如果這里的解釋不夠清晰,大家可以把棧式看成是正常的函數式訪問,幾乎所有的語言都基于棧來實現函數調用,此時虛擬機里每個偽指令(opcode),都類似于基于棧進行了函數調用。而基于寄存器式,則可以被看成是宏,宏在執行之前就會被編譯器翻譯成直接執行的一段代碼,這時將不再有過多的壓棧出棧操作,而是會盡可能使用立即數進行運算。
我們上面標準虛擬機里編譯出來的代碼,經過dx工具轉出來的.dex文件,格式就會跟上面很不一樣:
代碼編譯偽指令
9000 0203 0000 add-int v0, v2, v3
da00 0002 0002 mul-int/lit8 v0, v0, #int 2 // #02
0f00 0004 return v0
對著代碼,我們可以看到(或者可以猜測),在Dalvik的偽指令體系里,指令長度變成了16bit,同時,根本去除了棧操作,而通過使用00, 02, 03這樣的虛擬寄存器來進行立即數操作,操作完則直接將結果返回。大家如果對Dalvik的偽指令體系感興趣,可以參考Dalvik的指令說明:http://source.android.com/tech/dalvik/dalvik-bytecode.html
像Dalvik這樣虛擬機實現里,當我們進行執行的時候,我們就可以通過將00,02,03這樣的虛擬寄存器,找一個空閑的真實的寄存器換上去,執行時會將立即數通過這些寄存器進行運算,而不再使用頻繁的棧進行存取操作。這時得到的代碼大小、執行性能都得到了提升。
如果寄存器式的虛擬機實現這么好,為什么不大家都使用這種方式呢?也不是沒有過嘗試,寄存器試的虛擬機實現一直是學術研究上的一個熱點,只是在Dalvik虛擬機之前,沒有成功過。寄存器式,在實際應用中,未必會比棧式更高效,而且如果是通用的Java虛擬機,需要運行在各種不同平臺上,寄存器式實現還有著天生的缺陷。比如說性能,我們也看到在Dalvik的這種偽指令體系里,使用16位的opcode用于實現更多支持,沒有棧訪問,則不得不靠增加opcode來彌補,再加需要進行虛擬寄存器的換算,這時解析器(Interpreter)在優化時就遠比簡單的8位解析器要復雜得多,復雜則優化起來更困難。從上面的函數與宏的對比里,我們也可以看到寄存器實現上的毛病,代碼的重復量會變大,原來不停操作棧的8bit代碼會變成更長的加寄存器操作的代碼,理論上這種代碼會使代碼體系變大。之所以在前面我們看到.dex代碼反而更精減,只不過是Dalvik虛擬機進行了犧牲通用性代碼固化,這解決了問題,但會影響到偽代碼的可移植性。在棧式虛擬機里,都使用8bit指令反復操作棧,于是理論上16bit、32bit、64bit,都可以有針對性的優化,而寄存器式則不可能,像我們的Dalvik虛擬機,我們可以看到它的偽代碼會使用32位里每個bit,這樣的方式不可能通用,16bit、32bit、64bit的處理器體系里,都需要重新設計一整套新的指令體系,完全沒有通用性。最后,所有的操作都不再經過棧,則在運行態要得到正確的運算操作的歷史就很難,于是JIT則幾乎成了不可能完成的任務,于是Dalvik虛擬機剛開始則宣稱JIT是沒有必要,雖然從2.2開始加入了JIT,但這種JIT也只是統計意義上的,并不是完整意義上的JIT運算加速。所有這些因素,都導致在標準Java虛擬機里,很難做出像Dalvik這樣的寄存器式的虛擬機。
幸運的是,我們上面說的這些限制條件,對Android來說,都不存在。嵌入式環境里的CPU與內存都是有限的資源,我們不太可能通過全面的JIT提升性能,而嵌入式環境以寄存器訪問基礎的RISC構架為主,從理論上來說,寄存器式的虛擬機將擁有更高的性能。如果是嵌入式平臺,則基本上都是32位的處理器,而出于功耗上的限制,這種狀況將持續很長一段時間,于是代碼通用性的需求不是那么高。如果我們放棄全面支持Java執行環境的兼容性,進一步通過固化設計來提升性,這時我們就可以得到一個有商用價值的寄存器式的虛擬機,于是,我們就得到了Dalvik。
Dalvik虛擬機的性能上是不是比傳統的棧式實現有更高性能,一直是一個有爭議的話題,特別是后面當Dalvik也從2.2之后也不得不開始進行JIT嘗試之后。我們可以想像,基于前面提到的寄存器式的虛擬機的實現原理,Dalvik虛擬機通過JIT進行性能提升會遇到困難。在Dalvik引入JIT后,性能得到了好幾倍的提升,但Dalvik上的這種JIT,并非完整的JIT,如果是棧式的虛擬機實現,這方面的提升會要更強大。但Dalvik實現本身對于Android來講是意義非凡的,在Java授權上繞開了限制,更何況在Android誕生時,嵌入式上的硬件條件極度受限,是不太可能通過棧式虛擬機方式來實現出一個性能足夠的嵌入式產品的。而當Android系統通過Dalvik虛擬機成功殺出一條血路,讓大家都認可這套系統之后,圍繞Android來進行虛擬機提速也就變得更現實了,比如現在也有使用更好的虛擬機來改進Android的嘗試,比如標準棧式虛擬機,使用改進版的Java語言的變種,像Scalar、Groovy等。
我們再看看,在寄存器式的虛擬機之外,Android在性能設計上的其他一些特點。
以Android API為基礎,不再以Java標準為基礎
當我們對外提供的是一個系統,一種平臺之時,就必須要考慮到系統的可持續升級的能力,同時又需要保持這種升級之后的向后兼容性。使用Java語言作為編程基礎,使我們的Android環境,得到了另一項好處,那就是可兼容性的提升。
在傳統的嵌入式Linux方案里,受限于有限的CPU,有限的內存,大家還沒有能力去實施一套像Android這樣使用中間語言的操作系統。使用C語言還需要加各種各樣的加速技巧才能讓系統可以運行,基至有時還需要通過硬件來進行加速,再在這種平臺上運行虛擬機環境,則很不靠譜。這樣的開發,當然沒有升級性可言,連二次開發的能力都很有限,更不用說對話接口了,所謂的升級,僅僅是增加點功能,修改掉一些Bug,再加入再多Bug。而使用機器可以直接執行的代碼,就算是能夠提供升級和二次開發的能力,也會有嚴重問題。這樣的寫出來的代碼,在不同體系架構的機器(比如ARM、X86、MIPS、PowerPC)上,都需要重新編譯一次。更嚴重的是,我們的C或者C++,都是通過參數壓棧,再進行指令跳轉來進行函數調用的,如果升級造成了函數參數變動,則還必須修改所開發的源代碼,不然會直接崩潰掉。而比較幸運的是,所有的嵌入式Linux方案,在Android之前,都沒有流行開,比較成功的最多也不過自己陪自己玩,份額很小,大部分則都是紅顏薄命,出生時是demo,消亡時也是demo。不然,這樣的產品,將來維護起來也會是個很吐血的過程。
而Android所使用的Java,從一開始就是被定位于“一次編寫,到處運行”的,不用說它極強大的跨平臺能力,就是其升級性與兼容性,也都是Java語言的制勝法寶之一。Java編譯生成的結果,是.class的偽代碼,是需要由虛擬器來解析執行的,我們可以提供不同體系構架里實現的Java虛擬機,甚至可以是不同產商設計生產的Java虛擬機,而這些不同虛擬機,都可以執行已經編譯過的.class文件,完全不需要重新編譯。Java是一種高級語言,具有極大的重用性,除非是極端的無法兼容接口變動,都可以通過重載來獲得更高可升級能力。最后,Java在歷史曾應用于多種用途的運行環境,于是定義針對不同場合的API標準,這些標準一般被稱為JSR(Java Specification Request),特別是嵌入式平臺,針對帶不帶屏幕、屏幕大小,運算能力,都定義了詳細而復雜的標準,符合了這些標準的虛擬機應該會提供某種能力,從而保證符合同一標準的應用程序得以正常執行。我們的JAVA ME,就是這樣的產物,在比較長周期內,因為沒有可選編程方案,JAVA ME在諸多領域里都成為了工業標準,但性能不佳,實用性較差。
JAVA ME之所以性能會不佳的一個重要原因,是它只是一種規范,作為規范的東西,則需要考慮到不同平臺資源上的不同,不容易追求極致,而且在長期的開發與使用的歷史里,一些歷史上的接口,也成為了進一步提升的負擔。Android則不一樣,它是一個新生事物,它不需要遵守任何標準,即使它能夠提供JAVA ME兼容,它能得到資源回報也不會大,而且會帶來JAVA ME的授權費用。于是,Android在設計上就采取了另一次的反Java設計,不兼容任何Java標準,而只以Android的API作為其兼容性的基礎,這樣就沒有了歷史包袱,可以輕裝上陣進行開發,也大大減小了維護的工作量。作為一個Java寫的操作系統,但又不兼容任何Java標準,這貌似是比較諷刺的,但我們在整個行業內四顧一下,大家應該都會發現這樣一種特色,所有不支持JAVA ME標準的系統,都發展得很好,而支持JAVA ME標準,則多被時代所淘汰。這不能說JAVA ME有多大的缺陷,或是晦氣太重,只不過靠支持JAVA ME來提供有限開發能力的系統,的確也會受限于可開發能力,無法走得太遠罷了。
這樣會不會導致Java寫的代碼在Android環境里運行不起來呢?理論上來說不會。如果是一些Java寫的通用算法,因為只涉及語言本身,不存在問題。如果是代碼里涉及一些基本的IO、網絡等通用操作,Android也使用了Apache組織的Harmony的Java IO庫實現,也不會有不兼容性。唯一不能兼容的是一些Java規范里的特殊代碼,像圖形接口、窗口、Swing等方面的代碼。而我們在Android系統里編程,最好也可以把算法與界面層代碼分離,這樣可以增加代碼復用性,也可以保證在UI編程上,保持跟Android系統的兼容性。
Android的版本有兩層作用,一是描述系統某一個階段性的軟硬件功能,另外就是用于界定API的規范。描述功能的作用,更多地用于宣傳,用于說該版本的Android是個什么東西,也就是我們常見的食物版本號,像éclair(2.0,2.1),Froyo(2.2), Gingerbread(2.3),Icecream Sandswich(4.0),Jelly Bean(4.1),大家都可以通過這些美味的版本號,了解Android這個版本有什么功能,有趣而易于宣傳。而對于這樣的版本號,實際上也意味著API接口上的升級,會增加或是改變一些接口。
所謂的API版本,是位于應用程序層與Framework層之間的一層接口層,如下所示:
應用程序只通過AndroidAPI來對下進行訪問,而我們每一個版本的Android系統,都會通過Framework來對上實現一套完整的API接口,提供給應用程序訪問。只要上下兩層這種調用與被調用的需求能夠在一定范圍內合拍,應用程序所需要的最低API版本低于Framework所提供的版本,這時應用程序就可以正常執行。從這個意義來說,API的版本,更大程度算是Android Framework實現上的版本,而Android系統,我們也可以看成就是Android的Framework層。
這種機制在Android發展過程中一直實施得很好,直到Android 2.3,都保持了向前發展,同時也保持向后兼容。Android 2.3也是Android歷史上的一個里程碑,一臺智能手機所應該實現的功能,Android2.3都基本上完成了。但這時又出現了平板(pad)的系統需求,從2.3又發展出一個跟手機平臺不兼容的3.0,然后這兩個版本再到4.0進行融合。在2.3到4..0,因為運行機制都有大的變動,于是這樣的兼容性遇到了一定的挑戰,現在還是無法實現100%的從4.0到2.3的兼容。
只兼容自己API,是Android系統自信的一種體現,同時,也給它帶來另一個好處,那就是可以大量使用JNI加速。
大量使用JNI加速
JNI,全稱是Java本地化接口層(Java Native Interface),就是通過給Java虛擬機加動態鏈接庫插件的方式,將一些Java環境原本不支持功能加入到系統中。
我們前面說到過Dalvik虛擬機在JIT實現上有缺陷,這點在Android設計人員的演示說明里,被狡猾地掩蓋了。他們說,在Android編程里,JIT不是必須的,Android在2.2之前都不提供JIT支持,理由是應用程序不可能太復雜,同時Android本身是沒有必要使用JIT,因為系統里大部分功能是通過JNI來調用機器代碼(Native代碼)來實現的。這點也體現了Android設計人員,作為技術狂熱者的可愛之處,類似這樣的錯誤還不少,比如Android剛開始的設計初衷是要改變大家編程的習慣,要通過Android應用程序在概念上的封裝,去除掉進程、線程這樣底層的概念;甚至他們還定義了一套工具,希望大家可以像玩積木一樣,在圖形界面里拖拉一下,在完全沒有編程背景的情況下也可以編程;等等。這些錯誤當然也被Android強大的開源社區所改正了。但對于Android系統性能是由大量JNI來推進的,這點診斷倒沒有錯,Android發展也一直順著這個方向在走。
Android系統實現里,大量使用JNI進行優化,這也是一個很大的反Java舉動,在Java的世界里,為了保持在各個環境的兼容性,除了Java虛擬機這個必須與底層操作系統打交道的執行實體,以及一些無法繞開底層限制的IO接口,Java環境的所有代碼,都盡可能使用Java語言來編寫。通過這種方式,可以有效減小平臺間差異所引發的不兼容。在Java虛擬機的開發文檔里,有詳盡的JNI編程說明,同時也強烈建議,這樣的編程接口是需要避免使用的,使用了JNI,則會跟底層打上交道,這時就需要每個體系構架,每個不同操作系統都提供實現,并每種情況下都需要編譯一次,維護的代價會過高。
但Android則是另一個情況,它只是借用Java編程語言,應用程序使用的只是Android API接口,不存在平臺差異性問題。比如我們要把一個Android環境運行在不那么流行的MIPS平臺之上,我們需要的JNI,是Android源代碼在MIPS構架上實現并編譯出來的結果,只要API兼容,則就不存在平臺差異性。對于系統構架層次來說,使用JNI造成的差異性,在Framework層里,就已經被屏蔽掉了:
如上圖所示,Android應用程序,只知道有Framework層,通過API接口與Framework通信。而我們底層,在Library層里,我們就可以使用大量的JNI,我們只需要在Framework向上的部分保持統一的接口就可以了。雖然我們的Library層在每個平臺上都需要重新編譯(有些部分可能還需要重新實現),但這是平臺產商或是硬件產商的工作,應用程序開發者只需要針對API版本寫代碼,而不需要關心這種差異性。于是,我們即得到高效的性能(與純用機器實現的軟件系統沒有多少性能差異),以使用到Java的一些高級特性。
單進程虛擬機
使用單進程虛擬機,是Android整個設計方案里反Java的又一表現。我們前面提到了,如果在Android系統里,要構建一種安全無憂的應用程序加載環境,這時,我們需要的是一種以進程為單位的“沙盒(Sandbox)”模型。在實現這種模型時,我們可以有多種選擇,如果是遵循Java原則的話,我們的設計應該是這個樣子的:
我們運行起Java環境,然后再在這個環境里構建應用程序的基于進程的“小牢房”。按照傳統的計算機理論,或是從資源有效的角度考慮,特別是如果需要使用棧式的標準Java虛擬機,這些都是沒話說的,只有這種構建方式才最優。
Java虛擬機,之所以被稱為虛擬機,是因為它真通過一個用戶態的虛擬機進程虛擬出了一個虛擬的計算機環境,是真正意義上的虛擬機。在這個環境里,執行.class寫出來的偽代碼,這個世界里的一切都是由對象構成的,支持進程,信號,Stream形式訪問的文件等一切本該是實際操作系統所支持的功能。這樣就抽象出來一個跟任何平臺無關的Java世界。如果在這個Java虛擬世界時打造“沙盒”模式,則只能使用同一個Java虛擬機環境(這不光是進程,因為Java虛擬機內部還可以再創建出進程),這樣就可以通過統一的垃圾收集器進行有效的對象管理,同時,多進程則內存有可能需要在多個進程空間里進行復制,在使用同一個Java虛擬機實例里,才有可能通過對象引用減小復制,不使用統一的Java虛擬機環境管理,則可復用性就會很低。
但這種方案,存在一些不容易解決的問題。這種單Java虛擬機環境的假設,是建立在標準Java虛擬機之上的,但如前面所說,這樣的選擇困難重重,于是Android是使用Dalvik虛擬機。這種單虛擬機實例設計,需要一個極其強大穩定的虛擬機實現,而我們的Dalvik虛擬機未必可以實現得如此功能復雜同時又能保證穩定性(簡單穩定容易,復雜穩定則難)。Android必須要使用大量的JNI開發,于是會進一步破壞虛擬機的穩定性,如果系統里只有一個虛擬機實例,則這個實例將會非常脆弱。當在Java環境里的進程,有惡意代碼或是實現不當,有可能破壞虛擬機環境,這時,我們只能靠重啟虛擬機來完成恢復,這時會影響到虛擬機里運行的其他進程,失去了“沙盒”的意義。最后,虛擬機必須給預足夠的權限運行,才能保證核心進程可訪問硬件資源,則權限控制有可能被某個惡意應用程序破壞,從而失去對系統資源的保護。
Android既然使用是非標準的Dalvik虛擬機,我們就可以繼續在反Java的道路上嘗試得更遠,于是,我們得到的是Android里的單進程虛擬機模型。在基于Dalvik虛擬機的方案里,虛擬機的作用退回到了解析器的階段,并不再是一個完整的虛擬機,而只是進程中一個用于解析.dex偽代碼的解析執行工具:
在這種沙盒模式里,每個進程都會執行起一個Dalvik虛擬機實例,應用程序在編程上,只能在這個受限的,以進程為單位的虛擬機實例里執行,任何出錯,也只影響到這個應用程序的宿主進程本身,對系統,對其他進程都沒有嚴重影響。這種單進程的虛擬機,只有當這個應用程序被調用到時才予以創建,也不存在什么需要重啟的問題,出了錯,殺掉出錯進程,再創建一個新的進程即可。基于uid/gid的權限控制,在虛擬機之外的實現,應用程序完全不可能通過Java代碼來破壞這種操作系統級別的權限控制,于是保護了系統。這時,我們的系統設計上反有了更高的靈活度,我們可以放心大膽地使用JNI開發,同時核心進程也有可能是直接通過C/C++寫出來的本地化代碼來實現,再通過JNI提供給Dalvik環境。而由于這時,由于我們降低了虛擬機在設計上的復雜程序,這時我們的執行性能必然會更好,更容易被優化。
當然,這種單進程虛擬機設計,在運行上也會帶來一些問題,比如以進程以單位進行GC,數據必然在每個進程里都進行復制,而進程創建也是有開銷的,造成程序啟動緩慢,在跨進程的Intent調用時,嚴重影響用戶體驗。Java環境里的GC是標準的,這方面的開銷倒是沒法繞開,所以Android應用程序編程優化里的重要一招就是減小對象使用,繞開GC。但數據復制造成的冗余,以及進程創建的開銷則可以進行精減,我們來看看Android如何解決這樣的問題。
盡可能共享內存
在幾乎所有的Unix進程管理模型里,都使用延時分配來處理代碼的加載,從而達到減小內存使用的作用,Linux內核也不例外。所謂的進程,在Linux內核里只是帶mm(虛存映射)的task_struct而已,而所謂的進程創建,就是通過fork()系統調用來創建一個進程,而在新創建的進程里使用execve()系列的系統調用來執行新的代碼。這兩個步驟是分兩步進行,父進程調用fork(),子進程里調用execve():
上圖的實線代表了函數調用,虛線代碼內存引用。在創建一個進程執行某些代碼時,一個進程會調用fork(),這個fork()會通過libc,通過系統調用,轉入內核實現的sys_fork()。然后在sys_fork()實現里,這時就會創建新的task_struct,也就是新的進程空間,形成父子進程關系。但是,這時,兩個進程使用同一個進程空間。當被創建的子進程里,自己主動地調用了execve()系列的函數之后,這時才會去通過內核的sys_execve()去嘗試解析和加載所要執行的文件,比如a.out文件,驗證權限并加載成功之后,這時才會建立起新的虛存映射(mm),但此時雖然子進程有了自己獨立的進程空間,并不會分配實際的物理內存。于是有了自己的進程空間,當下次執行到時,才會通過一次缺頁中斷加載a.out的代碼段,數據段,而此時,libc.so因為兩個進程都需要使用,于是會直接通過一次內存映射來完成。
通過Linux的進程創建,我們可以看到,進程之間雖然有獨立的空間,但進程之間會大量地通過頁面映射來實現內存頁的共享,從而減小內存的使用。雖然在代碼執行過程中都會形成它自己的進程空間,有各自獨立的內存類,但對于可執行文件、動態鏈接庫等這些靜態資源,則在進程之間會通過頁面映射進行共享進行共享。于是,可以得到的解決思路,就是如何加強頁面的共享。
加強共享的簡單一點的思路,就是人為地將所有可能使用到的動態鏈接庫.so文件,dalvik虛擬機的執行文件,都通過強制讀一次,于是物理內存里便有了存放這些文件內容的內存頁,其他部分則可以通過mmap()來借用這些被預加載過的內存頁。于是,當我們的用戶態進程被執行時,雖然還是同樣的執行流程,但因為內存里面有了所需要的虛擬機環境的物理頁,這時缺頁中斷則只是進行一次頁面映射,不需要讀文件,非常快就返回了,同時由于頁面映射只是對內存頁的引用,這種共享也減小實際物理頁的使用。我們將上面的fork()處理人為地改進一下,就可以使用如下的模式:
這時,對于任一應用程序,在dalvik開始執行前,它所需要的物理頁就都已經存在了,對于非系統進程的應用程序而言,它所需要使用的Framework提供的功能、動態鏈接庫,都不會從文件系統里再次讀取,而只需要通過page_fault觸發一次頁面映射,這時就可以大大提供加載時的性能。然后便是開始執行dalvik虛擬,解析.dex文件來執行應用程序的獨特實現,當然,每個classes.dex文件的內容則是需要各自獨立地進行加載。我們可以從.dex文件的解析入手,進一步加強內存使用。
Android環境里,會使用dx工具,將.class文件翻譯成.dex文件,.dex文件與.class文件,不光是偽指令不同,它們的文件格式也完全不同,從而達到加強共享的目的。標準的Java一般使用.jar文件來包裝一個軟件包,在這個軟件里會是以目錄結構組織的.class文件,比如org/lianlab/hello/Hello.class這樣的形式。這種格式需要在運行時進行解壓,需要進行目錄結構的檢索,還會因為.class文件里分散的定義,無法高效地加載。而在.dex文件里,所有.class文件里實現的內容,會合并到一個.dex文件里,然后把每個.class文件里的信息提取出來,放到同一個段位里,以便通過內存映射的方式加速文件的操作與加載。
這時,我們的各個不同的.class文件里內容被檢索并合并到同一個文件里,這里得到的.dex文件,有特定情況下會比壓縮過的.jar文件還要小,因為此時可以合并不同.class文件里的重復定義。這樣,在可以通過內存映射來加速的基礎上,也從側面降低了內存的使用,比如用于.class的文件系統開銷得到減小,用于加載單個.class文件的開銷也得以減小,于是得到了加速的目的。
這還不是全部,需要知道,我們的dalvik不光是一個可執行的ELF文件而已,還是Java語言的一個解析器,這時勢必需要一些額外的.class文件(當然,在Android環境里,因為使用了與Java虛擬機不兼容的Dalvik虛擬機,這樣的.class文件也會被翻譯成.dex文件)里提供的內容,這些額外的文件主要就是Framework的實現部分,還有由Harmony提供的一些Java語言的基本類。還不止于此,作為一個系統環境,一些特定的圖標,UI的一些控件資源文件,也都會在執行過程里不斷被用到,最好我們也能實現這部分的預先加載。出于這樣的目的,我們又會面臨前面的兩難選擇,改內核的page_fault處理,還是自己設計。出于設計上的可移植性角度考慮,還是改設計吧。這時,就可以得到Android里的第一個系統進程設計,Zygote。
我們這時對于Zygote的需求是,能夠實現動態鏈接庫、Dalvik執行進程的共享,同時它最好能實現一些Java環境里的庫文件的預加載,以及一些資源文件的加載。出于這樣的目的,我們得到了Zygote實現的雛形:
這時,Zygote基本上可以滿足我們的需求,可以加載我們運行一個應用程序進程除了classes.dex之外的所有資源,而我們前面也看到.dex這種文件格式本身也被優化過,于是對于頁面共享上的優化基本上得以完成了。我們之后的操作完全可以依賴于zygote進程,以后的設計里,我們就把所有的需要特權的服務都在zygote進程里實現就好了。
有了zygote進程則我們解決掉了共享的問題,但如果把所有的功能部分都放在Zygote進程里,則過猶不及,這樣的做法反而更不合適。Zygote則創建應用程序進程并共享應用程序程序所需要的頁,而并非所有的內存頁,我們的系統進程執行的絕大部分內容是應用程序所不需要的,所以沒必要共享。共享之后還會帶來潛在問題,影響應用程序的可用進程空間,另外惡意應用程序則可以取得我們系統進程的實現細節,反而使我們的辛辛苦苦構建的“沙盒”失效了。
Zygote,英文愿意是“孵化器”的意思,既然是這種名字,我們就可以在設計上盡可能保持其簡單性,只做孵化這么最簡單的工作,更符合我們目前的需求。但是還有一個實現上的小細節,我們是不是期望zygote通過fork()創建進程之后,每個應用程序自己去調用exec()來加載dalvik虛擬機呢?這樣實現也不合理,實現上很丑陋,還不安全,一旦惡意應用程序不停地調用到zygote創建進程,這時系統還是會由于創建進程造成的開銷而耗盡內存,這時系統也還是很脆弱的。這些應該是由系統進程來完成的,這個系統進程應該也需要兼職負責Intent的分發。當有Intent發送到某個應用程序,而這個應用程序并沒有被運行起來時,這時,這個系統進程應該發一個請求到Zygote創建虛擬機進程,然后再通過系統進程來驅動應用程序具體做怎么樣的操作,這時,我們的Android的系統構架就基本上就緒了。在Android環境里,系統進程就是我們的System Server,它是我們系統里,通過init腳本創建的第一個Dalvik進程,也就是說Android系統,本就是構建在Dalvik虛擬機之上的。
在SystemServer里,會實現ActivityManager,來實現對Activity、Service等應用程序執行實體的管理,分發Intent,并維護這些實體生命周期(比如Activity的棧式管理)。最終,在Android系統里,最終會有3個進程,一個只負責進程創建以提供頁面共享,一個用戶應用程序進程,和我們實現一些系統級權限才能完成的特殊功能的SystemServer進程。在這3種進程的交互之下,我們的系統會堅固,我們不會盲目地創建進程,因為應用程序完全不知道有進程這回事,它只會像調用函數那樣,調用一個個實現具體功能的Activity,我們在完成內存頁共享難題的同時,也完成Android系統設計的整體思路。
這時對于應用程序處理上,還剩下最后一個問題,如果加快應用程序的加載。
應用程序進程“永不退出”
雖然我們擁有了內存頁的預加載實現,但這還是無法保證Android應用程序執行上的高效性的。根據到現在為此我們分析到的Android應用程序支持,我們在這方面必將面臨挑戰。像Activity之間進行跳轉,我們如果處理跳轉出的Activity所依附的那個進程呢?直接殺死掉,這時,當我們從被調用Activity返回時怎么辦?
這也會是個比較復雜的問題。一是前一個進程的狀態如何處理,二是我們又如何對待上一個已經暫時退出執行的進程。
我們老式的應用程序是不存在這樣的問題的,因為它不具備跨進程交互的能力,唯一的有可能進行跨進程交互的方式是在應用程序之間進行復制/粘貼操作。而對于進程內部的界面之間的切換,實際上只會發生在同一個While循環里面,一旦退出某一個界面,則相應的代碼都不會被執行到,直到處理完成再返回原始界面:
而這種界面模型,在Android世界里,只是一個UI線程所需要完成的工作,跟界面交互倒并不相關。我們的Android 在界面上進行交互,實際上是在Activity之間進行切換,而每個進程內部再維護一套上述的UI循環體:
在這樣的運行模式下,如果我們退出了某一個界面的執行,則沒有必要再維持其運行,我們可以通過特殊的設計使其退出執行。但這種調用是無論處理完,還是中途取消,我們還是會回到上一個界面,如果要達到一體化看上去像同一個應用程序的效果,這里我們需要恢復上一個界面的狀態。比如我們例子里,我們打了聯系列表選擇了某個聯系人,然后通過Gallery設置大頭貼,再返回到聯系人列表時,一定要回到我們正在編譯聯系人的界面里。如果這時承載聯系人列表的進程已經退出了話,我們將要使整個操作重做一次,很低效。
所以綜合考慮,最好的方式居然會是偷懶,對上個進程完全不處理,而需要提供一種暫停機制,可以讓不處理活躍交互狀態的進程進入暫停。當我們返回時則直接可以到上次調用前的那個界面,這時對用戶來說很友好,在多個進程間協作在用戶看來會是在同一個應用程序進行,這才是Android設計的初衷。
因為針對需要暫停的處理,所以我們的應用程序各個實體便有了生命周期,這種生命周期會隨著Android系統變得復雜而加入更多的生命周期的回調點。但對于偷懶處理,則會有后遺癥,如果應用程序一直不退出,則對系統會是一個災難。系統會因為應用程序不斷增加而耗盡資源,最后會崩潰掉。
不光Android會有這樣的問題的,Linux也會有。我們一直都說Linux內核強勁安全,但這也是相對的,如果我們系統里有了一些流氓程序,也有可能通過耗盡資源的方式影響系統運行。大家可以寫一些簡單的例子做到這點,比如:
while(1)
{
char * buf = malloc (30 * 1000);
memset (buf, ‘a’, 30*1000);
if (!fork() )
fork();
}
這時會發現系統還是會受到影響,但Linux的健壯性表現在,雖然系統會暫時因為資源不足而變得響應遲緩,但還是可以保證系統不會崩潰。為了進程數過多而影響系統運行,Linux內核里有一種OOM Killer(Out Of Memory Killer)機制,系統里通過一種叫notifier的機制(顧名思義,跟我們的Listener設計模式類似的實現)監聽目前系統里內存使用率,當內存使用達到比率時,就開始殺掉一些進程,回收內存,這里系統就可以回到正常執行。當然,在真正發生Out Of Memory錯誤也會提前觸發這種殺死進程的操作。
一旦發生OOM事件,這時系統會通過一定規則殺死掉某種類型的進程來回收內存,所謂槍打出頭鳥,被殺的進程應該是能夠提供更多內存回收機會的,比如進程空間很大、內存共享性很小的。這種機制并不完全滿足Android需要,如果剛好這個“出頭鳥”就是產生調用的進程,或是系統進程,這時反而會影響到Android系統的正常運行。
這時,Android修改了Linux內核里標準的OOM Killer,取而代之是一個叫LowMemKiller的驅動,觸發Out Of Memory事件的不再是Linux內核里的Notifier,而由Android系統進程來驅動。像我們前面說明的,在Android里負責管理進程生成與Activity調用棧的會是這個系統進程,這樣在遇到系統內存不夠(可以直接通過查詢空閑內存來得到)時,就觸發Low Memory Killer驅動來殺死進程來釋放內存。
這種設計,從我們感性認識里也可以看到,用adb shell free登錄到設備上查看空閑內存,這時都會發現的內存的剩余量很低。因為在Android設備里,系統里空閑內存數量不低到一定的程度,是不會去回收內存的,Android在內存使用上,是“月光族”。Android通過這種方式,讓盡可能多的應用程序駐留在內存里,從而達到一個加速執行的目的。在這種模型時,內存相當于一個我們TCP協議棧里的一個窗口,盡可能多地進行緩沖,而落到窗口之外的則會被舍棄。
理論上來說,這是一種物盡其用,勤儉執家的做法,這樣使Android系統保持運行流暢,而且從側面也刺激了Android設備使用更大內存,因為內存越多則內存池越大,可同時運行的任務越多,越流暢。唯一不足之處,一些試圖縮減Android內存的廠商就顯得很無辜,精減內存則有可能影響Android的使用體驗。
我們經常會見到系統間的對比,說Android是真實的多任務操作系統,而其他手機操作平臺只是偽多任務的。這是實話,但這不是被Android作為優點來設計的,而只是整個系統設計迫使Android系統不得不使用這種設計,來維持系統的流暢度。至于多任務,這也是無心插柳柳成蔭的運氣吧。
Pre-runtime運算
在Android系統里,無論我們今天可以得到的硬件平臺是多么強大,我們還是有降低系統里的運算量的需求。作為一個開源的手機解決方案,我們不能假設系統具備多么強勁的運算能力,出于成本的考慮,也會有產商生產一些更廉價的低端設備。而即便是在一些高端硬件平臺之上,我們也不能浪費手機上的運算能力,因為我們受限于有限的電池供電能力。就算是將來這些限制都不存在,我們最好也還是減少不必要的損耗,將計算能力花到最需要使用它們的地方。于是,我們在前面談到的各種設計技巧之外,又增加了降低運算量的需求。
這些技巧,貌似更高深,但實際上在Android之前的嵌入式Linux開發過程里,大家也被迫干過很多次了。主要的思路時,所有跟運行環境無關運算操作,我們都在編譯時解決掉,與運行環境相關的部分,則盡可能使用固化設計,在安裝時或是系統啟動時做一次。
與運算環境無關的操作,在我們以前嵌入式開發里,Codec會用到,比如一些碼表,實際上每次算出來都是同樣或是類似的結構,于是我們可以直接在編譯時就把這張表算出來,在運行時則直接使用。在Android里,因為大量使用了XML文件,而XML在運行時解析很消耗內存,也會占用大量內存空間,于是就把它在編譯時解析出來,在應用程序可能使用的內存段位里找一個空閑位置放進去,然后再將這個內存偏移地址寫到R.java文件里。在執行時,就是直接將二進制的解析好的xml樹形結構映射到內存R.java所指向的位置,這時應用程序的代碼在執行時就可以直接使用了。
在Android系統里使用的另一項編譯態運算是prelink。我們Linux內核之睥系統環境,一般都會使用Gnu編譯器的動態鏈接功能,從而可以讓大量代碼通過動態鏈接庫的方式進行共享。在動態鏈接處理里,一般會先把代碼編譯成位置無關代碼(Position Independent Code,PIC),然后在鏈接階段將共用代碼編譯成.so動態鏈接庫,而將可執行代碼鏈接到這樣的.so文件。而在動態鏈接處理里,無論是.so庫文件還是可執行文件,在.text段位里會有PLT(Procedure Linkage Table),在.data段位里會有GOT(Global Offset Table)。這樣,在代碼執行時,這兩個文件都會被映射到同一進程空間,可執行程序執行到動態鏈接庫里的代碼,會通過PLT,找到GOT里定位到的動態鏈接庫里代碼具體實現的位置,然后實現跳轉。
通過這樣的方式,我們就可以實現代碼的共享,如上圖中,我們的可執行文件a.out,是可以與其他可執行程序共享libxxx.so里實現的func_from_dso()的。在動態鏈接的設計里,PLT與GOT分開是因為.text段位一般只會被映射到只讀字段,避免代碼被非法偷換,而.data段位映射后是可以被修改的,所以一般PLT表保持不動,而GOT會根據.so文件被映射到進程空間的偏移位置再進行轉換,這樣就實現了靈活的目的。同時,.so文件內部也是這樣的設計,也就是動態鏈接庫本身可以再次使用這樣的代碼共享技術鏈接到其他的動態鏈接庫,在運行時這些庫都必須被映射到同一進程空間里。所以,實際上,我們的進程空間可能使用到大量的動態鏈接庫。
動態鏈接在運行時還進行一些運行態處理,像GOT表是需要根據進程上下文換算成正確的虛擬地址上的依稀,另外,還需要驗證這些動態鏈接代碼的合法性,并且可能需要處理鏈接時的一些符號沖突問題。出于加快動態連接庫的調用過程,PLT本身也會通過Hash表來進行索引以加快執行效率。但是動態鏈接庫文件有可能很大,里面實現的函數很多很復雜,還有可能可執行程序使用了大量的動態鏈接庫,所有這些情況會導致使用了動態鏈接的應用程序,在啟動時都會很慢。在一些大型應用程序里,這樣的開銷有可能需要花好幾秒才能完全。于是有了prelink的需求。Prelink就是用一個交叉編譯的完整環境,模擬一次完整地運行過程,把參與運行的可執行程序與動態鏈接所需要使用的地址空間都算出來一個合理的位置,然后再就這個值寫入到ELF文件里的特殊段位里。在執行時,就可以不再需要(即便需要,也只是小范圍的改正)進行動態鏈接處理,可以更快完成加載。這樣的技術一直是Linux環境里一個熱門研究方向,像firefox這樣的大型應用程序經過prelink之后,可以減少幾乎一半的啟動時間,這樣的加速對于嵌入式環境來說,也就更加重要了。
但這種技術有種致命缺陷,需要一臺Linux機器,運行交叉編譯環境,才能使用prelink。而Android源代碼本就設計成至少在MacOS與Linux環境里執行的,它使用的交叉編譯工具使用到Gnu編譯的部分只完成編譯,鏈接還是通過它自己實現的工具來完成的。有了需求,但受限于Linux環境,于是Android開發者又繼續創新。在Android世界里使用的prelink,是固定段位的,在鏈接時會根據固定配置好地址信息來處理動態鏈接,比如libc.so,對于所有進程,libc.so都是固定的位置。在Android一直到2.3版本時,都會使用build/core/prelink-linux-arm.map這個文件來進行prelink操作,而這個文件也可以看到prelink處理是何其簡單:
# core system libraries
libdl.so 0xAFF00000 # [<64K]
libc.so 0xAFD00000 # [~2M]
libstdc++.so 0xAFC00000 # [<64K]
libm.so 0xAFB00000 # [~1M]
liblog.so 0xAFA00000 # [<64K]
libcutils.so 0xAF900000 # [~1M]
libthread_db.so 0xAF800000 # [<64K]
libz.so 0xAF700000 # [~1M]
libevent.so 0xAF600000 # [???]
libssl.so 0xAF400000 # [~2M]
libcrypto.so 0xAF000000 # [~4M]
libsysutils.so 0xAEF00000 # [~1M]
就像我們看到的,libdl.so,對于任何進程,都會是在0xAFD00001(libc.so結束)到0xAFF00000之間這個區域之間,而
在Android發展的初期,這種簡單的prelink機制,一直是有效的,但這不是一種很合理的解決方案。首先,這種方式不通用,也不夠節省資源,我們很難想像要在系統層加入firefox、openoffice這樣大型軟件(幾十、上百個.so文件),同時雖然絕大部分的.so是應用程序不會用到的,但都被一股腦地塞了進來。最好,這些鏈接方式也不安全,我們雖然可以通過“沙盒”模式來打造應用程序執行環境的安全性,但應用程序完全知道一些系統進程使用的.so文件的內容,則破解起來相對比較容易,進程空間分布很固定,則還可以人為地制造一些棧溢出方式來進行攻擊。
雖然作了這方面的努力,但當Android到4.0版時,為了加強系統的安全性,開始使用新的動態鏈接技術,地址空間分布隨機化(Address Space Layout Randomization,ASLR),將地址空間上的固定分配變成偽隨機分布,這時就也取消了prelink。
Android系統設計上,對于性能,在各方面都進行了相當成功的嘗試,最后得到的效果也非常不錯。大家經常批評Android整個生態環境很惡劣,高中低檔的設備充斥市場,五花八門的分辨率,但拋開商業因素不談,Android作為一套操作系統環境,可以兼容到這么多種應用情境,本就是一種設計上很成功的表現。如果說這種實現很復雜,倒還顯得不那么神奇,問題是Android在解決一些很難的工程問題的時候,用的技巧還是很簡單的,這就非常不容易了。我們寫過代碼的人都會知道,把代碼寫得極度讓人看不懂,邏輯復雜,其實并不需要太高智商,反而是編程能力不行所致。邏輯清晰,簡單明了,又能解決問題,才真正是大神級的代碼,業界成功的項目,linux、git、apache,都是這方面的典范。
Android所有這些提升性能的設計,都會導致另一個間接收益,就是所需使用的電量也相應大大降低。同樣的運算,如果節省了運算上的時間,變相地也減少了電量上的損失。但這不夠,我們的手機使用的電池非常有限,如果不使用一些特殊的省電技術,也是不行的。于是,我們可以再來透過應用程序,看看Android的功耗管理。