1. 什么是JVM?
JVM本質上就是一個軟件,是計算機硬件的一層軟件抽象,在這之上才能夠運行Java程序,JAVA在編譯后會生成類似于匯編語言的JVM字節碼,與C語言編譯后產生的匯編語言不同的是,C編譯成的匯編語言會直接在硬件上跑,但JAVA編譯后生成的字節碼是在JVM上跑,需要由JVM把字節碼翻譯成機器指令,才能使JAVA程序跑起來。
JVM運行在操作系統上,屏蔽了底層實現的差異,從而有了JAVA吹噓的平臺獨立性和Write Once Run Anywhere。根據JVM規范實現的具體虛擬機有幾十種,主流的JVM包括Hotspot、Jikes RVM等,都是用C/C++和匯編編寫的,每個JRE編譯的時候針對每個平臺編譯,因此下載JRE(JVM、Java核心類庫和支持文件)的時候是分平臺的,JVM的作用是把平臺無關的.class里面的字節碼翻譯成平臺相關的機器碼,來實現跨平臺。
2.什么是DVM,和JVM有什么不同?
JVM是Java Virtual Machine,而DVM就是Dalvik Virtual Machine,是安卓中使用的虛擬機,所有安卓程序都運行在安卓系統進程里,每個進程對應著一個Dalvik虛擬機實例。他們都提供了對象生命周期管理、堆棧管理、線程管理、安全和異常管理以及垃圾回收等重要功能,各自擁有一套完整的指令系統,以下簡要對比兩種虛擬機的不同。
①JAVA虛擬機運行的是JAVA字節碼,Dalvik虛擬機運行的是Dalvik字節碼
JAVA程序經過編譯,生成JAVA字節碼保存在class文件中,JVM通過解碼class文件中的內容來運行程序。而DVM運行的是Dalvik字節碼,所有的Dalvik字節碼由JAVA字節碼轉換而來,并被打包到一個DEX(Dalvik Executable)可執行文件中,DVM通過解釋DEX文件來執行這些字節碼。
②Dalvik可執行文件體積更小
以下是JVM規范中以C的數據結構表達的class文件結構,class文件被虛擬機加載到內存中后便是這樣
class文件中包含多個不同的方法簽名,如果A類文件引用B類文件中的方法,方法簽名也會被復制到A類文件中(在虛擬機加載類的連接階段將會使用該簽名鏈接到B類的對應方法),也就是說,多個不同的類會同時包含相同的方法簽名,同樣地,大量的字符串常量在多個類文件中也被重復使用,這些冗余信息會直接增加文件的體積,而JVM在把描述類的數據從class文件加載到內存時,需要對數據進行校驗、轉換解析和初始化,最終才形成可以被虛擬機直接使用的JAVA類型,因為大量的冗余信息,會嚴重影響虛擬機解析文件的效率。
為了減小執行文件的體積,安卓使用Dalvik虛擬機,SDK中有個dx工具負責將JAVA字節碼轉換為Dalvik字節碼,dx工具對JAVA類文件重新排列,將所有JAVA類文件中的常量池分解,消除其中的冗余信息,重新組合形成一個常量池,所有的類文件共享同一個常量池,使得相同的字符串、常量在DEX文件中只出現一次,從而減小了文件的體積。
dx工具的轉換過程和DEX文件的結構如下圖所示。
③JVM基于棧,DVM基于寄存器
關于棧式虛擬機:
1.代碼必須使用這些指令來移動變量(即push和pop)
2.代碼尺寸小和解碼效率會更高些
3.堆棧虛擬機指令有隱含的操作數。
關于寄存器式虛擬機:
1.使用堆棧來分配激活記錄器
2.基于寄存器代碼免去了使用push和pop命令的麻煩,減少了每個函數的指令總數。
3.代碼尺寸和解碼效率不如基于棧虛擬機,因為它包含操作數,所以指令大于基于堆棧的指令。但是基于寄存器產生更少的代碼,所以總的代碼數不會增加。
4.寄存器虛擬機必須從操作指令中解碼操作數,需要額外的解碼操作。
基于棧與基于寄存器的指令集,用在解釋器里,籠統說有以下對比:
- 從源碼生成代碼的難度:基于棧 < 基于寄存器,不過差別不是特別大
- 表示同樣程序邏輯的代碼大小(code size):基于棧 < 基于寄存器
- 表示同樣程序邏輯的指令條數(instruction count):基于棧 > 基于寄存器
- 簡易實現中數據移動次數(data movement count):基于棧 > 基于寄存器;不過值得一提的是實現時通過棧頂緩存(top-of-stack caching)可以大幅降低基于棧的解釋器的數據移動開銷,可以讓這部分開銷跟基于寄存器的在同等水平。請參考另一個回答:寄存器分配問題? - RednaxelaFX 的回答
- 采用同等優化程度的解釋器速度:基于棧 < 基于寄存器
- 交由同等優化程度的JIT編譯器編譯后生成的代碼速度:基于棧 === 基于寄存器
因而,籠統說可以有以下結論:
- 要追求盡量實現簡單:選擇基于棧
- 傳輸代碼的大小盡量小:選擇基于棧
- 純解釋執行的解釋器的速度:選擇基于寄存器
- 帶有JIT編譯器的執行引擎的速度:隨便,兩者一樣;對簡易JIT編譯器而言基于棧的指令集可能反而更便于生成更快的代碼,而對比較優化的JIT編譯器而言輸入是基于棧還是基于寄存器都無所謂,經過parse之后就變得完全一樣了。
要是拿兩個分別實現了基于棧與基于寄存器架構、但沒有直接聯系的VM來對比,效果或許不會太好。現在恰巧有兩者有緊密聯系的例子——JVM與Dalvik VM。JVM的字節碼主要是零地址形式的,概念上說JVM是基于棧的架構。Google Android平臺上的應用程序的主要開發語言是Java,通過其中的Dalvik VM來運行Java程序。為了能正確實現語義,Dalvik VM的許多設計都考慮到與JVM的兼容性;但它卻采用了基于寄存器的架構,其字節碼主要是二地址/三地址混合形式的,乍一看可能讓人納悶。考慮到Android有明確的目標:面向移動設備,特別是最初要對ARM提供良好的支持。ARM9有16個32位通用寄存器,Dalvik VM的架構也常用16個虛擬寄存器(一樣多……沒辦法把虛擬寄存器全部直接映射到硬件寄存器上了);這樣Dalvik VM就不用太顧慮可移植性的問題,優先考慮在ARM9上以高效的方式實現,發揮基于寄存器架構的優勢。 Dalvik VM的主要設計者Dan Bornstein在Google I/O 2008上做過一個關于Dalvik內部實現的演講;同一演講也在Google Developer Day 2008 China和Japan等會議上重復過。這個演講中Dan特別提到了Dalvik VM與JVM在字節碼設計上的區別,指出Dalvik VM的字節碼可以用更少指令條數、更少內存訪問次數來完成操作。
眼見為實。要自己動手感受一下該例子。
創建Demo.java文件,內容為:
public class Demo {
public static void foo() {
int a = 1;
int b = 2;
int c = (a + b) * 5;
}
}
通過javac編譯,得到Demo.class。通過javap可以看到foo()方法的字節碼是:
0: iconst_1
1: istore_0
2: iconst_2
3: istore_1
4: iload_0
5: iload_1
6: iadd
7: iconst_5
8: imul
9: istore_2
10: return
接著用Android SDK里platforms\android-1.6\tools目錄中的dx工具將Demo.class轉換為dex格式。轉換時可以直接以文本形式dump出dex文件的內容。使用下面的命令:
** Command prompt 代碼 **
dx --dex --verbose --dump-to=Demo.dex.txt --dump-method=Demo.foo --verbose-dump Demo.class
可以看到foo()方法的字節碼是:
** Dalvik bytecode代碼 **
0000: const/4 v0, #int 1 // #1
0001: const/4 v1, #int 2 // #2
0002: add-int/2addr v0, v1
0003: mul-int/lit8 v0, v0, #int 5 // #05
0005: return-void
讓我們看看兩個版本在概念上是如何工作的。
** JVM: **
(圖中數字均以十六進制表示。其中字節碼的一列表示的是字節碼指令的實際數值,后面跟著的助記符則是其對應的文字形式。標記為紅色的值是相對上一條指令的執行狀態有所更新的值。下同)
說明:
Java字節碼以1字節為單元。上面代碼中有11條指令,每條都只占1單元,共11單元==11字節。 程序計數器是用于記錄程序當前執行的位置用的。對Java程序來說,每個線程都有自己的PC。PC以字節為單位記錄當前運行位置里方法開頭的偏移量。
每個線程都有一個Java棧,用于記錄Java方法調用的“活動記錄”(activation record)。Java棧以幀(frame)為單位線程的運行狀態,每調用一個方法就會分配一個新的棧幀壓入Java棧上,每從一個方法返回則彈出并撤銷相應的棧幀。
每個棧幀包括局部變量區、求值棧(JVM規范中將其稱為“操作數棧”)和其它一些信息。局部變量區用于存儲方法的參數與局部變量,其中參數按源碼中從左到右順序保存在局部變量區開頭的幾個slot。求值棧用于保存求值的中間結果和調用別的方法的參數等。兩者都以字長(32位的字)為單位,每個slot可以保存byte、short、char、int、float、reference和returnAddress等長度小于或等于32位的類型的數據;相鄰兩項可用于保存long和double類型的數據。每個方法所需要的局部變量區與求值棧大小都能夠在編譯時確定,并且記錄在.class文件里。
在上面的例子中,Demo.foo()方法所需要的局部變量區大小為3個slot,需要的求值棧大小為2個slot。Java源碼的a、b、c分別被分配到局部變量區的slot 0、slot 1和slot 2。可以觀察到Java字節碼是如何指示JVM將數據壓入或彈出棧,以及數據是如何在棧與局部變量區之前流動的;可以看到數據移動的次數特別多。動畫里可能不太明顯,iadd和imul指令都是要從求值棧彈出兩個值運算,再把結果壓回到棧上的;光這樣一條指令就有3次概念上的數據移動了。
Java的局部變量區并不需要把某個局部變量固定分配在某個slot里;不僅如此,在一個方法內某個slot甚至可能保存不同類型的數據。如何分配slot是編譯器的自由。從類型安全的角度看,只要對某個slot的一次load的類型與最近一次對它的store的類型匹配,JVM的字節碼校驗器就不會抱怨。以后再找時間寫寫這方面。
** Dalvik VM: **
說明:
Dalvik字節碼以16位為單元(或許叫“雙字節碼”更準確 =_=|||)。上面代碼中有5條指令,其中mul-int/lit8指令占2單元,其余每條都只占1單元,共6單元==12字節。
與JVM相似,在Dalvik VM中每個線程都有自己的PC和調用棧,方法調用的活動記錄以幀為單位保存在調用棧上。PC記錄的是以16位為單位的偏移量而不是以字節為單位的。
與JVM不同的是,Dalvik VM的棧幀中沒有局部變量區與求值棧,取而代之的是一組虛擬寄存器。每個方法被調用時都會得到自己的一組虛擬寄存器。常用v0-v15這16個,也有少數指令可以訪問v0-v255范圍內的256個虛擬寄存器。與JVM相同的是,每個方法所需要的虛擬寄存器個數都能夠在編譯時確定,并且記錄在.dex文件里;每個寄存器都是字長(32位),相鄰的一對寄存器可用于保存64位數據。方法的參數按源碼中從左到右的順序保存在末尾的幾個虛擬寄存器里。
與JVM版相比,可以發現Dalvik版程序的指令數明顯減少了,數據移動次數也明顯減少了,用于保存臨時結果的存儲單元也減少了。
你可能會抱怨:上面兩個版本的代碼明明不對應:JVM版到return前完好持有a、b、c三個變量的值;而Dalvik版到return-void前只持有b與c的值(分別位于v0與v1),a的值被刷掉了。
但注意到a與b的特征:它們都只在聲明時接受過一次賦值,賦值的源是常量。這樣就可以對它們應用常量傳播,將
int c = (a + b) * 5;
替換為
int c = (1 + 2) * 5;
然后可以再對c的初始化表達式應用常量折疊,進一步替換為:
int c = 15;
把變量的每次狀態更新(包括初始賦值在內)稱為變量的一次“定義”(definition),把每次訪問變量(從變量讀取值)稱為變量的一次“使用”(use),則可以把代碼整理為“使用-定義鏈”(簡稱UD鏈,use-define chain)。顯然,一個變量的某次定義要被使用過才有意義。上面的例子經過常量傳播與折疊后,我們可以分析得知變量a、b、c都只被定義而沒有被使用。于是它們的定義就成為了無用代碼(dead code),可以安全的被消除。 上面一段的分析用一句話描述就是:由于foo()里沒有產生外部可見的副作用,所以foo()的整個方法體都可以被優化為空。經過dx工具處理后,Dalvik版程序相對JVM版確實是稍微優化了一些,不過沒有影響程序的語義,程序的正確性是沒問題的。這是其一。
其二是Dalvik版代碼只要多分配一個虛擬寄存器就能在return-void前同時持有a、b、c三個變量的值,指令幾乎沒有變化:
0000: const/4 v0, #int 1 // #1
0001: const/4 v1, #int 2 // #2
0002: add-int v2, v0, v1
0004: mul-int/lit8 v2, v2, #int 5 // #05
0006: return-void
這樣比原先的版本多使用了一個虛擬寄存器,指令方面也多用了一個單元(add-int指令占2單元);但指令的條數沒變,仍然是5條,數據移動的次數也沒變。
題外話1:Dalvik VM是基于寄存器的,x86也是基于寄存器的,但兩者的“寄存器”卻相當不同:前者的寄存器是每個方法被調用時都有自己一組私有的,后者的寄存器則是全局的。也就是說,概念上Dalvik VM字節碼中不用擔心保護寄存器的問題,某個方法在調用了別的方法返回過來后自己的寄存器的值肯定跟調用前一樣。而x86程序在調用函數時要考慮清楚calling convention,調用方在調用前要不要保護某些寄存器的當前狀態,還是說被調用方會處理好這些問題,麻煩事不少。Dalvik VM這種虛擬寄存器讓人想起一些實際處理器的“寄存器窗口”,例如SPARC的Register Windows也是保證每個函數都覺得自己有“私有的一組寄存器”,減輕了在代碼里處理寄存器保護的麻煩——扔給硬件和操作系統解決了。IA-64也有寄存器窗口的概念。
題外話2:Dalvik的.dex文件在未壓縮狀態下的體積通常比同等內容的.jar文件在deflate壓縮后還要小。但光從字節碼看,Java字節碼幾乎總是比Dalvik的小,那.dex文件的體積是從哪里來減出來的呢?這主要得益與.dex文件對常量池的壓縮,一個.dex文件中所有類都共享常量池,使得相同的字符串、相同的數字常量等都只出現一次,自然能大大減小體積。相比之下,.jar文件中每個類都持有自己的常量池,諸如"Ljava/lang/Object;"這種常見的字符串會被重復多次。Sun自己也有進一步壓縮JAR的工具,Pack200,對應的標準是JSR 200。它的主要應用場景是作為JAR的網絡傳輸格式,以更高的壓縮比來減少文件傳輸時間。在官方文檔提到了Pack200所用到的壓縮技巧。
3.什么是ART虛擬機,和JVM/DVM有什么不同?
首先了解JIT(Just In Time,即時編譯技術)和AOT(Ahead Of Time,預編譯技術)兩種編譯模式。
JIT以JVM為例,javac把程序源碼編譯成JAVA字節碼,JVM通過逐條解釋字節碼將其翻譯成對應的機器指令,逐條讀入,逐條解釋翻譯,執行速度必然比C/C++編譯后的可執行二進制字節碼程序慢,為了提高執行速度,就引入了JIT技術,JIT會在運行時分析應用程序的代碼,識別哪些方法可以歸類為熱方法,這些方法會被JIT編譯器編譯成對應的匯編代碼,然后存儲到代碼緩存中,以后調用這些方法時就不用解釋執行了,可以直接使用代碼緩存中已編譯好的匯編代碼。這能顯著提升應用程序的執行效率。(安卓Dalvik虛擬機在2.2中增加了JIT)
相對的AOT就是指C/C++這類語言,編譯器在編譯時直接將程序源碼編譯成目標機器碼,運行時直接運行機器碼。
Dalvik虛擬機執行的是dex字節碼,ART虛擬機執行的是本地機器碼
Dalvik執行的是dex字節碼,依靠JIT編譯器去解釋執行,運行時動態地將執行頻率很高的dex字節碼翻譯成本地機器碼,然后在執行,但是將dex字節碼翻譯成本地機器碼是發生在應用程序的運行過程中,并且應用程序每一次重新運行的時候,都要重新做這個翻譯工作,因此,及時采用了JIT,Dalvik虛擬機的總體性能還是不能與直接執行本地機器碼的ART虛擬機相比。
安卓運行時從Dalvik虛擬機替換成ART虛擬機,并不要求開發者重新將自己的應用直接編譯成目標機器碼,也就是說,應用程序仍然是一個包含dex字節碼的apk文件。所以在安裝應用的時候,dex中的字節碼將被編譯成本地機器碼,之后每次打開應用,執行的都是本地機器碼。移除了運行時的解釋執行,效率更高,啟動更快。(安卓在4.4中發布了ART運行時)
ART優點:
①系統性能顯著提升
②應用啟動更快、運行更快、體驗更流暢、觸感反饋更及時
③續航能力提升
④支持更低的硬件
ART缺點
①更大的存儲空間占用,可能增加10%-20%
②更長的應用安裝時間
總的來說ART就是“空間換時間”
參考資料:
1.Android 代碼優化