Dalvik虛擬機器簡要介紹和學習計劃

yangxi_001發表於2013-11-27
我們知道,Android應用程式是執行在Dalvik虛擬機器裡面的,並且每一個應用程式對應有一個單獨的Dalvik虛擬機器例項。除了指令集和類檔案格式不同,Dalvik虛擬機器與Java虛擬機器共享有差不多的特性,例如,它們都是解釋執行,並且支援即時編譯(JIT)、垃圾收集(GC)、Java本地方法呼叫(JNI)和Java遠端除錯協議(JDWP)等。本文對Dalvik虛擬機器進行簡要介紹,以及制定學習計劃。

        Dalvik虛擬機器是由Dan Bornstein開發的,名字來源於他的祖先曾經居住過的位於冰島的同名小漁村。Dalvik虛擬機器起源於Apache Harmony專案,後者是由Apache軟體基金會主導的,目標是實現一個獨立的、相容JDK 5的虛擬機器,並根據Apache License v2釋出。由此可見,Dalvik虛擬機器從誕生的那一天開始,就和Java有說不清理不斷的關係。

        Dalvik虛擬機器與Java虛擬機器的最顯著區別是它們分別具有不同的類檔案格式以及指令集。Dalvik虛擬機器使用的是dex(Dalvik Executable)格式的類檔案,而Java虛擬機器使用的是class格式的類檔案。一個dex檔案可以包含若干個類,而一個class檔案只包括一個類。由於一個dex檔案可以包含若干個類,因此它就可以將各個類中重複的字串和其它常數只儲存一次,從而節省了空間,這樣就適合在記憶體和處理器速度有限的手機系統中使用。一般來說,包含有相同類的未壓縮dex檔案稍小於一個已經壓縮的jar檔案。

        Dalvik虛擬機器使用的指令是基於暫存器的,而Java虛擬機器使用的指令集是基於堆疊的。基於堆疊的指令很緊湊,例如,Java虛擬機器使用的指令只佔一個位元組,因而稱為位元組碼。基於暫存器的指令由於需要指定源地址和目標地址,因此需要佔用更多的指令空間,例如,Dalvik虛擬機器的某些指令需要佔用兩個位元組。基於堆疊和基於暫存器的指令集各有優劣,一般而言,執行同樣的功能,前者需要更多的指令(主要是load和store指令),而後者需要更多的指令空間。需要更多指令意味著要多佔用CPU時間,而需要更多指令空間意味著指令緩衝(i-cache)更易失效。

        此外,還有一種觀點認為,基於堆疊的指令更具可移植性,因為它不對目標機器的暫存器進行任何假設。然而,基於暫存器的指令由於對目標機器的暫存器進行了假設,因此,它更有利於進行AOT(ahead-of-time)優化。 所謂AOT,就是在解釋語言程式執行之前,就先將它編譯成本地機器語言程式。AOT本質上是一種靜態編譯,它是是相對於JIT而言的,也就是說,前者是在程式執行前進行編譯,而後者是在程式執行時進行編譯。執行時編譯意味著可以利用執行時資訊來得到比較靜態編譯更優化的程式碼,同時也意味不能進行某些高階優化,因為優化過程太耗時了。另一方面,執行前編譯由於不佔用程式執行時間,因此,它就可以不計時間成本來優化程式碼。無論AOT,還是JIT,最終的目標都是將解釋語言編譯為本地機器語言,而本地機器語言都是基於暫存器來執行的,因此,在某種程度來講,基於暫存器的指令更有利於進行AOT編譯以及優化。

        事實上,基於暫存器和基於堆疊的指令集之爭,就如精簡指令集(RISC)和複雜指令集(CISC)之爭,誰優誰劣,至今是沒有定論的。例如,上面提到完成相同的功能,基於堆疊的Java虛擬機器需要更多的指令,因此就會比基於暫存器的Dalvik虛擬機器慢,然而,在2010年,Oracle在一個ARM裝置上使用一個non-graphical Java benchmarks來對比Java SE Embedded和Android 2.2的效能,發現後者比前者慢了2~3倍。上述效能比較結論以及資料可以參考以下兩篇文章:

        1. Virtual Machine Showdown: Stack Versus Registers

        2. Java SE Embedded Performance Versus Android 2.2

        基於暫存器的Dalvik虛擬機器和基於堆疊的Java虛擬機器的更多比較和分析,還可以參考以下文章:

        1. http://en.wikipedia.org/wiki/Dalvik_(software)

        2. http://www.infoq.com/news/2007/11/dalvik

        3. http://www.zhihu.com/question/20207106

        不管結論如何,Dalvik虛擬機器都在盡最大的努力來優化自身,這些措施包括:

        1. 將多個類檔案收集到同一個dex檔案中,以便節省空間;

        2. 使用只讀的記憶體對映方式載入dex檔案,以便可以多程式共享dex檔案,節省程式載入時間;

        3. 提前調整好位元組序(byte order)和字對齊(word alignment)方式,使得它們更適合於本地機器,以便提高指令執行速度;

        4. 儘量提前進行位元組碼驗證(bytecode verification),提高程式的載入速度;

        5. 需要重寫位元組碼的優化要提前進行。

        這些優化措施的更具體描述可以參考Dalvik Optimization and Verification With dexopt一文。

        分析完Dalvik虛擬機器和Java虛擬機器的區別之後,接下來我們再簡要分析一下Dalvik虛擬機器的其它特性,包括記憶體管理、垃圾收集、JIT、JNI以及程式和執行緒管理。

        一. 記憶體管理

        Dalvik虛擬機器的記憶體大體上可以分為Java Object Heap、Bitmap Memory和Native Heap三種。

        Java Object Heap是用來分配Java物件的,也就是我們在程式碼new出來的物件都是位於Java Object Heap上的。Dalvik虛擬機器在啟動的時候,可以通過-Xms和-Xmx選項來指定Java Object Heap的最小值和最大值。為了避免Dalvik虛擬機器在執行的過程中對Java Object Heap的大小進行調整而影響效能,我們可以通過-Xms和-Xmx選項來將它的最小值和最大值設定為相等。

        Java Object Heap的最小和最大預設值為2M和16M,但是手機在出廠時,廠商會根據手機的配置情況來對其進行調整,例如,G1、Droid、Nexus One和Xoom的Java Object Heap的最大值分別為16M、24M、32M 和48M。我們可以通過ActivityManager類的成員函式getMemoryClass來獲得Dalvik虛擬機器的Java Object Heap的最大值。

        ActivityManager類的成員函式getMemoryClass的實現如下所示:

  1. public class ActivityManager {  
  2.     ......  
  3.   
  4.     /** 
  5.      * Return the approximate per-application memory class of the current 
  6.      * device.  This gives you an idea of how hard a memory limit you should 
  7.      * impose on your application to let the overall system work best.  The 
  8.      * returned value is in megabytes; the baseline Android memory class is 
  9.      * 16 (which happens to be the Java heap limit of those devices); some 
  10.      * device with more memory may return 24 or even higher numbers. 
  11.      */  
  12.     public int getMemoryClass() {  
  13.         return staticGetMemoryClass();  
  14.     }  
  15.   
  16.     /** @hide */  
  17.     static public int staticGetMemoryClass() {  
  18.         // Really brain dead right now -- just take this from the configured   
  19.         // vm heap size, and assume it is in megabytes and thus ends with "m".   
  20.         String vmHeapSize = SystemProperties.get("dalvik.vm.heapsize""16m");  
  21.         return Integer.parseInt(vmHeapSize.substring(0, vmHeapSize.length()-1));  
  22.     }  
  23.   
  24.     ......  
  25. }  
