Android上的Dalvik虛擬機器

paulquei發表於2018-09-21

本文會介紹Andorid系統上曾經使用過的Dalvik虛擬機器。後面還會有一篇文章講解Android系統上現在使用的虛擬機器:ART。

另外,我的部落格裡有一篇關於Java虛擬機器的預習文章也可以看一看:Java虛擬機器與垃圾回收演算法

也許有人會問,既然Dalvik虛擬機器都已經被廢棄了,為什麼我們還要了解它呢?出於下面的原因,讓我覺得還是有必要了解一下Dalvik虛擬機器的:

  • Dalvik留下的很多機制在現在的Android系統是一樣適用的,例如Dalvik指令,dex檔案
  • 並非每個人都是在最新版本的Android系統上工作
  • 瞭解一項技術曾經的歷史和演進過程,有助於增加對於現在狀態的理解

Dalvik是Google專門為Android作業系統開發的虛擬機器。它支援.dex(即“Dalvik Executable”)格式的Java應用程式的執行。.dex格式是專為Dalvik設計的一種壓縮格式,適合記憶體和處理器速度有限的系統。

Dalvik由Dan Bornstein編寫,名字來源於他的祖先曾經居住過的小漁村達爾維克(Dalvík),位於冰島。

棧 VS 暫存器

大多數虛擬機器都是基於堆疊架構的,例如前面提到的HotSpot JVM。然而Dalvik虛擬機器卻恰好不是,它是基於暫存器架構的虛擬機器。

對於基於棧的虛擬機器來說,每一個執行時的執行緒,都有一個獨立的棧。棧中記錄了方法呼叫的歷史,每有一次方法呼叫,棧中便會多一個棧楨。最頂部的棧楨稱作當前棧楨,其代表著當前執行的方法。棧楨中通常包含四個資訊:

  • 區域性變數:方法引數和方法中定義的區域性變數
  • 運算元棧:後入先出的棧
  • 動態連線:指向執行時常量池該棧楨所屬方法的引用
  • 返回地址:當前方法的返回地址

棧幀的結構如下圖所示:

stack_frame.png

基於堆疊架構的虛擬機器的執行過程,就是不斷在運算元棧上操作的過程。例如,對於計算“1+1”的結果這樣一個計算,基於棧的虛擬機器需要先將這兩個數壓入棧,然後通過一條指標對棧頂的兩個數字進行加法運算,然後再將結果儲存起來。其指令集會是這樣子:

iconst_1
iconst_1
iadd
istore_0

而對於基於暫存器的虛擬機器來說執行過程是完全不一樣的。該型別虛擬機器會將運算的引數放至暫存器中,然後在暫存器上直接進行運算。因此如果是基於暫存器的虛擬機器,其指令可能會是這個樣子:

mov eax,1
add eax,1

這兩種架構哪種更好呢?

很顯然,既然它們同時存在,那就意味著它們各有優劣,假設其中一種明顯優於另外一種,那劣勢的那一種便就不會存在了。

如果我們對這兩種架構進行對比,我們會發現它們存在如下的區別:

  • 基於棧的架構具有更好的可移植性,因為其實現不依賴於物理暫存器
  • 基於棧的架構通常指令更短,因為其操作不需要指定運算元和結果的地址
  • 基於暫存器的架構通常執行速度更快,因為有暫存器的支撐
  • 基於暫存器的架構通常需要較少的指令來完成同樣的運算,因為不需要進行壓棧和出棧

dex檔案

如果我們對比jar檔案和dex檔案,就會發現:dex檔案格式相對來說更加的緊湊。

jar檔案以class為區域進行劃分,在連續的class區域中會包含每個class中的常量,方法,欄位等等。而dex檔案按照型別(例如:常量,欄位,方法)劃分,將同一型別的元素集中到一起進行存放。這樣可以更大程度上避免重複,減少檔案大小。

兩種檔案格式的對比如下圖所示:

jar_vs_dex.png

dex檔案的完整格式參見這裡:Dalvik 可執行檔案格式

由於Dex檔案相較於Jar來說,對同一型別的元素進行了規整,並且去掉了重複項。因此通常情況下,對於同樣的內容,前者比後者檔案要更小。以下是Google給出的資料,從這個對比資料可以看出,兩者的差距還是很大的。

內容 未壓縮jar包 已壓縮jar包 未壓縮dex檔案
系統庫 100% 50% 48%
Web瀏覽器 100% 49% 44%
鬧鐘應用 100% 52% 44%

為了便於開發者分析dex檔案中的內容,Android系統中內建了dexdump工具。藉助這個工具,我們可以詳細瞭解到dex的檔案結構和內容。以下是這個工具的幫助文件。在接下來的內容中,我們將借這個工具來反編譯出dex檔案中的Dalvik指令。

angler:/ # dexdump
dexdump: no file specified
Copyright (C) 2007 The Android Open Source Project

