深入理解Android(二):Java虛擬機Dalvik
編者按:隨著移動設備硬件能力的提升,Android系統開放的特質開始顯現,各種開發的奇技淫巧、黑科技不斷涌現,InfoQ特聯合《深入理解Android》系列圖書作者鄧凡平,開設深入理解Android專欄,探索Android從框架到應用開發的奧秘。
一、背景
這個選題很大,但并不是一開始就有這么高大上的追求。最初之時,只是源于對Xposed的好奇。Xposed幾乎是定制ROM的神器軟件技術架構 或者說方法了。它到底是怎么實現呢?我本意就是想搞明白Xposed的實現原理,但隨著代碼研究的深入,我發現如果不了解虛擬機的實現,而僅簡單停留在 Xposed的調用流程之上,那真是對Xposed最大的不敬了。另外,歪果仁為什么能寫出Xposed?Android上的Java虛擬機對他們來說應 該也是相對陌生的,何以他們能做而我們沒有人搞出這樣的東西?
所以,在研究Xposed之后,我決定把虛擬機方面的東西也來研究一番。誠如我在很多場合中提到的關于Android學習的三個終極問題(其實對其他各科學習也適用):學什么?怎么學?學到什么程度為止?關于這三個問題,以本次研究的情況來看,回答如下:
- 學習目標是:按順序是dalvik虛擬機,然后是Xposed針對dalvik的實現,然后是art虛擬機。
- 學習方法:VM原理配合具體實現,以代碼為主。Java VM有一套規范,各公司具體的VM實現必須遵守此規范。所以對VM學習而言,規范很重要,它是不變的,而代碼實現只不過是該規范的一種實現罷了。這里也直 接體現了我提出的關于專業知識學習的一句警語“基于Android,高于Android”。對VM而言,先掌握規范才是最最重要和核心的事情。
- 學到什么程度為止:對于dalvik虛擬機,我們以學會一段Java程序從代碼,到字節碼,最后到如何被VM加載并運行它為止。關于 dalvik的內存管理我們不會介紹。對于XPosed,基于dalvik+selinux環境的代碼我們會全部分析。對于ART,由于它是Google 未來較長一段時期的重點,所以我們也會圍繞它做更多的分析,諸如內存管理怕是肯定會加上的。
除了這三個問題,其實還有一個隱含的疑問,學完之后有什么用呢?
- 這個問題的答案要看各位的需求了。從本人角度來看,我就是想知道Xposed是怎么實現的。另外,如果了解虛擬機實現的話,我還想定制它,使得它在智能POS領域變得更安全一點。
- 當然,我自己有一個比較高大上的夢想,就是我一直想寫Linux Kernel方面的書,而且我自認為已經找到了一個絕妙的學習它的入手點(我在魅族做分享的時候介紹過。到今天為止一年多過去了,不知道當初的有心人是否 借此脫引而出,如果有的話也請和大家分享下你的學習經歷)。Anyway,從目前的工作環境和需求來看,VM是當前更好的學習目標。
言歸正傳,現在開始正式介紹dalvik,請牢記關于它的學習目標和學習程度。
你也可以下載本專題對應的 demo代碼 用于學習。
二、Class、dex、odex文件結構
2.1 Class文件結構總覽
Class文件是理解Vm實現的關鍵。關于Class文件的結構,這里介紹的內容直接參考JVM規范,因為它是最權威的資料。
Oracle的JVM SE7官方規范:https://docs.oracle.com/javase/specs/jvms/se7/html/
還算很有良心,純網頁版的,也可以下載PDF版。另外,周志明老師曾經翻譯過中文版的JVM規范,網上可搜索到。
作為分析Class文件的入口,我在Demo示例中提供了一個特別簡單的例子,代碼如圖1所示:
TestMain類的代碼簡單到不行,此處也不擬多說,因為沒有特殊之處。
當我們用eclipse編譯這個類后,將得到bin/com/test/TestMain.class。這個TestMain.class就是我們要分析的Class文件了。
Class文件到底是什么東西?我覺得一種通俗易懂的解釋就是:
- *.java文件是人編寫的,給人看的。
- *.class是通過工具處理*.java文件后的產物,它是給VM看的,給VM操作的
在某種哲學意義上看,java源文件和處理得到的class文件是同一種東西......
那么,這個給VM使用的class文件,其內部結構是怎樣的呢?Jvm規范很聰明,它通過一個C的數據結構表達了class文件結構。這個數據結構如圖2所示:
請大家務必駐足停留片刻,因為搞清楚圖2的內容對后續的學習非常關鍵。圖2的ClassFile這個數據結構真得是太容易理解了。相比那些 native的二進制程序而言,ClassFile的組織結構和Java源碼的組織結構匹配度非常高,以致于我第一眼看到這個結構體時,我覺得自己差不多 就理解了它:
- 比如,類的是public的還是final的,還是interface,就由access_flags來表示。其具體取值我覺得都不用管,代碼中用得是名字諸如ACC_XXX這樣得的標志位來表示,一看知道是啥玩意兒。
- Java類中定義的域(成員變量),方法等都有對應的數據結構來表達,而且還是個數組。
- 唯一有點特別之處的是常量池。什么東西會放在常量池呢?最容易想到的就是字符串了。對頭,這個Java源碼中的類名,方法名,變量名,居然都 是以字符串形式存儲在常量池中。所以,圖2中的this_class和super_class分別指向兩個字符串,代表本類的名字和基類的名字。這兩個字 符串存儲在常量池中,所以this_class和super_class的類型都是u2(索引,代表長度為2個字節)。
Class文件用javap工具可以很好得解析成圖2那樣的格式,我這里替大家解析了一把,結果如圖3所示(先顯示部分內容):
注意,解析方法為:javap -verbose xxxx.class
先來看看常量池。
2.1.1 常量池介紹
常量池看起來陌生,其實簡單得要死。注意,count_pool_count是常量池數組長度+1。比如,假設某個Class文件常量池只有4個元素,那么count_pool_count=5)。
javap解析class文件的時候,常量池的索引從1算起,0默認是給VM自己用得,一般不顯示0這一項。這也是為什么圖3中常量池第一個元素 以#1開頭。所以,如果count_pool_count=5的話,真正有用的元素是從count_pool[1]到count_pool[4]。
常量池數組的元素類型由下面的代碼表示:
cp_info { //特別注意,這是介紹的cp_info是相關元素類型的通用表達。 u1 tag; //tag為1個字節長。不論cp_info具體是哪種,第一個字節一定代表tag u1 info[]; //其他信息,長度隨tag不同而不同 } //tag取值,先列幾個簡單的: tag=7 <==info代表這個cp_info是CONSTANT_Class_info結構體 tag=9<==info代表CONSTANT_Fieldrefs_info結構體 tag=10<==info代表CONSTANT_Methodrefs_info結構體 tag=8<==info代表CONSTANT_String_info結構體 tag=1<==info代表CONSTANT_Utf8_info結構體
在JVM規范中,真正代表字符串的數據結構是CONSTANT_Utf8_info結構體,它的結構如下代碼所示:
CONSTANT_Utf8_info { u1 tag; u2 length; //下面就是存儲UTF8字符串的地方了 u1 bytes[length]; }
大家看圖3中常量池的內容,比如#2=Utf8 com/test/TestMain 這行表示:
數組第二個元素的類型是CONSTANT_Utf8_info,字符串為“com/test/TestMain”
下面我們看幾個常用的常量池元素類型
(1) CONSTANT_Class_info
這個類型是用于描述類信息的,此處的類信息很簡單,就是類名(也就是代表類名的字符串)
CONSTANT_Class_info { u1 tag; //tag取值為7,代表CONSTANT_Class_info u2 name_index; //name_index表示代表自己類名的字符串信息位于于常量池數組中哪一個,也就是索引 }
唉,夠懶的,name_index對應的那個常量池元素必須是CONSTANT_Utf8_info,也就是字符串。圖3中的例子,咱們再看看:
#1 = Class #2 //com/test/TestMain
#2 = Utf8 com/test/TestMain
這說明:
- 常量池第一個元素類型為Class_info,它對應的name_index取值為2,表示使用第2個元素
- 常量池第二個元素類型為Utf8 內容為“com/test/TestMain”
- #1最后的//表示注釋,它把第二行的字符串內容直接搬過來,方便我們查看
(2) CONSTANT_NameAndType_Info
這個結構也是常量池數據結構中中比較重要的一個,干什么用得呢?恩,它用來描述方法/成員名以及類型信息的。有點JNI基礎的童鞋相信不難明白, 在JNI中,一個類的成員函數或成員變量都可以由這個類名字符串+函數名字符串+參數類型字符串+返回值類型來確定(如果是成員變量,就是類名字符串+變 量名字符串+類型字符串)來表達。既然是字符串,那么NameAndType_Info也就是存儲了對應字符串在常量池數組中的索引:
CONSTANT_NameAndType_info { u1 tag; u2 name_index; //方法名或域名對應的字符串索引 u2 descriptor_index; //方法信息(參數+返回值),或者成員變量的信息(類型)對應的字符串索引 } //還是來看圖3中的例子吧 #13 = Utf8 ()V #15 = NameAnType #16.#13 //合起來就是test.()V 函數名是test,參數和返回值是()V #16=Utf8 test
太簡單了,都不惜得說...,請大家自行解析#25這個常量池元素的內容,一定要做喔!
注意,對于構造函數和類初始化函數來說,JVM要求函數名必須是<init>和<cinit>。當然,這兩個函數是編譯器生成的。
(3) CONSTANT_MethodrefInfo三兄弟
Methodref_Info還有兩個兄弟,分別是Fieldref_Info,InterfaceMethodref_Info,他們三用于描 述方法、成員變量和接口信息。剛才的NameAndType_Info其實已經描述了方法和成員變量信息的一部分,唯一還缺的就是沒有地方描述它們屬于哪 個類。而咱這三兄弟就補全了這些信息。他們三的數據結構如圖4所示:
如此直白簡單,不解釋了。不放心的童鞋們請對照圖3的例子自行玩耍!
常量池先介紹到這,它還有一些有用的信息,不過要等到后面我們碰到具體問題時再分析
2.1.2 Field和Method描述
剛才在常量池介紹中有提到Methodref_Info和Fieldref_Info,不過這兩個Info無非是描述了函數或成員變量的名字,參 數,類型等信息。但是真正的方法、成員變量信息還包括比如訪問權限,注解,源代碼位置等。對于方法來說,更重要的還包括其函數功能(即這個函數對應的字節 碼)。
在Java VM中,方法和成員變量的完整描述由如圖5所示的數據結構來表達的:
- access_flags:描述諸如final,static,public這樣的訪問標志
- name_index:方法或成員變量名在常量池中對應的索引,類型是Utf8_Info
- attribute_info:是域或方法中很重要的信息。我們單獨用一節來介紹它。
2.1.3 attribute_info介紹
attribute_info結構體很簡單,如下代碼所示:
cp_info { //特別注意,這是介紹的cp_info是相關元素類型的通用表達。 u1 tag; //tag為1個字節長。不論cp_info具體是哪種,第一個字節一定代表tag u1 info[]; //其他信息,長度隨tag不同而不同 } //tag取值,先列幾個簡單的: tag=7 <==info代表這個cp_info是CONSTANT_Class_info結構體 tag=9<==info代表CONSTANT_Fieldrefs_info結構體 tag=10<==info代表CONSTANT_Methodrefs_info結構體 tag=8<==info代表CONSTANT_String_info結構體 tag=1<==info代表CONSTANT_Utf8_info結構體
Java VM規范中,attribute類型比較多,我們重點介紹幾個,先來看代表一個函數實際內容的Code屬性。
(1) Code屬性
代表Code屬性的數據結構如圖6所示:
- 前2個成員變量就不多說了。屬于attribute的頭6個字節,分別指向代表屬性名字符串的常量池元素以及后續屬性數據的長度。注 意,Code屬性的attribute_name_index所指向的那個Utf8常量池元素對應的字符串內容就是“Code”,大家可參考圖3的#9。
- max_stack和max_locals:虛擬機在執行一個函數的時候,會為它建立一個操作數棧。執行過程中的參數啊,一些計算值啊等都會 壓入棧中。max_stack就表示該函數執行時,這個棧的最大深度。這是編譯時就能確定的。max_locals用于描述這個方法最大的棧數和最大的本 地變量個數。本地變量個數包括傳入的參數。
- code_length和code:這個函數編譯成Java字節碼后對應的字節碼長度和內容。
- exception_table_length:用來描述該方法對應異常處理的信息。這塊我不打算講了,其實也蠻簡單,就是用start_pc表示異常處理時候從此方法對應字節碼(由code[]數組表示)哪個地方開始執行。
- Code屬性本身還能包含一些屬性,這是由attributes_count和attributes數組決定的。
來看個實際例子吧,如圖7所示(接著圖3的例子):
圖7中:
- stack=2,locals=2,args_size=1。結合代碼,main函數確實有一個參數,而且還有一個本地變量。注意,main函數是static的。如果對于類的非static函數,那么locals的第0個元素代表this。
- stack后面接下來的就是code數組,也就是這個函數對應的執行代碼。0表示code[]的索引位置。0:new:代表這個操作是new操作,此操作對應的字節碼長度為3,所以下一個操作對應的字節碼從索引3開始。
- LineNumberTable也是屬性的一種,用于調試,它將源碼和字節碼匹配了起來。比如line 7: 0這句話代表該函數字節碼0那一個操作對應代碼的第7行。
- LocalVariableTable:它也是屬性一種,用于調試,它用于描述函數執行時的變量信息。比如圖7中的Start = 0:表示從code[]第0個字節開始,Length = 13表示到從start=0到start+13個字節(不包含第13個字節,因為code數組一共就12個字節)這段范圍內,這個變量都有效(也就是這個 變量的作用域),Slot=0表示這個變量在本地變量表中第一個元素,還記得前面提到的locals嗎?,name為“args”,表示這個參數的名字叫 args,類型(由Signature表示)就是String數組了。
請大家自行解析圖7中最后一行,看看能搞明白LocalVariableTable的含義不...
另外,Android SDK build Tools中的dx工具dump class文件得到的信息更全,大家可以試試。
使用方法是:dx --dump --debug xxx.class。
Class文件先介紹到這,下面我們來看看Android平臺上的dex文件。
2.2 Dex文件結構和Odex
2.2.1 dex文件結構簡介
Android平臺中沒有直接使用Class文件格式,因為早期的Anrdroid手機內存,存儲都比較小,而Class文件顯然有很多可以優化 的地方,比如每個Class文件都有一個常量池,里邊存儲了一些字符串。一串內容完全相同的字符串很有可能在不同的Class文件的常量池中存在,這就是 一個可以優化的地方。當然,Dex文件結構和Class文件結構差異的地方還很多,但是從攜帶的信息上來看,Dex和Class文件是一致的。所以,你了 解了Class文件(作為Java VM官方Spec的標準),Dex文件結構只不過是一個變種罷了(從學習到什么程度為止的問題來看,如果不是要自己來解析Dex文件,或者反編譯/修改 dex文件,我覺得大致了解下Dex文件結構的情況就可以了)。圖8所示為Dex文件結構的概貌:
有一點需要說明:傳統Class文件是一個Java源碼文件會生成一個.Class文件,而Android是把所有Class文件進行合并,優 化,然后生成一個最終的class.dex,如此,多個Class文件里如果有重復的字符串,當把它們都放到一個dex文件的時候,只要一份就可以了嘛。
dex頭部信息中的magic取值為“dex\n035\0”
proto_ids:描述函數原型信息,包括返回值,參數信息。比如“test:()V”
methods_ids:函數信息,包括所屬類及對應的proto信息。比如
"Lcom.test.TestMain. test:()V",.前面是類信息,后面屬于proto信息
下面我們將示例TestMain.class轉換成dex文件,然后再用dexdump工具看看它的結果,如圖9所示:
具體方法:
- 先將.class文件轉換成dex文件,工具是sdk build-tools下的dx命令。dx --dex --debug --verbose-dump --output=test.dex com/test/TestMain.class,生成test.dex文件。
- 同樣,利用build-tools下的dexdump命令查看,dexdump -d -l plain test.dex,得到圖9的結果
圖9中的dexdump結果其實比圖3還要清晰易懂。我們重點關注code段的內容(圖中紅框的部分):
- registers:Dalvik最初目標是運行在以ARM做CPU的機器上的,ARM芯片的一個主要特點是寄存器多。寄存器多的話有好處, 就是可以把操作數放在寄存器里,而不是像傳統VM一樣放在棧中。自然,操作寄存器是比操作內存(棧嘛,其實就是一塊內存區域)快。registers變量 表示該方法運行過程中會使用多少個寄存器。
- ins:輸入參數對應的個數,outs:此函數內部調用其他函數,需要的參數個數。
- insns:size:以4字節為單位,代表該函數字節碼的長度(類似Class文件的code[]數組)
Android官方文檔:https://source.android.com/devices/tech/dalvik/dex-format.html
說實話,寫完這一小節的時候,我又反復看了官方文檔還有其他一些參考文檔。很痛苦,主要是東西太多,而我們目前又沒有實際的問題,所以基本上是一邊看一邊忘!
恩。至少在這個階段,先了解到這個程度就好。后面會隨著學習的深入,有更多的深入知識,到時候根據需求再加進來。
2.2.2 odex介紹
再來看odex。odex是Optimized dex的簡寫,也就是優化后的dex文件。為什么要優化呢?主要還是為了提高Dalvik虛擬機的運行速度。但是odex不是簡單的、通用的優化,而是在其優化過程中,依賴系統已經編譯好的其他模塊,簡單點說:
- 從Class文件到dex文件是針對Android平臺的一種優化,是一種通用的優化。優化過程中,唯一的輸入是Class文件。
- odex文件就是dex文件具體在某個系統(不同手機,不同手機的OS,不同版本的OS等)上的優化。odex文件的優化依賴系統上的幾個核 心模塊(由BOOTCLASSPATH環境變量給出,一般是/system/framework/下的jar包,尤其是core.jar)。我個人感覺 odex的優化就好像是把中那些本來需要在執行過程中做的類校驗、調用其他類函數時的解析等工作給提前處理了。
圖10給出了圖1所示示例代碼得到的test.dex,然后利用dexopt得到test.odex,接著利用dexdump得到其內容,最后利用Beyond Compare比較這兩個文件的差異。
圖10中,綠色框中是test.dex的內容,紅色框中是test.odex的內容,這也是兩個文件的差異內容:
- test.dex中,TestMain類僅僅是PUBLIC的,但test.odex則增加了VERIFIED和OPTIMIZED兩項。VERIFIED是表示該類被校驗過了,至于校驗什么東西,以后再說。
- 然后就是一些方法的不同了。優化后的odex文件,一些字節碼指令變成了xxx-quick。比如圖中最后一句代碼對于的字節碼中,未優化前 invoke-virtual指令表示從method table指定項(圖中是0002)里找到目標函數,而優化后的odex使用了invoke-virtual-quick表示從vtable中找到目標函 數(圖中是000b)。
vtable是虛表的意思,一般在OOP實現中用得很多。vtable一定比methodtable快么?那倒是有可能。我個人猜測:
- method表應該是每個dex文件獨有的,即它是基于dex文件的。
- 根據odex文件的生成方法(后面會講),我覺得vtable恐怕是把dex文件及依賴的類(比如Java基礎類,如Object類等)放一 起進行了處理,最終得到一張大的vtable。這個odex文件依賴的一些函數都放在vtable中。運行時直接調用指定位置的函數就好,不需要再解析 了。以上僅是我的猜測。
1 http://mylifewithandroid.blogspot.com/2009/05/about-quick-method-invocation.html介紹了vtable的生成,大家可以看看
2 http://pallergabor.uw.hu/androidblog/dalvik_opcodes.html 詳細描述了dex/odex指令的格式,大家有興趣可以做參考。
(1) odex文件的生成
前面曾經提到過,odex文件的生成依賴于BOOTCLASSPATH提供的系統核心庫。以我們這個簡單的例子而言,core.jar是必須的 (java基礎類大部分封裝在core.jar中)。另外,core.jar對應的core.odex文件也需要。所有這些文件我都已經上傳到示例代碼倉 庫的javavmtest/odex-test目錄下。然后執行dextest.sh腳本。此腳本內容如下:
#!/bin/sh #在根目錄下建立/data/dalvik-cache目錄,這是因為odex往往是在機器上生成的,所有這些目錄都是 #設備上才有。我們模擬一下罷了 sudo mkdir -p /data/dalvik-cache/ #core.dex文件名:這也是模擬了機器上的情況。系統將dex文件的絕對路徑名換成了@來唯一標示 #一個dex文件。由于我在制作core.dex的時候,該core.jar包放在了/home/innost/workspace/my-projects/ #javavmtest/odex-test下,生成的core.dex就應該命名為home@innost@workspace@my-projects@javavmtest@odex-test@core.jar@classes.dex CORE_TARGET_DEX="home@innost@workspace@my-projects@javavmtest@odex-test@core.jar@" CURRENT_PATH=`pwd` #為了減少麻煩,我這里做了一個鏈接,將需要的dex文件鏈接到此目錄下的core.dex sudo ln -sf ${CURRENT_PATH}/core.dex /data/dalvik-cache/${CORE_TARGET_DEX}classes.dex rm test.odex #設置BOOTCLASSPATH變量 export BOOTCLASSPATH=${CURRENT_PATH}/core.jar /home/innost/workspace/android-4.4.4/out/host/linux-x86/bin/dexopt --preopt ${CURRENT_PATH}/test.jar test.odex "m=y u=n" #刪掉/data目錄 sudo rm -rf /data
odex文件由dexopt生成,這個工具在SDK里沒有,只能由源碼生成。odex文件的生成有三種方式:
- preopt:即OEM廠商(比如手機廠商),在制作鏡像的時候,就把那些需要放到鏡像文件里的jar包,APK等預先生成對應的odex文件,然后再把classes.dex文件從jar包和APK中去掉以節省文件體積。
- installd:當一個apk安裝的時候,PackageManagerService會調用installd的服務,將apk中的class.dex進行處理。當然,這種情況下,APK中的class.dex不會被剔除。
- dalvik VM:preopt是廠商的行為,可做可不做。如果沒有做的話,dalvik VM在加載一個dex文件的時候,會先生成odex。所以,dalvik VM實際上用得是odex文件。以后我們研究dalvik VM的時候會看到這部分內容。
實際上dex轉odex是利用了dalvik vm,里邊也會運行dalvik vm的相關方法。
2.3 小結
本節主要介紹了Class文件,以及在Android平臺上的變種dex和odex文件。以標準角度來看,Class文件是由Java VM規范定義的,所以通用性更廣。dex或者是odex只不過是規范在Android平臺上的一種具體實現罷了,而且dex/odex在很多地方也需要遵 守規范。因為dex文件的來源其實還是Class文件。
對于初學者而言,我建議了解Class文件的結構為主。另外,關于dex/odex的文件結構,除非有明確需求(比如要自己修改字節碼等),否則 以了解原理就可以。而且,將來我們看到dalvik vm的實際代碼后,你會發現dex的文件內容還是會轉換成代碼里的那些你很熟悉的類型,數據結構。比如dex存儲字符串是一種優化后的方法,但是到vm代 碼中,還不是只能用字符串來表示嗎?
另外,你還會發現,Class、dex還是odex文件都存儲了很多源碼中的信息,比如類名、函數名、參數信息、成員變量信息等,而且直接用得是字符串。這和Native的二進制比起來,就容易看懂多了。
三、字節碼的執行
下面我們來講講字節碼的執行。很多人對Java字節碼到底是怎么運行的比較好奇。Java字節碼的運行和操作系統上(比如Linux)一個進程是 如何執行其代碼,從理論上說是一致的。只不過Java字節碼的執行是JVM,而操作系統上一個進程其代碼的執行是由CPU來完成。當然,現在JVM也可以 把Java字節碼直接轉成機器碼,然后交給CPU來執行。這樣可以顯著提高運行速度。
本節我們將介紹Android平臺上Java字節碼的執行。當然,我并不會具體分析每一行代碼都是怎么執行的(比如函數參數的入棧,寄存器的使用),而只是想向大家介紹大體的流程,滿足大家的好奇心。如果有更深次的學習需求,你就可以在本節基礎上自行開展了!
下面所講內容的源碼全部位于AOSP源碼/dalvik/vm/mterp/out目錄下
mterp/out目錄下有好些個源碼文件,如圖11所示:
這個目錄中的文件就是不同平臺上,Java字節碼處理的代碼。每一個平臺包含一個匯編文件和一個C文件。
- 前面講過,Java字節碼可以完全由JVM自己來執行,比如碰到一個new instance的字節碼,就對應去調用內存分配函數。這種完全由JVM執行的情況,其對應代碼位于InterpC-portable.cpp中。待會我們先分析它。
- 對于ARM平臺,則有InterpAsm-armXXX.S和對應的InterpC-armXXX.cpp。其中.S文件是匯編文件,而.CPP文件是對應的C++文件。二者要結合起來使用。
- x86和mips平臺與ARM平臺類似。
- 當CPU類型不屬于ARM、x86或mips(也不采用純解釋方法),則通過InterpAsm-allstubs.S和interpAsm-allsubts.cpp來處理。
下面我們看對于new操作,portable、arm平臺的處理。
3.1 portable的純解釋執行
在InterpC-portable.cpp中,有幾處關鍵代碼,先來看圖12:
在這段代碼中:
- H(_op):這個宏定義了&&op_##_op這樣的東西。op_#_op其實是一個標號(Label,和goto中的label是一個意思),而&&代表這個Label的地址 [4] 。
- HANDLE_OPCODE(_op):這個宏定義了一個標號op_##_op。
- 在FINISH宏中,有一個goto *handleTable,這是portable模式下JVM執行Java字節碼的關鍵。簡單點說,portable模式下,每一種Java操作碼 (OPCode)都對應有一個處理邏輯(是一段代碼,但不一定是函數),FINISH宏就是取出當前的操作碼,然后跳轉(goto)到對應的處理邏輯去處 理它。
那么,handlerTable是怎么定義的呢?來看圖13:
圖13中:
- dvmInterpretPortable是porttable模式下Java字節碼的執行入口。也就是當執行Java字節碼的時候(比如 TestMain.class中的main函數時),都會調用這個函數。這里要強調一點,JVM執行的時候,除了Java字節碼外,還有很多JVM自己的 處理邏輯。比如分配內存時候對堆棧size的檢查,看看是不是超標。
- DEFINE_GOTO_TABLE則定義了操作碼的標記。
那么,new操作符對應的goto label在哪里呢?來看圖14:
你看,portable.cpp中通過HANDLE_OPCODE(OP_NEW_INSTANCE)定義了new操作符的處理邏輯。這段邏輯中,真正分配內存的操作是由紅框的dvmAllocObject來處理的。
看到這里,你會發現JVM執行Java字節碼還是比較容易理解的。其實對于arm等平臺也是這樣。
3.2 ARM平臺上的執行
和portable下dvmInterpretPortable函數(Java字節碼執行的入口函數)相對應的,其他模式下的入口函數是dvmMterpStd,其代碼如圖15所示:
dvmMterpStd中最重要的是dvmMterpStdRun,這個函數是由各平臺對應的xxx.S匯編文件定義的。InterpAsm-armv7-a-neon.S對應的dvmMterpStdRun函數以及對new的處理邏輯如圖16所示:
圖16中:
- dvmMterpStdRun也是通過GOTO_OPCODE調整到不同操作碼處理邏輯的地方去執行。
- new操作符對應的OP_NEW_INSTANCE處理也會調用dvmAllocObject來分配內存喔。
3.3 小結
這一節我們介紹了JVM是怎么執行Java字節碼的,主要以揭秘性質為主,大家也以掌握原理為首要任務。其中,portable模式下,操作碼是 一條一條解釋執行的。而具體CPU平臺上,則是由相關匯編代碼來處理。二者實際上大同小異。但是由CPU來執行,顯然處理要快,比如對于+這種操作,用 portable的解釋執行當然比直接轉換成機器指令來執行要慢很多。
到此,我們了解了Class文件結構,以及Java字節碼到底是怎么執行的。下一步,我們就開始正式分析Dalvik虛擬機了。
四、Dalvik虛擬機啟動
4.1 dalvik的啟動
Android平臺中,第一個虛擬機是通過app_process進程啟動的,這個進程也就是大名鼎鼎的Zygote(含義是受精卵)。 Zygote的啟動我在《深入理解Android卷I》第四章深入理解Zygote中有詳細分析,這里我們簡單回顧下。圖17所示為zygote啟動的觸 發機制:
上述代碼是位于init.rc中,當Linux天字號第一進程init啟動后,將執行init.rc中的內容。此處的zygote的一個Service,對應的進程是/system/bin/app_process,后面的--zygote...等是該進程的參數。
zygote,也就是app_process,其源碼位于frameworks/base/cmds/app_process里,源碼比較少,主要是一個App_main.cpp。其main函數如下:
int main(int argc, char* const argv[]) { ....... AppRuntime runtime; //AppRuntime是關鍵數據結構 const char* argv0 = argv[0]; int i = runtime.addVmArguments(argc, argv);//添加參數,不重要 // Parse runtime arguments. Stop at first unrecognized option. ....... if (zygote) {//我是zygote runtime.start("com.android.internal.os.ZygoteInit", startSystemServer ? "start-system-server" : ""); } ...... }
runtime是核心對象,其類型是AppRuntime,是定義在app_process中的一個Class,它從AndroidRuntime派生。start函數就是AndroidRuntime中的,用于啟動VM的入口。
4.1.1 AndroidRuntime start之一
start函數我們分兩部分講,第一部分如圖18所示:
第一部分包含三個主要函數:
- jni_invocation.Init:初始化JNI相關的幾個重要函數。
- startVm:注意,它傳入了一個JNIEnv* env對象進去,當這個函數返回時,我們在JNI中天天見的JNIEnv對象就是這個東西。startVm是Dalvik VM的核心,該函數返回后,VM就基本就緒了。
- startReg:注冊Android平臺中一些特有的JNI函數。
(1) JniInvocation Init
該函數內容如圖19所示:
該函數:
- 通過dlopen加載libdvm.so。看來每個Java進程都會有這個東西。這可是dalvik vm的核心庫。這個庫有很多API,我個人覺得如果了解libdvm.so的話,應該能干很多事情。我們后續分析xposed就會看到。
- 從libdvm.so中找到JNI_GetDefaultJavaVMInitArgs、JNI_CreateVM和JNI_GetCreateJavaVMs這三個函數指針。
所以,以后調用比如JNI_CreateVM_函數的時候,我們知道它的真實實現其實是位于libdvm.so中的JNI_CreateVM就好。
比較簡單,Nothing more....
4.2 startVM之旅
startVM屬于Android Runtime start函數的第一部分,不過該函數內容比較多,我們單獨搞一大節來講它!
startVM此函數前面一大段都是參數處理,所以對本文有意義的內容其實只有圖20所示的部分:
核心內容還是在libdvm.so中的JNI_CreateVM函數中,這個函數定義在dalvik/vm/jni.cpp中。來看它!
4.2.1 JNI_CreateJavaVM
(1) gDvm、JavaVMExt和JNIEnvExt
圖21所示為此函數的主要代碼:
圖21中,首先撲面而來的就是Dalvik VM中的幾個重量級數據結構:
- gDvm,全局變量,數據類型為結構體DvmGlobals,該結構體是Dalvik的核心數據結構,幾乎所有的重要成員,控制參數(比如堆棧大小,狀態、已經加載的類信息)等都通過gDvm來管理。
- JavaVMExt:JavaVM在JNI編程中代表虛擬機本身。在Dalvik中,這個虛擬機本身真正的數據類型是此處的 JavaVMExt。由于JNI支持C和C++兩種語言調用(對C而言,就是直接調用函數,對于C++而言,就是調用一個類的成員函數),所以 JavaVM這個數據結構在C++里是一個類(如果定義了__cplusplus宏,就是_JavaVM類),在C里則是 JNIInvokeInterface數據結構。
- 同樣,對于JNIEnvExt而言,當使用C++編譯時候,它就是__JNIEnv類,使用C編譯時就是JNINativeInterface。
圖22所示為JavaVMExt和JNIEnvExt的內容:
圖22中可知:
- JavaVMExt有一個envList鏈表,該鏈表管理這一個Java進程中所有JNIEnv環境實體。JNIEnv環境和線程有關,什么 樣的線程會需要JNIEnv環境呢?所有從Java層調用JNI的線程以及從Native線程往調用Java函數的線程都需要創建一個JNIEnv。說白 了,JNIEnv環境是Java和Native世界的橋梁。
- JNIEnvExt提供的跨Java和Native的橋梁主要就是JNIEnv定義的那些函數,它們統一保存在JNINativeInterface數據結構體中,比如圖中右下角紅框中的NewGlobalRef、NewLocalRef等。
- 注意,gDvm的funcTable變量指向了全局對象gInvokeInterface。該變量定義在dalvik/vm/jni.cpp中。
再來看gDvm的內容,它自己其實就是一大倉庫,里邊有很多成員變量,每個成員變量都有各自的用途。其內部如圖23所示:
圖23中:
- gDvm的數據類型是DvmGlobals,里邊存儲了整個Dalvik虛擬機中相關的參數,成員變量。其中loadedClasses代表虛擬機加載的所有類信息。
- classJavaLangClass指向一個類型為ClassObject的對象。ClassObject是Class信息在代碼中的表 示,其主要內容見圖右上角,它包括類名信息、成員變量、函數(函數的代碼表示是Method)等。classJavaLangClass代表的就是 Java中最基礎的java.lang.Class類。
- ClassObject從Object類派生(C++中,struct其實就是class)
這里要特別說明虛擬機中對類唯一性的確定方法:
1 對我們而言,類的唯一性由包名+類名表示,比如java.lang.Class這個類,就是唯一的。但實際上,根據Java VM規范,類的唯一性由全路徑類名+定義它的ClassLoader兩者唯一確定。
2 對一個類的加載而言,ClassLoader有兩種情況。一種是直接創建目標類,這種loader叫Define Loader(定義加載器)。另外一種情況是一個ClassLoader創建了Class,但它可以自己直接創建,也可以是委托給比如父加載器創建的,這 種Loader叫Initiating Loader(初始加載器)。
3 類的唯一性是由全路徑類名+定義加載器唯一決定。
下面來看JNIEnvExt的創建,這是由圖21中的dvmCreateJNIEnv函數完成的。
(2) dvmCreateJNIEnv
圖21中的調用方法如下:
JNIEnvExt* pEnv = (JNIEnvExt*) dvmCreateJNIEnv(NULL);
該函數的相關代碼如圖24所示:
圖24中,Dalvik虛擬機里JNI的所有函數都封裝在gNativeInterface中。這個結構體包含了JNI定義的所有函數。注意,在 使用sourceInsight的時候會有一些函數無法被解析。因為這些函數使用了類似圖右下角的CALL_VIRTUAL宏方式定義。
我確認了下,應該所有函數的定義其實都在jni.cpp這一個文件里。
到此,我們為主線程創建和初始化了gDvm和JNI環境。下面來看dvmStartup。
4.2.2 dvmStartup:虛擬機創建的核心
去掉dvmStartup函數中一些判斷代碼后,該函數整個執行流程可由圖25表示:
圖25中,dvmStartup的執行從左到右。由于本章我只是想討論dalvik是怎么執行的Java代碼的,所以這里有一些函數(比如GC相關的,就不擬討論)。
dvmStartup首先是解析參數,這些參數信息可能會傳給gDvm相關的成員變量。解析參數是由setCommandLineDefaults和processOptions來完成的。具體代碼就不看了,最終設置的幾個重要的參數是:
- gDvm.executionMode = kExecutionModeJit:如果定義的WITH_JIT宏,則執行模式是JIT模式。
- gDvm.bootClassPathStr:由BOOTCLASSPATH環境變量提供。Nexus7 WiFi版4.4.4的值如圖26所示。
- gDvm.mainThreadStackSize = kDefaultStackSize。kDefaultStackSize值為16K,代表主線程的堆棧大小
- gDvm.dexOptMode = OPTIMIZE_MODE_VERIFIED,用于控制odex操作,該參數表示只對verified的類進行odex。
圖26為Nexus 7 Wi-Fi版4.4.4的BOOTCLASSPATH值:
圖26可知,system/framework下幾乎所有的jar包都被放在了BOOT CLASSPATH里。這意味這zygote進程加載了所有framework的包,這進一步意味著App也加載了所有framework的包.....。
下面來分析幾個和本章目標相關的函數:
(1) dvmThreadStartup
圖27所示為dvmThreadStartup的一些關鍵代碼和解釋:
Thread是Dalvik中代表和管理一個線程的重要結構。注意,這里的Thread不簡單是我們在Java層中的線程。在那里,我們只需要在 線程里執行要干得活就可以了。而這里的Thread幾乎模擬了一個CPU(或者說CPU上的一個核)是怎么執行代碼的。比如Thread中為函數調用要設 置和維護一個棧,還要要有一個變量指向當前正在執行的指令(大名鼎鼎的PC)。這一塊我不想浪費時間介紹,有興趣的童鞋們可以此為契機進行深入研究。
(2) dvmInlineNativeStartup
dvmInlineNativeStartup主要是將一些常用的函數搞成inline似的。這里的inline,其實就是將某些Java函數搞成JNI。比如String類的charAt、compareTo函數等。相關代碼如圖28所示:
注意,在上面函數中,gDvm.inlineMethods只不過是分配了一個內存空間,該空間大小和gDvmInlineOpsTable一 樣。而gDvm.inlineMethods數組元素并未和gDvmInlineOpsTable掛上鉤。當然,最終是會掛上的,但是不在這里。此處暫且 不表。
(3) dvmClassStartup
下面我們跳到dvmClassStartup,這個函數很重要。圖29是其代碼:
圖29中:
- 創建了一個Hash表,用來存儲已經加載的類。
- 創建了代表java.lang.Class和所有基礎數據類型的Class信息。
下面來看processClassPath這個函數,它要加載所有的Boot Class,由于它涉及到類的加載,所以它也是本文的重點內容。先來看圖30:
processClassPath主要是處理BOOTCLASSPATH,也就是圖26中的那些位于system/framework/下的jar包。圖31展示了prepareCpe的代碼,該函數處理一個一個的文件:
prepareCpe倒是很簡單:
- 對于.jar/.zip/.apk結尾的文件,則調用dvmJarFileOpen進行處理。
- 對于.dex結尾的文件則調用dvmRawDexFileOpen進行處理。
- 處理成功后,則設置ClassPathEntry的kind為KCpeJar或者是KCpeDex,代表文件的類型是Jar還是Dex。并且 設置cpe->ptr指針為對應的文件(jar文件則是JarFile,Dex文件這是RawDexFile)。存儲它們的原因是因為后續要從這些 文件中解析里邊包含的信息。
這里我們看dvmJarFileOpen函數,如圖32所示:
圖32介紹了dvmJarFileOpen的主要內容,其中:
- 打開jar中的classes.dex文件,然后判斷有沒有對應的odex文件。如果沒有,就調用dexopt生成一個odex文件。文件后綴還是.dex,但是路徑位于/data/dalvik-cache下。
到此dvmClassStartup就介紹完了。下面來看一個重要函數,dvmFindRequiredClassesAndMembers。
(4) dvmFindRequiredClassesAndMembers
dvmFindRequiredClassesAndMembers初始化一些重要類和函數。其代碼如圖33所示:
dvmFindRequiredClassesAndMembers就是初始化一些類,函數,虛函數等等。我們重點關注它是怎么初始化的。一共有三個重要函數:
- findClassNoInit:和Java層的findClass有關,涉及到JVM中如何加載一個Class。
- dvmFindDirectMethodByDescriptor和dvmFindVirtualMethodByDescriptor:涉及到JVM中如何定位到一個方法。
重點是findClassNoInit,代碼如圖34所示:
圖34中,有幾個關鍵點:
- dvmLookupClass:這是從gDvm的已加載Class Hash表里搜索,看看目標Class是否已經加載了。注意搜索時的匹配條件:前面也曾經說到過,除了類名要相同之外,該類的類加載器也必須一樣。另外, 當待搜索類的類加載器位于clazz的初始化加載類列表中的時候,即使兩個類的定義ClassLoader不一樣,也可以滿足搜索條件。關于初始類加載器 來確定唯一性,我沒有在JVM規范中找到明確的說明。
- loadClassFromDex:該函數將解析odex文件中的類信息。下面重點介紹它。
- dvmAddClasstoHash:把這個新解析得到的Class加到Class Hash表里。
- dvmLinkClass:解析這個Class的一些信息。比如,Class的基類是誰,該class實現了哪些接口。請大家回過頭去看 2.1節的圖2 Class文件內部結構。一個Class的基類以及它實現的接口類信息都是通過對應的索引來間接指向基類Class以及接口類Class的。而 dvmLinkClass處理完后,這些索引將由實際的ClassObject對象來替代。另外,dvmLinkClass將做一些校驗,比如此 Class的基類是final的話,那么這個Class就應該存在。
注意:我們在編寫代碼的時候,對于類的唯一性往往只知道全路徑類名,很少關注ClassLoader的重要性。實際上,我之前曾經碰到過一個問 題:通過兩個不同ClassLoader加載的相同的Class居然不相等。當時很不明白為什么要這么設計, 直到我碰到一個真實事情:有一天我在等車,聽見一個路人大聲叫著“李志剛,李志剛”。我回頭一看,以為他是在找人,結果發現他的寵物狗跑了出來。原來他的 寵物狗就叫李志剛。這就說明,兩個具有相同名字的東西,實際上很能是完全不同的事物。所以,簡單得以兩個類是否同名來判斷唯一性肯定是不行得了。
下面來看最重要的loadClassFromDex,這個函數其實就是把odex文件中的信息轉換成ClassObject。我們來看它:loadClassFromDex代碼如圖34所示:
其中主要的加載函數是loadClassFromDex0,其代碼如圖35所示:
以上是loadClassFromDex0的第一部分內容,這這一塊比較簡單,也就是設置一些東西。下面看圖36
圖36中:
- newClazz的基類和它所實現的接口類,在loadClassFromDex0中還只是一索引來標識。最后這些索引會在dvmLinkClass里轉換并指向成真正的ClassObject。
- 然后調用loadSFieldFromDex來解析類的靜態成員信息。成員信息由數據結構DexFieldId表示,其實包含的那些信息
其實loadClassFromDex0后面的工作也類似,比如解析成員函數信息,成員變量信息等。我們直接看相關函數吧:
圖37展示了解析成員變量和解析函數用的兩個函數。
注意native函數的處理,此處是先用dvmResolveNativeMethod頂著。我們以后分析JNI的時候再來討論它。
上面的findClassNoInit是用于搜索Class的,下面我們來看dvmFindDirectMethodByDescriptor函數,它是用來搜索方法的,代碼如圖38所示:
對compareMethodHelper好奇的讀者,我在圖40里展示了如何從dex文件中獲取一個函數的返回值信息。
好像感覺我們一直和字符串在玩耍。
4.3 小結
說實話,講到現在,其實虛擬機啟動的流程差不多就完了。當然,本節所說的這個流程是很粗獷的,主要內容還是集中在Class的加載上,然后浮光掠 影看了下一些重要的數據結構。Anyway,上述流程,我建議讀者結合代碼反復走幾個來回。下面我們將開始介紹一些細節性的內容:
- 第五章介紹類的初始化和加載。
- 第六章介紹Java中的函數調用到底是怎么實現的。
- 第七章介紹JNI的內容。
五、Class的加載和初始化
JVM中,一個Class首先被使用的時候會調用它的<clinit>函數。<clinit>函數是一個由編譯器生成的 函數,當類有static成員變量或者static語句塊的時候,編譯器就會為它生成這個函數。那么,我們要搞清楚這個函數在什么時候被調用,以什么樣的 方式被調用。
先來看一段示例代碼,如圖41所示:
示例代碼中:
- TestMain有一個靜態成員變量another,其類型是TestAnother。初始值是NULL。
- main函數中,構造了這個TestAnother對象。
- TestAnother有一個靜態成員變量testCLinit和static語句。
- 最后一個圖是執行結果。從其輸出來看,main函數的“00000”先執行,然后執行的是TestAnother的static語句,最后是TestAnother的構造函數。
問題來了:TestAnother的<clinit>什么時候被調用?我一開始思考這個問題的時候:這個函數是編譯器自動生成的,那么調用它的地方是不是也由編譯器控制呢?
要確認這一點,只需要看dexdump的結果,如圖42所示:
圖42中:
- 上圖:由于TestMain也有靜態成員變量,所以編譯器為它生成了<clinit>函數。在它的<clinit> 中,由于another變量賦值為null,所以沒有觸發another類的加載(不過,這個結論不是由圖42得到的,而是由圖41日志輸出的順序得到 的)。
- 下圖:是TestMain的main函數。我們來看another對象的創建,首先是通過new-instance指令創建,然后通過 invoke-direct調用了TestAnother的<init>函數。是的,你沒看錯,TestAnother的構造函數(也就 是<init>)是明確被調用的,但是TestAnother的<clinit>調用之處卻毫無蹤跡。
當然,根據圖41的日志輸出,我們知道<clinit>是在TestAnother的構造函數之前調用的,那唯一有可能的地方會不會是new-instance呢?
5.1 new-instance
我們在3.1節portable的純解釋執行一節中提到過new-instance,下面我們將以portable為主要講解對象來介紹。
其實,不管是portable還是arm、x86方式,最終都會變成機器指令來執行。相對arm、x86的匯編代碼,portable是以C語言實現的Java字節碼解釋器,非常方便我們理解。
圖43為new-instance指令對應的代碼:
第六節會介紹portable模式下Java函數是如何執行的,所以這里大家先不用管HANDLE_OPCODE這樣的宏是干什么用的。圖43中:
- 先調用dvmDexGetResolvedClass,看看目標類TestAnother是不是已經被解析過了。前面曾經提到說,一個類在初始化的時候可能會解析它所使用到的其他類。
- 假設被引用的類沒有解析過,則調用dvmResolveClass來加載目標類。
- 目標類加載成功后,如果該類沒有初始化過,則調用dvmInitClass進行初始化。
我們重點介紹dvmResolveClass和dvmInitClass。
5.1.1 dvmResolveClass分析
圖44是dvmResolveClass的代碼:
圖44中:
- 上圖是dvmResolveClass的代碼,其主要邏輯就是先得到目標類名(Lcom/test/TestAnother;)然后調用dvmFindClassNoInit來加載目標類。
- 下圖是dmvFindClassNoInit的代碼,由于referrer的ClassLoader(也就是使用TestAnother類的 TestMain類的ClassLoader)不為空,代碼邏輯將走到findClassFromLoaderNoInit。注 意,dvmFindSystemClassNoInit我們在4.2.2.4節將bootclass類解析的時候講過。
圖45是findClassFromLoaderNoInit的代碼,出奇的簡單:
代碼真是簡潔啊,居然調用java/lang/ClassLoader的loadClass函數來加載類。當然,dalvik中調用Java函數 是通過dvmCallMethod來實現的。這個函數我們下一節再介紹。然后,我們把loader存儲到目標clazz的初始加載loader鏈表中。初 始加載鏈表在決定類唯一性的時候很有幫助(不記得初始加載器和定義加載器的同學們,請回顧圖23后的說明和圖33)。
Anyway,到此,目標類就算加載成功了。類加載成功到底意味這什么?前面講過loadClassFromDex等函數,類加載成功意味著dalvik虛擬機從dex字節碼文件中成功得到了一個代表該類的ClassObject對象,里邊該填的信息在這里都填好了!
加載成功,下一步工作是初始化,來看下一節:
5.1.2 dvmInitClass分析
圖46為dvmInitClass的代碼:
終于,在dvmInitClass中,我們看到了<clinit>的執行。其他感覺沒什么特別需要說的了。
再次強調,本章是整個虛擬機旅程中一次浮光掠影般的介紹,先讓大家,包括我自己看看虛擬機是個什么樣子,有一個粗略的認識即可。后續有打算搞一個完整的,嚴謹的,基于ART的虛擬機分析系列。
六、Java函數是怎么run起來的
JVM規范定義了JVM應該怎么執行一個函數,東西較碎,但和其他語言一樣,無非是如下幾個要點:
- JVM在執行一個函數之前,它會首先分配一個棧幀(JVM中叫Frame),這個Frame其實就是一塊內存,里邊存儲了參數,還預留了空間用來存儲返回值,還有其他一些東西。
- 函數執行時,從當前棧幀(每一個函數執行之前,JVM都會為它分配一個棧幀)獲取參數等信息,然后執行,然后將返回值存儲到當前棧幀。當前正在執行的函數叫current Method(當前方法)
- 函數返回后,JVM回收當前棧幀。
函數執行肯定是在一個線程里來做的,棧幀則理所當然就會和某個線程相關聯。我們先來看dalvik是怎么創建線程及對應棧的。
6.1 allocThread分析
Dalvik中,allocThread用于創建代表一個線程的線程對象,其代碼如圖47所示:
圖47是dalvik虛擬機為一個線程創建代表對象的處理代碼,其中,它為每個線程都創建了一個線程棧。線程棧大小默認為16KB,并設置了相關的棧頂和棧底指針,如圖中右下角所示:
- interpStackStart為棧頂,位于內存高位值。
- interpStackEnd為棧底,位于內存地位。
- 整個棧的內存起始位置為stackBottom。stackBottom和interpStackEnd還有一個768字節的保護區域。如果棧內容下壓到這塊區域,就認為出錯了。
每個線程都分配16KB,會不會耗費內存呢?不會,這是因為mmap只是在內核里建立了一個內存映射項,這個項覆蓋16KB內存。注意,它只是告 訴kernel,這塊區域最大能覆蓋16KB內存。如果一直沒有使用這塊內存的話,那么內存并不會真正分配。所以,只有我們真正操作了這塊內存,系統才會 為它分配內存。
6.2 dvmCallMethod
dalvik中,如果需要調用某個函數,則會調用dvmCallMethod(嗯嗯?不對吧,Java字節碼里的invoke-direct指令難道也是調用這個么?別急,待會再說invoke-direct的實現。)
dvmCallMethod第一步主要是調用callPrep準備棧幀,這是函數調用的關鍵一步,馬上來看:
6.2.1 dvmPushInterpFrame
當調用一個Java函數時,JVM需要為它搞一個新的棧幀,圖49展示了dvmPushInterpFrame的代碼
圖49中:
- 一個棧幀的大小包括兩個StackSaveArea和輸入參數及函數內部本地變量(大小為 method->registersSize*4)所需的空間。但是,在計算棧是否overflow的時候,會額外加上該函數內部調用其他函數時所 傳參數所占空間(大小為method->outsSize*4)
- 這兩個StackSaveArea,一個叫BreakSaveBlock,另外一個叫SaveBlock。其分布如圖49中右下角位置所示。這兩個SSA的作用,我們后面將看到。
- self->interpSave.curFrame指向saveBlock的高地址。緊接其上的就是參數空間
1 注意:registersSize包括函數輸入參數和函數內部本地變量的個數
2 dvmPushJNIFrame,這個函數是當Java要調用JNI函數時的壓棧處理,該函數和dvmPushInterpFrame幾乎一樣,只是在計 算所需棧空間時,沒有加上outsSize*4,因為native函數所需棧是由Native自己控制的。此函數代碼很簡單,請童鞋們自己學習
好了,棧已經準備好了,我們看看函數到底怎么執行。
6.2.2 參數入棧
圖48中dvmCallMethodV調用callPrep之后,有一段代碼我們還沒來得及展示,如圖50所示:
參數入棧,您看明白了嗎?
6.2.3 調用函數
接著看dvmCallMethodV調用函數部分,如圖51所示
對于java函數,其處理邏輯由dvmInterpret完成,對于Native函數,則由對應的nativeFunc完成。JNI我們放到后面講,先來處理dvmInterpret。如圖52所示:
圖52中:
- self->interpSave.pc指向要指向函數的指令部分(method->insns)
下面我們來看dvmInterpretPortable的處理:
(1) dvmInterpretPortable
dvmInterpretPortable位于dalvik/vm/mterp/out/InterpC-portable.cpp里,這個 InterpC-portable.cpp是用工具生成的,將分散在其他地方的函數合并到最終這一個文件里。我們先來看該函數的第一段內容,如圖53所 示:
第一部分中,我們發現dvmInterpretPortable通過DEFINE_GOTO_TABLE定義了一個 handlerTable[kNumPackedOpcodes]數組,這個數組里的元素通過H宏定義。H宏使用了&&操作符來獲取某個 goto label的位置。比如圖中的H(OP_RETURN_VOID),展開這個宏后得到&&op_OP_RETURN_VOID,這表示 op_OP_RETURN_VOID的位置。
那么,這個op_OP_RETURN_VOID標簽是誰定義的呢?恩,圖中的HANDLE_OPCODE宏定義的,展開后得到op_OP_RETURN_VOID:。
最后:
- pc=self->interpSave.pc:將pc指向self->interpSave.pc,它是什么?回顧圖52,原來這就是method->insns。也就是這個方法的第一個字節碼指令。
- fp=self->interpSave.curFrame:參看圖50右邊的示意圖。
來看portable模式下Java字節碼的處理,這也是最精妙的一部分,如圖54所示:
請先認真看圖54的內容,然后再看下面的總結,portable模式下:
- FINISH(0):移動PC,然后獲取對應指令的操作碼到ins。根據ins獲取該指令的操作碼(注意,一條指令包含操作碼和操作數),然后goto到該操作碼對應的處理label處。
- 在對應label處理邏輯處:從指令中提取參數,比如INST_A或INST_B。然后處理,然后再次調整PC,使得它能處理下一條指令。
好了,portable模式下dalvik如何運行java指令就是這樣的,就是這么任性,就是這么簡單。下面,我們來看Invoke-direct指令又是如何被解析然后執行的。
(2) invoke-direct指令是如何被執行的
剛才你看到了portable模式下指令的執行,就是解析指令的操作碼然后跳轉到對應的label。假設我們現在碰到了invoke-direct指令,這是用來調用函數的。我們看看dvmInterpretPortable怎么處理它。一個圖就可以了,如圖55所示:
就是跳來跳去麻煩點,其實和dvmCallMethod一樣一樣。
(3) 函數返回
一切盡在圖56。
函數返回后,還需要pop棧幀,代碼在stack.cpp的dvmPopFrame中。此處略過不討論了。
6.3 小結
這一節你真得要好好思考,函數調用,不論是Java、C/C++,python等等,都有這類似的處理:
- 建立棧幀,參數入棧。
- 跳轉到對應函數的位置,native就是函數地址指針,Java這是goto label,轉換成匯編還是地址指針。
- 函數返回,pop棧幀。
這好像是程序設計的基礎知識,這回你真正明白了嗎?
七、JNI相關
關于JNI,我打算介紹下面幾個內容:
- Java層加載so庫,so庫中一般會注冊相關JNI函數。
- Java層調用native函數。
native庫中,如果某個線程需要調用java函數,它會先創建一個JNIEnv環境,然后callXXMethod來調用Java層函數。這部分內容請大家自行研究吧....
把這幾個步驟講清楚的話,JNI內容就差不多了。
7.1 so加載和JNI函數注冊
7.1.1 so文件搜索路徑和so加載
APP中,如果要使用JNI的話,native函數必須封裝在動態庫里,Windows平臺叫DLL,Linux平臺叫so。然后,我們要在 APP中通過System.loadLibrary方法把這個so加載進來。所以,入口是System的loadLibrary函數。相關代碼如圖57所 示:
圖57是System.loadLibrary的相關代碼。這里主要介紹了so加載路徑的問題:
- 我們在應用里調用loadLibrary的時候系統默認會傳入調用類的ClassLoader。如果有ClassLoader,則so必須由 它加載。原因其實很簡單,就是APP只能加載自己的so,而不能加載別的APP的so。這種做法和傳統的linux平臺上把so的搜索路徑設置到 LD_LIBRARY_PATH環境變量中有沖突,所以Android想出了這種辦法。
- 如果沒有ClassLoader,則還是使用傳統的LD_LIBRARY_PATH來搜索相關目錄以加載so。
這里再明確解釋下,loadLibrary只是指定了so文件的名字,而沒有指定絕對路徑。所以虛擬機得知道去哪個目錄搜索這個文件。傳統做法是 搜索LD_LIBRARY_PATH環境變量所表明的文件夾(AOSP默認是/vendor/lib和/system/lib)這兩個目錄。但是我剛才 講,如果使用傳統方法,APP A有so要加載的話,得把自己的路徑加到LD_LIBRARY_PATH里去。比如LD_LIBRARY_PATH=/vendor/lib: /system/lib:/data/data/pkg-of-app-A/libs,這種方法將導致任何APP都可以加載A的so。
真正的加載由doLoad函數完成。這個函數相關的代碼如圖58所示:
沒什么太多可說的,無非就是dlopen對應的so,然后調用JNI_OnLoad(如果該so定義了這個函數的話)。另外,dalvik虛擬機會保存自己加載的so項。
注意,圖58里左邊有兩個笑臉,當然是很“陰險”的笑臉。什么意思呢?請童鞋們看看nativeLoad和它對應的 Dalvik_java_lang_Runtime_nativeLoad函數。你會發現Runtime_nativeLoad的函數參數聲明好奇怪,完 全不符合JNI規范。并且,Runtime_nativeLoad的函數返回是void,但是Java中的nativeLoad卻是有返回值的。怎么回 事???此處不表,下文接著說。
7.1.2 JNI 函數主動注冊和被動注冊
(1) 調用RegisterNatives主動注冊JNI函數
我們在JNI里,往往會自行注冊java中native函數和native層對應函數的關系。這樣,Java層調用native函數時候就會轉到 native層對應函數來執行。注冊,是通過JNIEnv的RegisterNatives函數來完成的。我們來看看它的實現。如圖59所示:
RegisterNatives里有幾個比較重要的點:
- 如果簽名信息以!開頭,則采用fastjni模式。這個玩意具體是什么,我們后面會講。
- Method的nativeFunc指向dvmCallJNIMethod,當java層調用native函數的時候會進入這個函數。而真正的native函數指針則存儲在Method->insns中。我們知道insns代表一個函數的字節碼.....。
(2) 被動注冊
被動注冊,也就是JNI里不調用RegisterNatives函數,而是讓虛擬機根據一定規則來查找native函數的實現。一般的JNI教科書都是介紹被動注冊,不過我從《深入理解Android卷1》開始就建議直接上主動注冊方法。
dalvik中,當最開始加載類并解析其中的函數時,如果標記為native函數,則會把Method->nativeFunc設置為dvmResolveNativeMethod(請回頭看圖37)。我們來看這個函數的內容,如圖60所示:
被動注冊的方式是在該native函數第一次調用的時候被處理。童鞋們主要注意native函數的匹配規則。Anyway,不建議使用被動注冊的方法,因為native層設置的函數名太長,搞起來很不方便。
7.2 調用Java native函數
6.2節專門講過如何調用java函數,故事還得從dvmCallMethodV說起,如圖61所示:
整個流程如下:
- dvmCallMethodV發現目標函數是native的時候,就直接調用method->nativeFunc。當native函 數已經解析過的時候,一般情況下該函數都指向dvmCallJNIMethod。如果這個native函數之前沒有解析,則它指向 dvmResolveNativeMethod。
- dvmCallJNIMethod進行參數處理,然后調用dvmPlatformInvoke,這個函數一般由不同平臺的匯編代碼提供,大致工作流程也就是解析參數,壓棧,然后調用method->insns指向的native層函數。
圖62是X86平臺上關于dvmPlatformInvoke注釋:
也就是解析參數嘛,不多說了。和前面講的Java準備棧幀類似,無非是用匯編寫得罷了。
(1) 神秘得fastJni
fastJni,唉,可惜代碼里有這個,但是好像沒地方用。干啥的呢?還記得我們前面圖58里的兩個笑臉嗎?
實話告訴大家,fastJni如果真正實現的話,可以加快JNI層函數的調用。為什么?我先給你看個東西,如圖63所示:
圖63需要好好解釋下:
- 首先,我們有兩種類型的函數,一個是DalvikBridgeFunc,這個函數有四個參數。一個是DalvikNativeFunc,這個函數有兩個參數。
- dvmResolveNativeMethod或者是dvmCallJNIMethod都屬于DalvikBridgeFunc類型。
- 不過,如果是dalvik內部注冊的native函數時候,比如Dalvik_java_lang_Runtime_nativeLoad這 樣的,它就屬于dalvik內部注冊的native函數,這個函數的類型就是DalvikNativeFunc。參考圖61右上角。也就是 說,Android為java.lang.Runtime.nativeLoad這個java層的native函數設置了一個native層的實現,這個 實現就是Dalvik_java_lang_Runtime_nativeLoad。
- 接著,這個函數被強制轉換成DalvikBridgeFunc類型,并且設置到了Method->nativeFunc上。
這種做法會造成什么后果呢?
- dvmCallMethodV發現自己調用的是native函數時候,直接調用Method->nativeFunc,也就是說,要么 調用到dvmCallJNIMethod(或者是dvmResolveNativeMethod,姑且不論它)要么就直接調用到 Dalvik_java_lang_Runtime_nativeLoad上了。
注意喔,這兩個函數的參數一個是四個參數,一個是兩個參數。不過注釋中說了,給一個只有兩個參數的函數傳4個參數沒有問題.....
等等,這么做的好處是什么?
- 原來,dvmCallJNIMethod干了好多雜事,比如參數解析,參數入棧,然后才是通過dvmPlatformInvoke來調用真正的native層函數。而且還要對返回值進行處理。
- fastJni模式下,直接調用對應的函數(比如Dalvik_java_lang_Runtime_nativeLoad),這樣就沒必要做什么參數入棧之類,也不用借助dvmPlatformInvoke再跳轉了,肯定比dvmCallMethod省了不少時間。
當然,fastJni模式是有要求的,比如是靜態,而且非synchronized函數。Anyway,目前這么高級的功能還是只有虛擬機自己用,沒放開給應用層。
八 dalvik虛擬機小結
本篇是我第一次細致觀察Android上Java虛擬機的實現,起因是想知道xposed的原理。我們下一篇會分析xposed的原理,其實蠻簡 單。因為xposed只涉及到了函數調用,hook之類的東西,沒有虛擬機里什么內存管理,線程管理之類的。所以,我們這兩篇文章都不會涉及內存管理,線 程管理之類的高級玩意兒。
簡單點說,本章介紹得和dalvik相關的內容還是比較好理解。希望各位先看看,有個感性認識,為將來我們搞更深入的研究而打點基礎。
- 參考文檔 。
- 很詳細的 關于dex文件的中文介紹 。
- dex/odex指令集可參考 這里 。
- 解釋器中對標號的使用 。
- 深入理解Android卷1和卷2的電子版已經全部公開,卷1第四章內容請參考 這里 。