public class ActivityManager {
    ......

    /**
     * Return the approximate per-application memory class of the current
     * device.  This gives you an idea of how hard a memory limit you should
     * impose on your application to let the overall system work best.  The
     * returned value is in megabytes; the baseline Android memory class is
     * 16 (which happens to be the Java heap limit of those devices); some
     * device with more memory may return 24 or even higher numbers.
     */
    public int getMemoryClass() {
        return staticGetMemoryClass();
    }

    /** @hide */
    static public int staticGetMemoryClass() {
        // Really brain dead right now -- just take this from the configured
        // vm heap size, and assume it is in megabytes and thus ends with "m".
        String vmHeapSize = SystemProperties.get("dalvik.vm.heapsize", "16m");
        return Integer.parseInt(vmHeapSize.substring(0, vmHeapSize.length()-1));
    }

    ......
}
        這個函式定義在檔案frameworks/base/core/java/android/app/ActivityManager.java中。

        Dalvik虛擬機器在啟動的時候,就是通過讀取系統屬性dalvik.vm.heapsize的值來獲得Java Object Heap的最大值的,而ActivityManager類的成員函式getMemoryClass最終也通過讀取這個系統屬性的值來獲得Java Object Heap的最大值。

        這個Java Object Heap的最大值也就是我們平時所說的Android應用程式程式能夠使用的最大記憶體。這裡必須要注意的是,Android應用程式程式能夠使用的最大記憶體指的是能夠用來分配Java Object的堆。

        Bitmap Memory也稱為External Memory,它是用來處理影象的。在HoneyComb之前,Bitmap Memory是在Native Heap中分配的,但是這部分記憶體同樣計入Java Object Heap中,也就是說,Bitmap佔用的記憶體和Java Object佔用的記憶體加起來不能超過Java Object Heap的最大值。這就是為什麼我們在呼叫BitmapFactory相關的介面來處理大影象時,會丟擲一個OutOfMemoryError異常的原因:

  1. java.lang.OutOfMemoryError: bitmap size exceeds VM budget  