dexdump: [-c] [-d] [-f] [-h] [-i] [-l layout] [-m] [-t tempfile] dexfile...

 -c : verify checksum and exit
 -d : disassemble code sections
 -f : display summary information from file header
 -h : display file header details
 -i : ignore checksum failures
 -l : output layout, either `plain` or `xml`
 -m : dump register maps (and nothing else)
 -t : temp file name (defaults to /sdcard/dex-temp-*)

Dalvik指令

Dalvik虛擬機器一共包含兩百多條指令。讀者可以訪問下面這個網址獲取這些指令的詳細資訊:Dalvik 位元組碼

我們這裡不會對每條指令做詳細講解,建議讀者大致瀏覽一下上面這個網頁。

下面以一個簡單的例子來讓讀者對Dalvik指令有一個直觀的認識。

下面是一個Activity的原始碼,在這個Activity中,我們定義了一個sum方法,進行兩個整數的相加。然後在Activity的onCreate方法中,在setContentView之後,呼叫這個sum方法並傳遞1和2,然後再將結果通過System.out.print進行輸出。這段程式碼很簡單,簡單到幾乎沒有什麼實際的作用,不過這不要緊,因為這裡我們的目的僅僅想看一下我們編寫的原始碼最終得到的Dalvik指令究竟是什麼樣的。

package test.android.com.helloandroid;

import android.app.Activity;
import android.os.Bundle;

public class MainActivity extends Activity {

    int sum(int a, int b) {
        return a + b;
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        System.out.print(sum(1,2));
    }
}

將這個工程編譯之後獲得了APK檔案。APK檔案其實是一種壓縮格式,我們可以使用任何可以解壓Zip格式的軟體對其解壓縮。解壓縮之後的檔案列表如下所示:

├── AndroidManifest.xml
├── META-INF
│   ├── CERT.RSA
│   ├── CERT.SF
│   └── MANIFEST.MF
├── classes.dex
├── res
│   ├── layout
│   │   └── activity_main.xml
│   ├── mipmap-hdpi-v4
│   │   ├── ic_launcher.png
│   │   └── ic_launcher_round.png
│   ├── mipmap-mdpi-v4
│   │   ├── ic_launcher.png
│   │   └── ic_launcher_round.png
│   ├── mipmap-xhdpi-v4
│   │   ├── ic_launcher.png
│   │   └── ic_launcher_round.png
│   ├── mipmap-xxhdpi-v4
│   │   ├── ic_launcher.png
│   │   └── ic_launcher_round.png
│   └── mipmap-xxxhdpi-v4
│       ├── ic_launcher.png
│       └── ic_launcher_round.png
└── resources.arsc

其他的檔案不用在意,這裡我們只要關注dex檔案即可。我們可以通過adb push命令將classes.dex檔案拷貝到手機上,然後通過手機上的dexdump命令來進行分析。

直接輸入dexdump classes.dex會得到一個非常長的輸出。下面是其中的一個片段:

...
Class #40            -
  Class descriptor  : `Ltest/android/com/helloandroid/MainActivity;`
  Access flags      : 0x0001 (PUBLIC)
  Superclass        : `Landroid/app/Activity;`
  Interfaces        -
  Static fields     -
  Instance fields   -
  Direct methods    -
    #0              : (in Ltest/android/com/helloandroid/MainActivity;)
      name          : `<init>`
      type          : `()V`
      access        : 0x10001 (PUBLIC CONSTRUCTOR)
      code          -
      registers     : 1
      ins           : 1
      outs          : 1
      insns size    : 4 16-bit code units
      catches       : (none)
      positions     :
        0x0000 line=6
      locals        :
        0x0000 - 0x0004 reg=0 this Ltest/android/com/helloandroid/MainActivity;
  Virtual methods   -
    #0              : (in Ltest/android/com/helloandroid/MainActivity;)
      name          : `onCreate`
      type          : `(Landroid/os/Bundle;)V`
      access        : 0x0004 (PROTECTED)
      code          -
      registers     : 5
      ins           : 2
      outs          : 3
      insns size    : 20 16-bit code units
      catches       : (none)
      positions     :
        0x0000 line=14
        0x0003 line=15
        0x0008 line=17
        0x0013 line=18
      locals        :
        0x0000 - 0x0014 reg=3 this Ltest/android/com/helloandroid/MainActivity;
        0x0000 - 0x0014 reg=4 savedInstanceState Landroid/os/Bundle;
    #1              : (in Ltest/android/com/helloandroid/MainActivity;)
      name          : `sum`
      type          : `(II)I`
      access        : 0x0000 ()
      code          -
      registers     : 4
      ins           : 3
      outs          : 0
      insns size    : 3 16-bit code units
      catches       : (none)
      positions     :
        0x0000 line=9
      locals        :
        0x0000 - 0x0003 reg=1 this Ltest/android/com/helloandroid/MainActivity;
        0x0000 - 0x0003 reg=2 a I
        0x0000 - 0x0003 reg=3 b I
  source_file_idx   : 455 (MainActivity.java)