java.lang.OutOfMemoryError: bitmap size exceeds VM budget
        在HoneyComb以及更高的版本中,Bitmap Memory就直接是在Java Object Heap中分配了,這樣就可以直接接受GC的管理。

        Native Heap就是在Native Code中使用malloc等分配出來的記憶體,這部分記憶體是不受Java Object Heap的大小限制的,也就是它可以自由使用,當然它是會受到系統的限制。但是有一點需要注意的是,不要因為Native Heap可以自由使用就濫用,因為濫用Native Heap會導致系統可用記憶體急劇減少,從而引發系統採取激進的措施來Kill掉某些程式,用來補充可用記憶體,這樣會影響系統體驗。

        此外,在HoneyComb以及更高的版本中,我們可以在AndroidManifest.xml的application標籤中增加一個值等於“true”的android:largeHeap屬性來通知Dalvik虛擬機器應用程式需要使用較大的Java Object Heap。事實上,在記憶體受限的手機上,即使我們將一個應用程式的android:largeHeap屬性設定為“true”,也是不能增加它可用的Java Object Heap的大小的,而即便是可以通過這個屬性來增大Java Object Heap的大小,一般情況也不應該使用該屬性。為了提高系統的整體體驗,我們需要做的是致力於降低應用程式的記憶體需求,而不是增加增加應用程式的Java Object Heap的大小,畢竟系統總共可用的記憶體是固定的,一個應用程式用得多了,就意味意其它應用程式用得少了。

        二. 垃圾收集(GC)

        Dalvik虛擬機器可以自動回收那些不再使用了的Java Object,也就是那些不再被引用了的Java Object。垃圾自動收集機制將開發者從記憶體問題中解放出來,極大地提高了開發效率,以及提高了程式的可維護性。

        我們知道,在C或者C++中,開發者需要手動地管理在堆中分配的記憶體,但是這往往導致很多問題。例如,記憶體分配之後忘記釋放,造成記憶體洩漏。又如,非法訪問那些已經釋放了的記憶體,引發程式崩潰。如果沒有一個好的C或者C++應用程式開發框架,一般的開發者根本無法駕馭記憶體問題,因為程式大了之後,很容易造成失控。最要命的是,記憶體被破壞的時候,並不一定就是程式崩潰的時候,它就是一顆不定時炸彈,說不準什麼時候會被引爆,因此,查詢原因是非常困難的。

        從這裡我們也可以推斷出,Android為什麼會選擇Java而不是C/C++來作來應用程式開發語言,就是為了能夠讓開發遠離記憶體問題,而將精力集中在業務上,開發出更多更好的APP來,從而迎頭趕超iOS。當然,Android系統記憶體也存在大量的C/C++程式碼,這隻要考慮效能問題,畢竟C/C++程式的執行效能整體上還是優於執行在虛擬機器之上的Java程式的。不過,為了避免出現記憶體問題,在Android系統內部的C++程式碼碼,大量地使用了智慧指標來自動管理物件的生命週期。選擇Java來作為Android應用程式的開發語言,可以說是技術與商業之間一個折衷,事實證明,這種折衷是成功的。

        回到正題,在GingerBread之前,Dalvik虛擬使用的垃圾收集機制有以下特點:

        1. Stop-the-word,也就是垃圾收集執行緒在執行的時候,其它的執行緒都停止;

        2. Full heap collection,也就是一次收集完全部的垃圾;

        3. 一次垃圾收集造成的程式中止時間通常都大於100ms。

        在GingerBread以及更高的版本中,Dalvik虛擬使用的垃圾收集機制得到了改進,如下所示:

        1.  Cocurrent,也就是大多數情況下,垃圾收集執行緒與其它執行緒是併發執行的;

        2.  Partial collection,也就是一次可能只收集一部分垃圾;

        3.  一次垃圾收集造成的程式中止時間通常都小於5ms。

        Dalvik虛擬機器執行完成一次垃圾收集之後,我們通常可以看到類似以下的日誌輸出:

  1. D/dalvikvm(9050): GC_CONCURRENT freed 2049K, 65% free 3571K/9991K, external 4703K/5261K, paused 2ms+2ms  