...

從這個片段中,我們看到了剛剛編寫的MainActivity類的詳細資訊。包括每一個方法的名稱,簽名,訪問級別,使用的暫存器等資訊。

接下來,我們通過dexdump -d classes.dex來反編譯程式碼段,以檢視方法實現邏輯所對應的Dalvik指令。

通過這個命令,我們得到sum方法的指令如下:

[019f98] test.android.com.helloandroid.MainActivity.sum:(II)I
0000: add-int v0, v2, v3

為了看懂add-int指令的含義,我們可以查閱Dalvik指令的說明文件:

Mnemonic / Syntax Arguments Description
binop vAA, vBB, vCC
90: add-int
A: destination register or pair (8 bits)
B: first source register or pair (8 bits)
C: second source register or pair (8 bits)
Perform the identified binary operation
on the two source registers,
storing the result in the destination register.

這段說明文件的含義是:add-int是一個需要兩個運算元的指令,其指令格式是:add-int vAA, vBB, vCC。其指令的運算過程,是將後面兩個暫存器中的值進行(加)運算,然後將結果放在(第一個)目標暫存器中。

很顯然,對應到add-int v0, v2, v3就是將v2和v3兩個暫存器的值相加,並將結果儲存到v0暫存器上。這正好是對應了我們所寫的程式碼:return a + b;

下面,我們再看一下稍微複雜一點的onCreate方法其對應的Dalvik指令:

[019f60] test.android.com.helloandroid.MainActivity.onCreate:(Landroid/os/Bundle;)V
0000: invoke-super {v3, v4}, Landroid/app/Activity;.onCreate:(Landroid/os/Bundle;)V // method@0001
0003: const/high16 v0, #int 2130903040 // #7f03
0005: invoke-virtual {v3, v0}, Ltest/android/com/helloandroid/MainActivity;.setContentView:(I)V // method@0318
0008: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream; // field@02e0
000a: const/4 v1, #int 1 // #1
000b: const/4 v2, #int 2 // #2
000c: invoke-virtual {v3, v1, v2}, Ltest/android/com/helloandroid/MainActivity;.sum:(II)I // method@0319
000f: move-result v1
0010: invoke-virtual {v0, v1}, Ljava/io/PrintStream;.print:(I)V // method@02d7
0013: return-void

同樣,通過查閱指令的說明文件,我們可以知道這裡牽涉到的幾條指令含義如下:

  • invoke-super: 呼叫父類中的方法
  • const/high16: 將指定的字面值的高16位拷貝到指定的暫存器中,這是一個16bit的操作
  • invoke-virtual: 呼叫一個virtual方法
  • sget-object: 獲取類中static欄位的物件,並存放到指定的暫存器上
  • const/4: 將指定的字面值拷貝到指定的暫存器中,這是一個32bit的操作
  • move-result: 該指令緊接著invoke-xxx指令,將上一條指令的結果移動到指定的暫存器中
  • return-void: void方法返回

由此,我們便能看懂這段指令的含義了。甚至我們已經具備了閱讀任何Dalvik程式碼的能力,因為無非就是明白每個指令的含義罷了。

單純的閱讀指令的說明文件可能很枯燥,也不容易記住。建議讀者繼續寫一些複雜的程式碼然後通過反編譯方式檢視其對應的虛擬機器指令來進行學習。或者對已有的專案進行反編譯來檢視其機器指令。也許一些讀者覺得,開發者根本不必去閱讀這些原本就不準備給人類閱讀的機器指令。但實際上,對於底層指令越是熟悉,對底層機制越是瞭解,往往能讓我們寫出越是高效的程式來,因為一旦我們深刻理解機制背後的執行原理,就可以避過或者減少一些不必要的重複運算。再者,具備對於底層指令的理解能力,也為我們分析解決一些從原始碼層無法分析的問題提供了一個新的手段。

最後筆者想提醒一下,即便在ART虛擬機器時代,這裡學習的Dalvik指令和反編譯手段仍然是沒有過時的。因為這種分析方式是依然可用的。這也是為什麼我們要講解Dalvik虛擬機器的原因。

Dalvik啟動過程

注:自Android 5.0開始,Dalvik虛擬機器已經被廢棄,其原始碼也已經被從AOSP中刪除。因此想要檢視其原始碼,需要獲取Android 4.4或之前版本的程式碼。本小節接下來貼出的原始碼取自AOSP程式碼TAG android-4.4_r1

Dalvik虛擬機器的原始碼位於下面這個目錄中:

/dalvik/vm/

在其他的文章(這裡, 還有這裡)中,我們講解了系統的啟動過程,並且也介紹了zygote程式。我們提到zygote程式會啟動虛擬機器,但是卻沒有深入瞭解過虛擬機器是如何啟動的,而這正是本文接下來要講解的內容。

zygote程式是由app_process啟動的,我們來回顧一下app_process main函式中的關鍵程式碼:

// app_process.cpp

int main(int argc, char* const argv[])
{
...
    if (zygote) {
        runtime.start("com.android.internal.os.ZygoteInit", args, zygote);
    } else if (className) {
        runtime.start("com.android.internal.os.RuntimeInit", args, zygote);
    } else {
        fprintf(stderr, "Error: no class name or --zygote supplied.
");
        app_usage();
        LOG_ALWAYS_FATAL("app_process: no class name or --zygote supplied.");
        return 10;
    }
}

這裡通過runtime.start方法指定入口類啟動了虛擬機器。虛擬機器在啟動之後,會以入口類的main函式為起點來執行。

runtimeAppRuntime類的物件,start方法是在AppRuntime類的父類AndroidRuntime中定義的方法。該方法中的關鍵程式碼如下:

// AndroidRuntime.cpp

void AndroidRuntime::start(const char* className, const char* options)
{
    ...

    /* start the virtual machine */
    JniInvocation jni_invocation;
    jni_invocation.Init(NULL);
    JNIEnv* env;
    if (startVm(&mJavaVM, &env) != 0) { ①
        return;
    }
    onVmCreated(env);

    /*
     * Register android functions.
     */
    if (startReg(env) < 0) { ②
        ALOGE("Unable to register all android natives
");
        return;
    }

    ...

    char* slashClassName = toSlashClassName(className);
    jclass startClass = env->FindClass(slashClassName); ③
    if (startClass == NULL) {
        ALOGE("JavaVM unable to locate class `%s`
", slashClassName);
        /* keep going */
    } else {
        jmethodID startMeth = env->GetStaticMethodID(startClass, "main",
            "([Ljava/lang/String;)V");
        if (startMeth == NULL) {
            ALOGE("JavaVM unable to find main() in `%s`
", className);
            /* keep going */
        } else {
            env->CallStaticVoidMethod(startClass, startMeth, strArray); ④

#if 0
            if (env->ExceptionCheck())
                threadExitUncaughtException(env);
#endif
        }
    }
    free(slashClassName);

    ALOGD("Shutting down VM
");  ⑤
    if (mJavaVM->DetachCurrentThread() != JNI_OK)
        ALOGW("Warning: unable to detach main thread
");
    if (mJavaVM->DestroyJavaVM() != 0)
        ALOGW("Warning: VM did not shut down cleanly
");
}

這段程式碼主要邏輯如下:

  1. 通過startVm方法啟動虛擬機器
  2. 通過startReg方法註冊Android Framework類相關的JNI方法
  3. 查詢入口類的定義
  4. 呼叫入口類的main方法
  5. 處理虛擬機器退出前執行的邏輯

接下來我們先看startVm方法的實現,然後再看startReg方法。

AndroidRuntime::startVm方法有三百多行程式碼。但其邏輯卻很簡單,因為這個方法中的絕大部分程式碼都是在確定虛擬機器的啟動引數的值。這些值主要來自於許多的系統屬性,這個方法中讀取的屬性以及這些屬性的含義如下表所示:

屬性名稱 屬性的含義
dalvik.vm.checkjni 是否要執行擴充套件的JNI檢查,CheckJNI是一種新增額外JNI檢查的模式;出於效能考慮,這些選項在預設情況下並不會啟用。此類檢查將捕獲一些可能導致堆損壞的錯誤,例如使用無效/過時的區域性和全域性引用。如果這個值為false,則讀取ro.kernel.android.checkjni的值
ro.kernel.android.checkjni 只讀屬性,是否要執行擴充套件的JNI檢查。當dalvik.vm.checkjni為false,此值才生效
dalvik.vm.execution-mode Dalvik虛擬機器的執行模式,即:所使用的直譯器,下文會講解
dalvik.vm.stack-trace-file 指定堆疊跟蹤檔案路徑
dalvik.vm.check-dex-sum 是否要檢查dex檔案的校驗和
log.redirect-stdio 是否將stdout/stderr轉換成log訊息
dalvik.vm.enableassertions 是否啟用斷言
dalvik.vm.jniopts JNI可選配置
dalvik.vm.heapstartsize 堆的起始大小
dalvik.vm.heapsize 堆的大小
dalvik.vm.jit.codecachesize JIT程式碼快取大小
dalvik.vm.heapgrowthlimit 堆增長的限制
dalvik.vm.heapminfree 堆的最小剩餘空間
dalvik.vm.heapmaxfree 堆的最大剩餘空間
dalvik.vm.heaptargetutilization 理想的堆記憶體利用率,其取值位於0與1之間
ro.config.low_ram 該裝置是否是低記憶體裝置
dalvik.vm.dexopt-flags 是否要啟用dexopt特性,例如位元組碼校驗以及為精確GC計算暫存器對映
dalvik.vm.lockprof.threshold 控制Dalvik虛擬機器除錯記錄程式內部鎖資源爭奪的閾值
dalvik.vm.jit.op 對於指定的操作碼強制使用解釋模式
dalvik.vm.jit.method 對於指定的方法強制使用解釋模式
dalvik.vm.extra-opts 其他選項

注:Android系統中很多服務都有類似的做法,即:通過屬性的方式將模組的配置引數外化。這樣外部只要設定屬性值即可以改變這些模組的內部行為。

這些屬性的值會被讀取並最終會被組裝到initArgs中,並以此傳遞給JNI_CreateJavaVM函式來啟動虛擬機器:

// AndroidRuntime.cpp

if (JNI_CreateJavaVM(pJavaVM, pEnv, &initArgs) < 0) {
    ALOGE("JNI_CreateJavaVM failed
");
    goto bail;
}

JNI_CreateJavaVM函式是虛擬機器實現的一部分,因此該方法程式碼已經位於Dalvik中。具體的在這個檔案中:/dalvik/vm/Jni.pp。

JNI_CreateJavaVM方法中的關鍵程式碼如下所示:

// Jni.cpp

jint JNI_CreateJavaVM(JavaVM** p_vm, JNIEnv** p_env, void* vm_args) {
    const JavaVMInitArgs* args = (JavaVMInitArgs*) vm_args;
    ...

    memset(&gDvm, 0, sizeof(gDvm));

    JavaVMExt* pVM = (JavaVMExt*) calloc(1, sizeof(JavaVMExt));
    pVM->funcTable = &gInvokeInterface;
    pVM->envList = NULL;
    dvmInitMutex(&pVM->envListLock);

    UniquePtr<const char*[]> argv(new const char*[args->nOptions]);
    memset(argv.get(), 0, sizeof(char*) * (args->nOptions));

    ...

    JNIEnvExt* pEnv = (JNIEnvExt*) dvmCreateJNIEnv(NULL);

    gDvm.initializing = true;
    std::string status =
            dvmStartup(argc, argv.get(), args->ignoreUnrecognized, (JNIEnv*)pEnv);
    gDvm.initializing = false;

    ...

    dvmChangeStatus(NULL, THREAD_NATIVE);
    *p_env = (JNIEnv*) pEnv;
    *p_vm = (JavaVM*) pVM;
    ALOGV("CreateJavaVM succeeded");
    return JNI_OK;
}

在這個函式中,會讀取啟動的引數值,並將這些值設定到兩個全域性變數中,它們是:

// Init.cpp

struct DvmGlobals gDvm;
struct DvmJniGlobals gDvmJni;

DvmGlobals這個結構體的定義非常之大,總計有約700行,其中儲存了Dalvik虛擬機器相關的全域性屬性,這些屬性在虛擬機器執行過程中會被用到。而gDvmJni中則記錄了Jni相關的屬性。

JNI_CreateJavaVM函式中最關鍵的就是呼叫dvmStartup函式。很顯然,這個函式的含義是:Dalvik Startup。因此這個函式負責了Dalvik虛擬機器的初始化工作,由於虛擬機器本身也是有很多子模組和元件構成的,因此這個函式中呼叫了一系列的初始化方法來完成整個虛擬機器的初始化工作,這其中包含:虛擬機器堆的建立,記憶體分配跟蹤器的建立,執行緒的啟動,基本核心類載入等一系列工作,在這之後整個虛擬機器就啟動完成了。

這些方法是與Dalvik的實現細節緊密相關的,這裡我們就不深入了,有興趣的讀者可以自行去學習。

虛擬機器啟動完成之後就可以用了。但對於Android系統來說,還有一些工作要做,那就是Android Framework相關類的JNI方法註冊。我們知道,Android Framework主要是Java語言實現的,但其中很多類都需要依賴於native實現,因此需要通過JNI將兩種實現銜接起來。例如,在第1章我們講解Binder機制中的Parcel類就是既有Java層介面也有native層的實現。除了Parcel類,還有其他類也是類似的。並且,Framework中的類是幾乎每個應用程式都可能會被用到的,為了減少每個應用程度單獨載入的邏輯,因此虛擬機器在啟動之後直接就將這些類的JNI方法全部註冊到虛擬機器中了。完成這個邏輯的便是上面我們看到的startReg方法:

/*
* Register android functions.
*/
if (startReg(env) < 0) {
   ALOGE("Unable to register all android natives
");
   return;
}

這個函式是在註冊所有Android Framework中類的JNI方法,在AndroidRuntime類中,通過gRegJNI這個全域性組數進行了記錄了這些資訊。這個陣列包含了一百多個條目,下面是其中的一部分:

static const RegJNIRec gRegJNI[] = {
    REG_JNI(register_android_debug_JNITest),
    REG_JNI(register_com_android_internal_os_RuntimeInit),
    REG_JNI(register_android_os_SystemClock),
    REG_JNI(register_android_util_EventLog),
    REG_JNI(register_android_util_Log),
    REG_JNI(register_android_util_FloatMath),
    REG_JNI(register_android_text_format_Time),
    REG_JNI(register_android_content_AssetManager),
    REG_JNI(register_android_content_StringBlock),
    REG_JNI(register_android_content_XmlBlock),
    REG_JNI(register_android_emoji_EmojiFactory),
    REG_JNI(register_android_text_AndroidCharacter),
    REG_JNI(register_android_text_AndroidBidi),
    REG_JNI(register_android_view_InputDevice),
    REG_JNI(register_android_view_KeyCharacterMap),
    REG_JNI(register_android_os_Process),
    REG_JNI(register_android_os_SystemProperties),
    REG_JNI(register_android_os_Binder),
    REG_JNI(register_android_os_Parcel),
    ...
};

這個陣列中的每一項包含了一個函式,每個函式由Framework中對應的類提供,負責該類的JNI函式註冊。這其中就包含我們在第二章提到的Binder和Parcel。

我們以Parcel為例來看一下:register_android_os_Parcel函式由android_os_Parcel.cpp提供,程式碼如下:

int register_android_os_Parcel(JNIEnv* env)
{
    jclass clazz;

    clazz = env->FindClass(kParcelPathName);
    LOG_FATAL_IF(clazz == NULL, "Unable to find class android.os.Parcel");

    gParcelOffsets.clazz = (jclass) env->NewGlobalRef(clazz);
    gParcelOffsets.mNativePtr = env->GetFieldID(clazz, "mNativePtr", "I");
    gParcelOffsets.obtain = env->GetStaticMethodID(clazz, "obtain",
                                                   "()Landroid/os/Parcel;");
    gParcelOffsets.recycle = env->GetMethodID(clazz, "recycle", "()V");

    return AndroidRuntime::registerNativeMethods(
        env, kParcelPathName,
        gParcelMethods, NELEM(gParcelMethods));
}

這段程式碼的最後是呼叫AndroidRuntime::registerNativeMethods對每個JNI方法進行註冊,gParcelMethods包含了Parcel類中的所有JNI方法列表,下面是其中一部分:

static const JNINativeMethod gParcelMethods[] = {
    {"nativeDataSize",            "(I)I", (void*)android_os_Parcel_dataSize},
    {"nativeDataAvail",           "(I)I", (void*)android_os_Parcel_dataAvail},
    {"nativeDataPosition",        "(I)I", (void*)android_os_Parcel_dataPosition},
    {"nativeDataCapacity",        "(I)I", (void*)android_os_Parcel_dataCapacity},
    {"nativeSetDataSize",         "(II)V", (void*)android_os_Parcel_setDataSize},
    {"nativeSetDataPosition",     "(II)V", (void*)android_os_Parcel_setDataPosition},
    {"nativeSetDataCapacity",     "(II)V", (void*)android_os_Parcel_setDataCapacity},
    ...
}

總結起來這裡的邏輯就是:

  • Android Framework中每個包含了JNI方法的類負責提供一個register_xxx方法,這個方法負責該類中所有JNI方法的註冊
  • 類中的所有JNI方法通過一個二維陣列記錄
  • gRegJNI中羅列了所有Framework層類提供的register_xxx函式指標,並以此指標來完成呼叫,以使得整個JNI註冊過程完成

至此,Dalvik虛擬機器的啟動過程我們就講解完了,下圖描述了完整的Dalvik虛擬機器啟動過程:

程式的執行:解釋與編譯

程式設計師通過原始碼的形式編寫程式,而機器只能認識機器碼。從編寫完的程式到在機器上執行,中間必須經過一個轉換的過程。這個轉換的過程由兩種做法,那就是:解釋編譯

  • 解釋是指:源程式由程式直譯器邊掃描邊翻譯執行,這種方式不會產生目標檔案,因此如果程式執行多次就需要重複解釋多次。
  • 編譯是指:通過編譯器將源程式完整的地翻譯成用機器語言表示的與之等價的目標程式。因此,這種方式只要編譯一次,得到的產物可以反覆執行。

許多指令碼語言,例如JavaScript用的就是解釋方式,因此其開發的過程中不牽涉到任何編譯的步驟(注意,這裡僅僅是指程式設計師的開發階段,在虛擬機器的內部解釋過程中,仍然會有編譯的過程,只不過對程式設計師隱藏了)。而對於C/C++這類靜態編譯語言來說,在寫完程式之後到真正執行之前,必須經由編譯器將程式編譯成機器對應的機器碼。

正如前面說過的觀點那樣:既然一個問題還存在兩種解決方法,那麼它們自然各有優勢。

解釋性語言通常都具有的一個優點就是跨平臺:因為這些語言由直譯器承擔了不同平臺上的相容工作,而開發者不用關心這一點。相反,編譯性語言的編譯產物是與平臺向對應的,Windows上編譯出來的C++可執行檔案(不使用交叉編譯工具鏈)不能在Linux或者Mac執行。但反過來,解釋性語言的缺點就是執行效率較慢,因為有很多編譯的動作延遲到執行時來執行了,這就必要導致執行時間較長。

而Java語言介於完全解釋和靜態編譯兩者之間。因為無論是JVM上的class檔案還是Dalvik上的dex檔案,這些檔案是已經經過詞法和語法分析的中間產物。但這個產物與C/C++語言所對應的編譯產物還不一樣,因為Java語言的編譯產物只是一箇中間產物,並沒有完全對應到機器碼。在執行時,還需要虛擬機器進行解釋執行或者進一步的編譯。

有些Java的虛擬機器只包含直譯器,有些只包含編譯器。而在Dalvik在最早期的版本中,只包含了直譯器,從Android 2.2版本開始,包含了JIT編譯器。

下圖描述瞭解釋和編譯的流程:

interpret_vs_compile.png

Dalvik上的直譯器

直譯器正如其名稱那樣:負責程式的解釋執行。在Dalvik中,內建了三個解析器,分別是:

  • fast: 預設直譯器。這個直譯器專門為平臺優化過,因為其中包含了手寫的彙編程式碼
  • portable: 顧名思義,具有較好可移植性的直譯器,因為這個直譯器是用C語言實現的
  • debug: 專門為debug和profile所用的解析器,效能較弱

使用者可以通過設定屬性來選擇直譯器,例如下面這條命令設定直譯器為portable:

adb shell "echo dalvik.vm.execution-mode = int:portable >> /data/local.prop"

前面我們已經看到,Dalvik虛擬機器在啟動的時候會讀取這個屬性,因此當你修改了這個屬性之後,需要重新啟動才能使之生效。

Dalvik直譯器的原始碼位於這個路徑:

/dalvik/vm/mterp

portable是最先實現的直譯器,這個直譯器以單個C語言函式的形式實現的。但是為了改進效能,Google後來使用匯編語言重寫了,這也就是fast直譯器。為了使得這些彙編程式更容易移植,直譯器的實現採用了模組化的方法:這使得允許每次開發特定平臺上的特定操作碼。

每個配置都有一個“config-*”檔案來控制來原始碼的生成。原始碼被寫入/dalvik/vm/mterp/out目錄,Android編譯系統會讀取這裡的檔案。

熟悉直譯器的最好方法就是看翻譯生成的檔案在“out”目錄下的檔案。

關於這部分內容我們就不深入展開了,有興趣的讀者可以自定閱讀這部分程式碼。

Dalvik上的JIT

Java虛擬機器的引入是將傳統靜態編譯的過程進行了分解:首先編譯出一箇中間產物(無論是JVM的class檔案格式還是Android的dex檔案格式),這個中間產物是平臺無關的。而在真正執行這個中間產物的時候,再由直譯器將其翻譯成具體裝置上的機器碼然後執行。

而虛擬機器上的直譯器通常只對執行到的程式碼進行機器碼翻譯。這樣做效率就很低,因為有些程式碼可能要重複執行很多遍(例如日誌輸出),但每遍都要重新翻譯。

而JIT就是為了解決這個問題而產生的,JIT在執行時進行程式碼的編譯,這樣下次再次執行同樣的程式碼的時候,就不用再次解釋翻譯了,而是可以直接使用編譯後的結果,這樣就加快了執行的速度。但它並非編譯所有程式碼,而是有選擇性的進行編譯,並且這個“選擇性”是JIT編譯器尤其需要考慮的。因為編譯是一個非常耗時的事情,對於那些執行較少的“冷門”程式碼進行編譯可能會適得其反。

總的來說,JIT在選擇哪些程式碼進行編譯時,有兩種做法:

  1. Method JIT
  2. Trace JIT

第一種是以Java方法為單位進行編譯。第二種是以程式碼行為單位進行編譯。考慮到移動裝置上記憶體較小(編譯的過程需要消耗記憶體),因此Dalvik上的JIT以後一種做法為主。

實際上,對於JIT來說,最重要還是需要確定哪些程式碼是“熱門”程式碼並需要編譯,解決這個問題的做法如下圖所示:

JIT_Flow.png

這個過程描述如下:

  • 首先需要記錄程式碼的執行次數
  • 並設定一個“熱門”程式碼的閾值,每次執行時都比對一下看看有沒有到閾值

    • 如果沒有,則還是繼續用解釋的方式執行
    • 如果到了閾值,則檢查該程式碼是否存在已經編譯好的產物

      • 如果有編譯好的產物直接使用
      • 如果沒有編譯好的產物,則傳送編譯的請求
  • 虛擬機器需要對已經編譯好的機器碼進行快取

Dalvik上的垃圾回收

垃圾回收是Java虛擬機器最為重要的一個特性。垃圾回收使得程式設計師不用再關心物件的釋放問題,極大的簡化了開發的過程。在前面的內容中,我們已經介紹了主要的垃圾回收演算法。這裡我們來具體看一下Dalvik虛擬機器上的垃圾回收。

Davlik上的垃圾回收主要是在下面的這些時機會觸發:

  • 堆中無法再建立物件的時候
  • 堆中的記憶體使用率超過閾值的時候
  • 程式通過Runtime.gc()主動GC的時候
  • 在OOM發生之前的時候

不同時機下GC的策略是有區別的,在Heap.h中定義了這四種GC的策略:

// Heap.h

/* Not enough space for an "ordinary" Object to be allocated. */
extern const GcSpec *GC_FOR_MALLOC;

/* Automatic GC triggered by exceeding a heap occupancy threshold. */
extern const GcSpec *GC_CONCURRENT;

/* Explicit GC via Runtime.gc(), VMRuntime.gc(), or SIGUSR1. */
extern const GcSpec *GC_EXPLICIT;

/* Final attempt to reclaim memory before throwing an OOM. */
extern const GcSpec *GC_BEFORE_OOM;

不同的垃圾回收策略會有一些不同的特性,例如:是否只清理應用程式的堆,還是連Zygote的堆也要清理;該垃圾回收演算法是否是並行執行的;是否需要對軟引用進行處理等。

Dalvik的垃圾回收演算法在下面這個檔案中實現:

/dalvik/vm/alloc/MarkSweep.h
/dalvik/vm/alloc/MarkSweep.cpp

從檔名稱上我們就能看得出,Dalvik使用的是標記清除的垃圾回收演算法。

Heap.cpp中的dvmCollectGarbageInternal函式控制了整個垃圾回收過程,其主要過程如下圖所示:

dalvik_gc_sequence.png

在垃圾收集過程中,Dalvik使用的是物件追蹤方法,這其中的詳細步驟說明如下:

  • 在開始垃圾回收之前,要暫停所有執行緒的執行:dvmSuspendAllThreads(SUSPEND_FOR_GC);
  • 建立GC標記的上下文:dvmHeapBeginMarkStep
  • 對GC的根物件進行標記:dvmHeapMarkRootSet
  • 然後以此為起點進行物件的追蹤:dvmHeapScanMarkedObjects
  • 處理引用關係:dvmHeapProcessReferences
  • 執行清理:

    • dvmHeapSweepSystemWeaks
    • dvmHeapSourceSwapBitmaps
    • dvmHeapSweepUnmarkedObjects
  • 完成標記工作:dvmHeapFinishMarkStep
  • 恢復所有執行緒的執行:dvmResumeAllThreads

dvmHeapSweepUnmarkedObjects函式會呼叫sweepBitmapCallback來清理物件,這個函式的程式碼如下所示:

// MarkSweep.cpp

static void sweepBitmapCallback(size_t numPtrs, void **ptrs, void *arg)
{
    assert(arg != NULL);
    SweepContext *ctx = (SweepContext *)arg;
    if (ctx->isConcurrent) {
        dvmLockHeap();
    }
    ctx->numBytes += dvmHeapSourceFreeList(numPtrs, ptrs);
    ctx->numObjects += numPtrs;
    if (ctx->isConcurrent) {
        dvmUnlockHeap();
    }
}

Java虛擬機器與垃圾回收演算法一文中,我們講過:垃圾回收清理完物件之後會遺留下記憶體碎片,因此虛擬機器還需要對碎片進行整理。在Dalvik虛擬機器中,是直接利用了底層記憶體管理庫完成這項工作。Dalvik的記憶體管理是基於dlmalloc實現的,這是由Doug Lea實現的記憶體分配器。而Dalvik的記憶體整理是直接利用了dlmalloc中的mspace_bulk_free函式進行了處理。讀者可以在這裡瞭解 dlmalloc

看到Dalvik垃圾回收演算法的讀者應該能夠發現,Dalvik虛擬機器上的垃圾回收有一個很嚴重的問題,那就是在進行垃圾回收的時候,會暫停所有執行緒。而這個在程式執行過程中幾乎是不能容忍的,這個暫停會造成應用程式的卡頓,並且這個卡頓會伴隨著每次垃圾回收而存在。這也是為什麼早期Android系統給大家的感受就是:很卡。這也是Google要用新的虛擬機器來徹底替代Dalvik的原因之一。

在下一篇文章中,我們會講解Android系統上新的虛擬機器:ART,也會看到它是如何解決垃圾回收的卡頓問題的。

參考資料與推薦讀物


相關文章