D/dalvikvm(9050): GC_CONCURRENT freed 2049K, 65% free 3571K/9991K, external 4703K/5261K, paused 2ms+2ms
        在這一行日誌中,GC_CONCURRENT表示GC原因,2049K表示總共回收的記憶體,3571K/9991K表示Java Object Heap統計,即在9991K的Java Object Heap中,有3571K是正在使用的,4703K/5261K表示External Memory統計,即在5261K的External Memory中,有4703K是正在使用的,2ms+2ms表示垃圾收集造成的程式中止時間。

        三. 即時編譯(JIT)

        前面提到,JIT是相對AOT而言的,即JIT是在程式執行的過程中進行編譯的,而AOT是在程式執行前進行編譯的。在程式執行的過程中進行編譯既有好處,也有壞處。好處在於可以利用程式的執行時資訊來對編譯出來的程式碼進行優化,而壞處在於佔用程式的執行時間,也就是說不能花太多時間在程式碼編譯和優化之上。

        為了解決時間問題,JIT可能只會選擇那些熱點程式碼進行編譯或者優化。根據2-8原則,一個程式80%的時間可能都是在重複執行20%的程式碼。因此,JIT就可以選擇這20%經常執行的程式碼來進行編譯和優化。

        為了充分地利用好執行時資訊來優化程式碼,JIT採用一種激進的方法。JIT在編譯程式碼的時候,會對程式的執行情況進行假設,並且按照這種假設來對程式碼進行優化。隨著程式的程式碼,如果前面的假設一直保持成立,那麼JIT就什麼也不用做,因此就可以提高程式的執行效能。一旦前面的假設不再成立了,那麼JIT就需要對前面編譯優化的程式碼進行調整,以便適應新的情況。這種調整成本可能是很昂貴的,但是隻要假設不成立的情況很少或者幾乎不會發生,那麼獲得的好處還是大於壞處的。由於JIT在編譯和優化程式碼的時候,對程式的執行情況進行了假設,因此,它所採取的激進優化措施又稱為賭博,即Gambling。

        我們以一個例子來說明這種Gambling。我們知道,Java的同步原語涉及到Lock和Unlock操作。Lock和Unlock操作是非常耗時的,而且它們只有在多執行緒環境中才真的需要。但是一些同步函式或者同步程式碼,有程式執行的時候,有可能始終都是被單執行緒執行,也就是說,這些同步函式或者同步程式碼不會被多執行緒同時執行。這時候JIT就可以採取一種Lazy Unlocking機制。

        當一個執行緒T1進入到一個同步程式碼C時,它還是按照正常的流程來獲取一個輕量級鎖L1,並且執行緒T1的ID會記錄在輕量鎖L1上。當經程T1離開同步函式或者同步程式碼時,它並不會釋放前面獲得的輕量級鎖L1。當執行緒T1再次進入同步程式碼C時,它就會發現輕量級鎖L的所有者正是自己,因此,它就可以直接執行同步程式碼C。這時候如果另外一個執行緒T2也要進入同步程式碼C,它就會發現輕量級鎖L已經被執行緒T1獲取。在這種情況下,JIT就需要檢查執行緒T1的呼叫堆疊,看看它是否還在執行同步程式碼C。如果是的話,那麼就需要將輕量級鎖L1轉換成一個重量級鎖L2,並且將重量級鎖L2的狀態設定為鎖定,然後再讓執行緒T2在重量級鎖L2上睡眠。等執行緒T1執行完成同步程式碼C之後,它就會按照正常的流程來釋放重量級鎖L2,從而喚醒執行緒T2來執行同步程式碼C。另一方面,如果執行緒T2在進入同步程式碼C的時候,JIT通過檢查執行緒T1的呼叫堆疊,發現它已經離開同步程式碼C了,那麼它就直接將輕量級鎖L1的所有者記錄為執行緒T2,並且讓執行緒T2執行同步程式碼C。

        通過上述的Lazy Unlocking機制,我們就可以充分地利用程式的執行時資訊來提高程式的執行效能,這種優化對於靜態編譯的語言來說,是無法做到的。從這個角度來看,我們就可以說,靜態編譯語言(如C++)並不一定比在虛擬機器上執行的語言(如Java)快,這是因為後者可以有一種強大的武器叫做JIT。

        Dalvik虛擬機器從Android 2.2版本開始,才支援JIT,而且是可選的。在編譯Dalvik虛擬機器的時候,可以通過WITH_JIT巨集來將JIT也編譯進去,而在啟動Dalvik虛擬機器的時候,可以通過-Xint:jit選項來開啟JIT功能。

        關於虛擬機器JIT的實現原理的簡要介紹,可以進一步參考這篇文章:http://blog.reverberate.org/2012/12/hello-jit-world-joy-of-simple-jits.html

        四. Java本地呼叫(JNI)

        無論如何,虛擬機器最終都是執行在目標機器之上的,也就是說,它需要將自己的指令翻譯成目標機器指令來執行,並且有些功能,需要通過呼叫目標機器執行的作業系統介面來完成。這樣就需要有一個機制,使得函式呼叫可以從Java層穿越到Native層,也就是C/C++層。這種機制就稱為Java本地呼叫,即JNI。當然,我們在執行Native程式碼的時候,有時候也是需要呼叫到Java函式的,這同樣是可以通過JNI機制來實現。也就是說,JNI機制既支援在Java函式中呼叫C/C++函式,也支援在C/C++函式中呼叫Java函式。

        事實上,Dalvik虛擬機器提供的Java執行時庫,大部分都是通過呼叫目標機器作業系統介面來實現的,也就是通過呼叫Linux系統介面來實現的。例如,當我們呼叫android.os.Process類的成員函式start來建立一個程式的時候,最終會呼叫到Linux系統提供的fork系統呼叫來建立一個程式。

        同時,為了方便開發者使用C/C++語言來開發應用程式,Android官方提供了NDK。通過NDK,我們就可以使用JNI機制來在Java函式中呼叫到C/C++函式。不過Android官方是不提倡使用NDK來開發應用程式的,這從它對NDK的支援遠遠不如SDK的支援就可以看得出來。

        五. 程式和執行緒管理

        一般來說,虛擬機器的程式和執行緒都是與目標機器本地作業系統的程式和執行緒一一對應的,這樣做的好處是可以使本地作業系統來排程程式和執行緒。程式和執行緒排程是作業系統的核心模組,它的實現是非常複雜的,特別是考慮到多核的情況,因此,就完全沒有必要在虛擬機器中提供一個程式和執行緒庫。

        Dalvik虛擬機器執行在Linux作業系統之上。我們知道,Linux作業系統並沒有純粹的執行緒概念,只要兩個程式共享同一個地址空間,那麼就可以認為它們同一個程式的兩個執行緒。Linux作業系統提供了兩個fork和clone兩個呼叫,其中,前者就是用來建立程式的,而後者就是用來建立執行緒的。關於Linux作業系統的程式和執行緒的實現,可以參考在前面Android學習啟動篇一文中提到的經典Linux核心書籍。

        關於Android應用程式程式,它有兩個很大的特點,下面我們就簡要介紹一下。

        第一個特點是每一個Android應用程式程式都有一個Dalvik虛擬機器例項。這樣做的好處是Android應用程式程式之間不會相互影響,也就是說,一個Android應用程式程式的意外中止,不會影響到其它的Android應用程式程式的正常執行。

        第二個特點是每一個Android應用程式程式都是由一種稱為Zygote的程式fork出來的。Zygote程式是由init程式啟動起來的,也就是在系統啟動的時候啟動的。Zygote程式在啟動的時候,會建立一個虛擬機器例項,並且在這個虛擬機器例項將所有的Java核心庫都載入起來。每當Zygote程式需要建立一個Android應用程式程式的時候,它就通過複製自身來實現,也就是通過fork系統呼叫來實現。這些被fork出來的Android應用程式程式,一方面是複製了Zygote程式中的虛擬機器例項,另一方面是與Zygote程式共享了同一套Java核心庫。這樣不僅Android應用程式程式的建立過程很快,而且由於所有的Android應用程式程式都共享同一套Java核心庫而節省了記憶體空間。

        關於Dalvik虛擬機器的特性,我們就簡要介紹到這裡。事實上,Dalvik虛擬機器和Java虛擬機器的實現是類似的,例如,Dalvik虛擬機器也支援JDWP(Java Debug Wire Protocol)協議,這樣我們就可以使用DDMS來除錯執行在Dalvik虛擬機器中的程式。對Dalvik虛擬機器的其它特性或者實現原理有興趣的,建議都可以參考Java虛擬機器的實現,這裡提供三本參考書:

        1. Java Virtual Machine Specification (Java SE 7) 

        2. Inside the Java Virtual Machine, Second Edition

        3. Oracle JRockit: The Definitive Guide

        另外,關於Dalvik虛擬機器的指令集和dex檔案格式的介紹,可以參考官方文件:http://source.android.com/tech/dalvik/index.html。如果對虛擬機器的實現原理有興趣的,還可以參考這個連結:http://www.weibo.com/1595248757/zvdusrg15

        在這裡,我們學習Dalvik虛擬機器的目標是打通Java層到C/C++層之間的函式呼叫,從而可以更好地理解Android應用程式是如何在Linux核心上面執行的。為了達到這個目的,在接下來的文章中,我們將關注以下四個情景:

        1. Dalvik虛擬機器的啟動過程

        2. Dalvik虛擬機器的執行過程

        3. JNI函式的註冊過程

        4. Java程式和執行緒的建立過程

        掌握了這四個情景之後,再結合前面的所有文章,我們就可以從上到下地打通整個Android系統了,敬請關注!

相關文章