Android上的ART虛擬機器

paulquei發表於2018-10-15

本會講解Android上的ART虛擬機器。

我的部落格中,還有另外兩篇關於Android虛擬機器的文章也可以配套閱讀:

從Android 5.0(Lollipop)開始,Android Runtime(下文簡稱ART)就徹底代替了原先的Dalvik,成為Android系統上新的虛擬機器。

這篇文章我們就來詳細瞭解一下ART虛擬機器。

ART VS. Dalvik

Dalvik虛擬機器是2008年跟隨Android系統一起釋出的。當時的移動裝置的系統記憶體只有64M左右,CPU頻率在250~500MHz之間。這個硬體水平早已發生了巨大變化。隨著智慧裝置的興起,這些年移動晶片的效能每年都有大幅提升。如今的智慧手機記憶體已經有6G甚至8G至多。CPU也已經步入了64位的時代,頻率高達2.0 GHz甚至更高。硬體的更新,常常也伴隨著軟體的換代。因此,Dalvik虛擬機器被淘汰也是情理之中的事情。

Dalvik之所以要被ART替代包含下面幾個原因:

  • Dalvik是為32位設計的,不適用於64位CPU。
  • 單純的位元組碼解釋加JIT編譯的執行方式,效能要弱於本地機器碼的執行。
  • 無論是解釋執行還是JIT編譯都是單次執行過程中發生,每執行一次都可能需要重新做這些工作,這樣做太浪費資源。
  • 原先的垃圾回收機制不夠好,會導致卡頓。

很顯然,ART虛擬機器對上面提到的這些地方做了改進。除了支援64位不必說,最主要的是下面兩項改進:

  • AOT編譯:Ahead-of-time(AOT)是相對於Just-in-time(JIT)而言的。JIT是在執行時進行位元組碼到本地機器碼的編譯,這也是為什麼Java普遍被認為效率比C++差的原因。無論是直譯器的解釋還是執行過程中即時編譯,都比C++編譯出的本地機器碼執行多了一個耗費時間的過程。而AOT就是向C++編譯過程靠攏的一項技術:當APK在安裝的時候,系統會通過一個名稱為dex2oat的工具將APK中的dex檔案編譯成包含本地機器碼的oat檔案存放下來。這樣做之後,在程式執行的時候,就可以直接使用已經編譯好的機器碼以加快效率。
  • 垃圾回收的改進:GC(Garbage Collection)是虛擬機器非常重要的一個特性,因為它的實現好壞會影響所有在虛擬機器上執行的應用。GC實現得不好可能會導致畫面跳躍,掉幀,UI響應過慢等問題。ART的垃圾回收機制相較於Dalvik虛擬機器有如下改進:

    • 將GC的停頓由2次改成1次
    • 在僅有一次的GC停頓中進行並行處理
    • 在特殊場景下,對於近期建立的具有較短生命的物件消耗更少的時間進行垃圾回收
    • 改進垃圾收集的工效,更頻繁的執行並行垃圾收集
    • 對於後臺程式的記憶體在垃圾回收過程進行壓縮以解決碎片化的問題

AOT編譯是在應用程式安裝時就進行的工作,下圖描述了Dalvik虛擬機器與(Android 5.0上的)ART虛擬機器在安裝APK時的區別:

art_vs_dalvik.png

兩種虛擬機器上安裝APK時的流程

從這幅圖中我們看到:

  • 在Dalvik虛擬機器上,APK中的Dex檔案在安裝時會被優化成odex檔案,在執行時,會被JIT編譯器編譯成native程式碼。
  • 而在ART虛擬機器上安裝時,Dex檔案會直接由dex2oat工具翻譯成oat格式的檔案,oat檔案中既包含了dex檔案中原先的內容,也包含了已經編譯好的native程式碼。

dex2oat生成的oat檔案在裝置上位於/data/dalvik-cache/目錄下。同時,由於32位和64位的機器碼有所區別,因此這個目錄下還會通過子資料夾對oat檔案進行分類。例如,手機上通常會有下面兩個目錄:

  • /data/dalvik-cache/arm/
  • /data/dalvik-cache/arm64/

接下來,我們就以oat檔案為起點來了解ART虛擬機器。

OAT檔案格式

OAT檔案遵循ELF格式。ELF是Unix系統上可執行檔案,目標檔案,共享庫和Core dump檔案的標準格式。ELF全稱是Executable and Linkable Format,該檔案格式如下圖所示:

ELF_layout.png

ELF檔案格式

每個ELF檔案包含一個ELF頭資訊,以及檔案資料。

頭資訊描述了整個檔案的基本屬性,例如ELF檔案版本,目標機器型號,程式入口地址等。

檔案資料包含三種型別的資料:

  • 程式表(Program header table):該資料會影響系統載入程式的記憶體地址空間
  • 段表(Section header table):描述了ELF檔案中各個段的(Section)資訊
  • 若干個段。常見的段包括:

    • 程式碼段(.text):程式編譯後的指令
    • 只讀資料段(.rodata):只讀資料,通常是程式裡面的只讀變數和字串常量
    • 資料段:(.data):初始化了的全域性靜態變數和區域性靜態變數
    • BSS端(.bss):未初始化的全域性變數和區域性靜態變數

關於ELF檔案格式的詳細說明可以參見維基百科:Executable and Linkable Format ,這裡不再深入討論。

下面我們再來看一下OAT檔案的格式:

art_oat_file.png

OAT檔案格式

從這個圖中我們看到,OAT檔案中包含的內容有:

  • ELF Header:ELF頭資訊。
  • oatdata symbol:oatdata符號,其地址指向了OAT頭資訊。
  • Header:Oat檔案的頭資訊,詳細描述了Oat檔案中的內容。例如:Oat檔案的版本,Dex檔案個數,指令集等等資訊。Header,Dex File陣列以及Class Metadata陣列都位於ELF的只讀資料段(.rodata)中。
  • Dex File陣列:生成該Oat檔案的Dex檔案,可能包含多個。
  • Class Metadata陣列:Dex中包含的類的基本資訊,可能包含多個。通過其中的資訊可以索引到編譯後的機器碼。
  • 編譯後的方法程式碼陣列:每個方法編譯後對應的機器碼,可能包含多個。這些內容位於程式碼段(.text)中。

我們可以通過/art/目錄下的這些原始碼檔案來詳細瞭解Oat檔案的結構:

  • compiler/oat_witer.h
  • compiler/oat_writer.cc
  • dex2oat/dex2oat.cc
  • runtime/oat.h
  • runtime/oat.cc
  • runtime/oat_file.h
  • runtime/oat_file.cc
  • runtime/image.h
  • runtime/image.cc

Oat檔案的主要組成結構如下表所示:

欄位名稱 說明
OatHeader Oat檔案頭資訊
OatDexFile陣列 Dex檔案的詳細資訊
Dex陣列 .dex檔案的拷貝
TypeLookupTable陣列 用來輔助查詢Dex檔案中的類
ClassOffsets陣列 OatDexFile中每個類的偏移表
OatClass陣列 每個類的詳細資訊
padding 如果需要,通過填充padding來讓後面的內容進行頁面對齊
OatMethodHeader Oat檔案中描述方法的頭資訊
MethodCode 類的方法程式碼,OatMethodHeader和MethodCode會交替出現多次

dex檔案可以通過dexdump工具進行分析。oat檔案也有對應的dump工具,這個工具就叫做oatdump

通過adb shell連上裝置之後,可以通過輸入oatdump來檢視該命令的幫助:

angler:/ # oatdump
No arguments specified
Usage: oatdump [options] ...
    Example: oatdump --image=$ANDROID_PRODUCT_OUT/system/framework/boot.art
    Example: adb shell oatdump --image=/system/framework/boot.art

  --oat-file=<file.oat>: specifies an input oat filename.
      Example: --oat-file=/system/framework/boot.oat

  --image=<file.art>: specifies an input image location.
      Example: --image=/system/framework/boot.art

  --app-image=<file.art>: specifies an input app image. Must also have a specified
 boot image and app oat file.

...

例如:可以通過–list-classes命令引數來列出dex檔案中的所有類:

oatdump --list-classes --oat-file=/data/dalvik-cache/arm64/system@app@Calendar@Calendar.apk@classes.dex

boot.oat 與 boot.art

任何應用程式都不是孤立存在的,幾乎所有應用程式都會依賴Android Framework中提供的基礎類,例如ActivityIntentParcel等類。所以在應用程式的程式碼中,自然少不了對於這些類的引用。因此,在上圖中我們看到,程式碼(.text)段中的的程式碼會引用Framework Image和Framrwork Code中的內容。

考慮到幾乎所有應用都存在這種引用關係,在執行時都會依賴於Framework中的類,因此係統如何處理這部分邏輯就是非常重要的了,因為這個處理的方法將影響到所有應用程式。

在AOSP編譯時,會將所有這些公共類放到專門的一個Oat檔案中,這個檔案就是:boot.oat。與之配合的還有一個boot.art檔案。

我們可以在裝置上的/data/dalvik-cache/[platform]/目錄下找到這兩個檔案:

-rw-r--r-- 1 root   root      11026432 1970-06-23 01:35 system@framework@boot.art
-rw-r--r-- 1 root   root      31207992 1970-06-23 01:35 system@framework@boot.oat

boot.art中包含了指向boot.oat中方法程式碼的指標,它被稱之為啟動映象(Boot Image),並且被載入的位置是固定的。boot.oat被載入的地址緊隨著boot.art。

包含在啟動映象中的類是一個很長的列表,它們在這個檔案中配置:frameworks/base/config/preloaded-classes。從Android L(5.0)之後的版本開始,裝置廠商可以在裝置的device.mk中通過PRODUCT_DEX_PREOPT_BOOT_FLAGS這個變數來新增配置到啟動映象中的類。像這樣:

PRODUCT_DEX_PREOPT_BOOT_FLAGS += --image-classes=<filename>

系統在初次啟動時,會根據配置的列表來生成boot.oat和boot.art兩個檔案(讀者也可以手動將/data/dalvik-cache/目錄下檔案都刪掉來讓系統重新生成),生成時的相關日誌如下:

1249:10-04 04:25:45.700   530   530 I art     : GenerateImage: /system/bin/dex2oat --image=/data/dalvik-cache/arm64/system@framework@boot.art --dex-file=/system/framework/core-oj.jar --dex-file=/system/framework/core-libart.jar --dex-file=/system/framework/conscrypt.jar --dex-file=/system/framework/okhttp.jar --dex-file=/system/framework/core-junit.jar --dex-file=/system/framework/bouncycastle.jar --dex-file=/system/framework/ext.jar --dex-file=/system/framework/framework.jar --dex-file=/system/framework/telephony-common.jar --dex-file=/system/framework/voip-common.jar --dex-file=/system/framework/ims-common.jar --dex-file=/system/framework/apache-xml.jar --dex-file=/system/framework/org.apache.http.legacy.boot.jar --oat-file=/data/dalvik-cache/arm64/system@framework@boot.oat --instruction-set=arm64 --instruction-set-features=smp,a53 --base=0x6f96c000 --runtime-arg -Xms64m --runtime-arg -Xmx64m --compiler-filter=verify-at-runtime --image-classes=/system/etc/preloaded-classes --compiled-classes=/system/etc/compiled-classes -j4 --instruction-set-variant=cor

Dalvik到ART的切換

ART虛擬機器是在Android 5.0上正式啟用的。實際上在Android 4.4上,就已經內建了ART虛擬機器,只不過預設沒有啟用。但是Android在系統設定中提供了選項讓使用者可以切換。那麼我們可能會很好奇,這裡到底是如何進行虛擬機器的切換的呢?

要知道這裡是如何實現的,我們可以從設定介面的程式碼入手。Android 4.4上是在開發者選項中提供了切換虛擬機器的入口。其實現類是DevelopmentSettings

如果你檢視相關程式碼你就會發現,這裡切換的過程其實就是設定了一個屬性值,然後將系統直接重啟。相關程式碼如下:

// DevelopmentSettings.java

private static final String SELECT_RUNTIME_PROPERTY = "persist.sys.dalvik.vm.lib";
...

SystemProperties.set(SELECT_RUNTIME_PROPERTY, newRuntimeValue);
pokeSystemProperties();
PowerManager pm = (PowerManager)
        context.getSystemService(Context.POWER_SERVICE);
pm.reboot(null);

那麼接下來我們要關注的自然是persist.sys.dalvik.vm.lib這個屬性被哪裡讀取到了。

回顧一下AndroidRuntime::start方法,讀者可能會發現這個方法中有兩行程式碼我們前面看到了卻沒有關注過:

// 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;
    }

那就是下面這兩行。實際上,它們就是切換虛擬機器的關鍵。

JniInvocation jni_invocation;
jni_invocation.Init(NULL);

JniInvocation這個結構是在/libnativehelper/目錄下定義的。對於虛擬機器的選擇也就是在這裡確定的。persist.sys.dalvik.vm.lib屬性的值實際上是so檔案的路徑,可能是libdvm.so,也可能是libart.so,前者是Dalvik虛擬機器的實現,而後者就是ART虛擬機器的實現。

JniInvocation::Init方法程式碼如下

// JniInvocation.cpp

bool JniInvocation::Init(const char* library) {
#ifdef HAVE_ANDROID_OS
  char default_library[PROPERTY_VALUE_MAX];
  property_get("persist.sys.dalvik.vm.lib", default_library, "libdvm.so"); ①
#else
  const char* default_library = "libdvm.so";
#endif
  if (library == NULL) {
    library = default_library;
  }

  handle_ = dlopen(library, RTLD_NOW); ②
  if (handle_ == NULL) { ③
    ALOGE("Failed to dlopen %s: %s", library, dlerror());
    return false;
  }
  if (!FindSymbol(reinterpret_cast<void**>(&JNI_GetDefaultJavaVMInitArgs_), ④
                  "JNI_GetDefaultJavaVMInitArgs")) {
    return false;
  }
  if (!FindSymbol(reinterpret_cast<void**>(&JNI_CreateJavaVM_),
                  "JNI_CreateJavaVM")) {
    return false;
  }
  if (!FindSymbol(reinterpret_cast<void**>(&JNI_GetCreatedJavaVMs_),
                  "JNI_GetCreatedJavaVMs")) {
    return false;
  }
  return true;
}

這段程式碼的邏輯其實很簡單:

  1. 獲取persist.sys.dalvik.vm.lib屬性的值(可能是libdvm.so,或者是libart.so)
  2. 通過dlopen載入這個so庫
  3. 如果載入失敗則報錯
  4. 確定so中包含了JNI介面需要的三個函式,它們分別是:JNI_GetDefaultJavaVMInitArgsJNI_CreateJavaVMJNI_GetCreatedJavaVMs

而每當使用者通過設定修改了persist.sys.dalvik.vm.lib屬性值之後,便會改變這裡載入的so庫。由此導致了虛擬機器的切換,如下圖所示:

dalvik_art_switch.png

Dalvik與ART虛擬機器的切換

ART虛擬機器的啟動過程

ART虛擬機器的程式碼位於下面這個路徑:

/art/runtime

前一篇文章中我們看到,JNI_CreateJavaVM是由Dalvik虛擬機器提供的用來建立虛擬機器例項的函式。並且在JniInvocation::Init方法會檢查,ART虛擬機器的實現中也要包含這個函式。

實際上,這個函式是由JNI標準介面定義的,提供JNI功能的虛擬機器都需要提供這個函式用來從native程式碼中啟動虛擬機器。

因此要知道ART虛擬機器的啟動邏輯,我們需要從ART的JNI_CreateJavaVM函式看起。

這個函式程式碼如下:

// java_vm_ext.cc

extern "C" jint JNI_CreateJavaVM(JavaVM** p_vm, JNIEnv** p_env, void* vm_args) {
  ScopedTrace trace(__FUNCTION__);
  const JavaVMInitArgs* args = static_cast<JavaVMInitArgs*>(vm_args);
  if (IsBadJniVersion(args->version)) {
    LOG(ERROR) << "Bad JNI version passed to CreateJavaVM: " << args->version;
    return JNI_EVERSION;
  }
  RuntimeOptions options;
  for (int i = 0; i < args->nOptions; ++i) {
    JavaVMOption* option = &args->options[i];
    options.push_back(std::make_pair(std::string(option->optionString), option->extraInfo));
  }
  bool ignore_unrecognized = args->ignoreUnrecognized;
  if (!Runtime::Create(options, ignore_unrecognized)) {
    return JNI_ERR;
  }

  // Initialize native loader. This step makes sure we have
  // everything set up before we start using JNI.
  android::InitializeNativeLoader();

  Runtime* runtime = Runtime::Current();
  bool started = runtime->Start();
  if (!started) {
    delete Thread::Current()->GetJniEnv();
    delete runtime->GetJavaVM();
    LOG(WARNING) << "CreateJavaVM failed";
    return JNI_ERR;
  }

  *p_env = Thread::Current()->GetJniEnv();
  *p_vm = runtime->GetJavaVM();
  return JNI_OK;
}

這段程式碼中牽涉的邏輯較多,這裡就不貼出更多的程式碼了。下圖總結了ART虛擬機器的啟動過程:

ART虛擬機器的啟動過程

圖中的步驟說明如下:

  • Runtime::Create: 建立Runtime例項

    • Runtime::Init:對Runtime進行初始化

      • runtime_options.GetOrDefault: 讀取啟動引數
      • new gc::Heap: 建立虛擬機器的堆,Java語言中通過new建立的物件都位於Heap中。

        • ImageSpace::CreateBootImage:初次啟動會建立Boot Image,即boot.art
        • garbage_collectors_.push_back: 建立若干個垃圾回收器並新增到列表中,見下文垃圾回收部分
      • Thread::Startup: 標記執行緒為啟動狀態
      • Thread::Attach: 設定當前執行緒為虛擬機器主執行緒
      • LoadNativeBridge: 通過dlopen載入native bridge,見下文。
  • android::InitializeNativeLoader: 初始化native loader,見下文。
  • runtime = Runtime::Current: 獲取當前Runtime例項
  • runtime->Start: 通過Start介面啟動虛擬機器

    • InitNativeMethods: 初始化native方法

      • RegisterRuntimeNativeMethods: 註冊dalvik.system,java.lang,libcore.util,org.apache.harmony以及sun.misc幾個包下類的native方法
      • WellKnownClasses::Init: 預先快取一些常用的類,方法和欄位。
      • java_vm_->LoadNativeLibrary:載入libjavacore.so以及libopenjdk.so兩個庫
      • WellKnownClasses::LateInit: 預先快取一些前面無法快取的方法和欄位
    • Thread::FinishStartup: 完成初始化
    • CreateSystemClassLoader: 建立系統類載入器
    • StartDaemonThreads: 呼叫java.lang.Daemons.start方法啟動守護執行緒

      • java.lang.Daemons.start: 啟動了下面四個Daemon:

        • ReferenceQueueDaemon
        • FinalizerDaemon
        • FinalizerWatchdogDaemon
        • HeapTaskDaemon

從這個過程中我們看到,ART虛擬機器的啟動,牽涉到了:建立堆;設定執行緒;載入基礎類;建立系統類載入器;以及啟動虛擬機器需要的daemon等工作。

除此之外,這裡再對native bridge以及native loader做一些說明。這兩個模組的原始碼位於下面這個路徑:

/system/core/libnativebridge/
/system/core/libnativeloader/
  • native bridge:我們知道,Android系統主要是為ARM架構的CPU為開發的。因此,很多的庫都是為ARM架構的CPU編譯的。但是如果將Android系統移植到其他平臺(例如:Intel的x86平臺),就會出現很多的相容性問題(ARM的指令無法在x86 CPU上執行)。而這個模組的作用就是:在執行時動態的進行native指令的轉譯,即:將ARM的指令轉譯成其他平臺(例如x86)的指令,這也是為什麼這個模組的名字叫做“Bridge”。
  • native loader:顧名思義,這個模組專門負責native庫的載入。一旦應用程式使用JNI呼叫,就會牽涉到native庫的載入。Android系統自Android 7.0開始,加強了應用程式對於native庫連結的限制:只有系統明確公開的庫才允許應用程式連結。這麼做的目的是為了減少因為系統升級導致了二進位制庫不相容(例如:某個庫沒有了,或者函式符號變了),從而導致應用程式crash的問題。而這個限制的工作就是在這個模組中完成的。系統公開的二進位制庫在這個檔案(裝置上的路徑)中列了出來:/etc/public.libraries.txt。除此之外,廠商也可能會公開一些擴充套件的二進位制庫,廠商需要將這些庫放在vendor/lib(或者/vendor/lib64)目錄下,同時將它們列在/vendor/etc/public.libraries.txt中。

記憶體分配

應用程式在任何時候都可能會建立物件,因此虛擬機器對於記憶體分配的實現方式會嚴重影響應用程式的效能。

原先Davlik虛擬機器使用的是傳統的 dlmalloc 記憶體分配器進行記憶體分配。這個記憶體分配器是Linux上很常用的,但是它沒有為多執行緒環境做過優化,因此Google為ART虛擬機器開發了一個新的記憶體分配器:RoSalloc,它的全稱是Rows of Slots allocator。RoSalloc相較於dlmalloc來說,在多執行緒環境下有更好的支援:在dlmalloc中,分配記憶體時使用了全域性的記憶體鎖,這就很容易造成效能不佳。而在RoSalloc中,允許線上程本地區域儲存小物件,這就是避免了全域性鎖的等待時間。ART虛擬機器中,這兩種記憶體分配器都有使用。

要了解ART虛擬機器對於記憶體的分配和回收,我們需要從Heap入手,/art/runtime/gc/ 目錄下的程式碼對應了這部分邏輯的實現。

在前面講解ART虛擬機器的啟動過程中,我們已經看到過,ART虛擬機器啟動中便會建立Heap物件。其實在Heap的建構函式,還會建立下面兩類物件:

  • 若干個Space物件:Space用來響應應用程式對於記憶體分配的請求
  • 若干個GarbageCollector物件:GarbageCollector用來進行垃圾收集,不同的GarbageCollector執行的策略不一樣,見下文“垃圾回收”

Space有下面幾種型別:

enum SpaceType {
  kSpaceTypeImageSpace,
  kSpaceTypeMallocSpace,
  kSpaceTypeZygoteSpace,
  kSpaceTypeBumpPointerSpace,
  kSpaceTypeLargeObjectSpace,
  kSpaceTypeRegionSpace,
};

下面一幅圖是Space的具體實現類。從這幅圖中我們看到, Space主要分為兩類:

  • 一類是記憶體地址連續的,它們是ContinuousSpace的子類
  • 還有一類是記憶體地址不連續的,它們是DiscontinuousSpace的子類

art_space.png

ART虛擬機器中的Space

在一個執行的ART的虛擬機器中,上面這些Space未必都會建立。有哪些Space會建立由ART虛擬機器的啟動引數決定。Heap物件中會記錄所有建立的Space,如下所示:

// heap.h

// All-known continuous spaces, where objects lie within fixed bounds.
std::vector<space::ContinuousSpace*> continuous_spaces_ GUARDED_BY(Locks::mutator_lock_);

// All-known discontinuous spaces, where objects may be placed throughout virtual memory.
std::vector<space::DiscontinuousSpace*> discontinuous_spaces_ GUARDED_BY(Locks::mutator_lock_);

// All-known alloc spaces, where objects may be or have been allocated.
std::vector<space::AllocSpace*> alloc_spaces_;

// A space where non-movable objects are allocated, when compaction is enabled it contains
// Classes, ArtMethods, ArtFields, and non moving objects.
space::MallocSpace* non_moving_space_;

// Space which we use for the kAllocatorTypeROSAlloc.
space::RosAllocSpace* rosalloc_space_;

// Space which we use for the kAllocatorTypeDlMalloc.
space::DlMallocSpace* dlmalloc_space_;

// The main space is the space which the GC copies to and from on process state updates. This
// space is typically either the dlmalloc_space_ or the rosalloc_space_.
space::MallocSpace* main_space_;

// The large object space we are currently allocating into.
space::LargeObjectSpace* large_object_space_;

Heap類的AllocObject是為物件分配記憶體的入口,這是一個模板方法,該方法程式碼如下:

// heap.h

// Allocates and initializes storage for an object instance.
template <bool kInstrumented, typename PreFenceVisitor>
mirror::Object* AllocObject(Thread* self,
                         mirror::Class* klass,
                         size_t num_bytes,
                         const PreFenceVisitor& pre_fence_visitor)
 SHARED_REQUIRES(Locks::mutator_lock_)
 REQUIRES(!*gc_complete_lock_, !*pending_task_lock_, !*backtrace_lock_,
          !Roles::uninterruptible_) {
return AllocObjectWithAllocator<kInstrumented, true>(
   self, klass, num_bytes, GetCurrentAllocator(), pre_fence_visitor);
}

在這個方法的實現中,會首先通過Heap::TryToAllocate嘗試進行記憶體的分配。在Heap::TryToAllocate方法,會根據AllocatorType,選擇不同的Space進行記憶體的分配,下面是部分程式碼片段:

// heap-inl.h

case kAllocatorTypeRosAlloc: {
  if (kInstrumented && UNLIKELY(is_running_on_memory_tool_)) {
    ...
  } else {
    DCHECK(!is_running_on_memory_tool_);
    size_t max_bytes_tl_bulk_allocated =
        rosalloc_space_->MaxBytesBulkAllocatedForNonvirtual(alloc_size);
    if (UNLIKELY(IsOutOfMemoryOnAllocation<kGrow>(allocator_type,
                                                  max_bytes_tl_bulk_allocated))) {
      return nullptr;
    }
    if (!kInstrumented) {
      DCHECK(!rosalloc_space_->CanAllocThreadLocal(self, alloc_size));
    }
    ret = rosalloc_space_->AllocNonvirtual(self, alloc_size, bytes_allocated, usable_size,
                                           bytes_tl_bulk_allocated);
  }
  break;
}
case kAllocatorTypeDlMalloc: {
  if (kInstrumented && UNLIKELY(is_running_on_memory_tool_)) {
    // If running on valgrind, we should be using the instrumented path.
    ret = dlmalloc_space_->Alloc(self, alloc_size, bytes_allocated, usable_size,
                                 bytes_tl_bulk_allocated);
  } else {
    DCHECK(!is_running_on_memory_tool_);
    ret = dlmalloc_space_->AllocNonvirtual(self, alloc_size, bytes_allocated, usable_size,
                                           bytes_tl_bulk_allocated);
  }
  break;
}
...
case kAllocatorTypeLOS: {
  ret = large_object_space_->Alloc(self, alloc_size, bytes_allocated, usable_size,
                                   bytes_tl_bulk_allocated);
  // Note that the bump pointer spaces aren`t necessarily next to
  // the other continuous spaces like the non-moving alloc space or
  // the zygote space.
  DCHECK(ret == nullptr || large_object_space_->Contains(ret));
  break;
}
case kAllocatorTypeTLAB: {
  ...
}
case kAllocatorTypeRegion: {
  DCHECK(region_space_ != nullptr);
  alloc_size = RoundUp(alloc_size, space::RegionSpace::kAlignment);
  ret = region_space_->AllocNonvirtual<false>(alloc_size, bytes_allocated, usable_size,
                                              bytes_tl_bulk_allocated);
  break;
}
case kAllocatorTypeRegionTLAB: {
  ...
  // The allocation can`t fail.
  ret = self->AllocTlab(alloc_size);
  DCHECK(ret != nullptr);
  *bytes_allocated = alloc_size;
  *usable_size = alloc_size;
  break;
}

AllocatorType的型別有如下一些:

enum AllocatorType {
  kAllocatorTypeBumpPointer,  // Use BumpPointer allocator, has entrypoints.
  kAllocatorTypeTLAB,  // Use TLAB allocator, has entrypoints.
  kAllocatorTypeRosAlloc,  // Use RosAlloc allocator, has entrypoints.
  kAllocatorTypeDlMalloc,  // Use dlmalloc allocator, has entrypoints.
  kAllocatorTypeNonMoving,  // Special allocator for non moving objects, doesn`t have entrypoints.
  kAllocatorTypeLOS,  // Large object space, also doesn`t have entrypoints.
  kAllocatorTypeRegion,
  kAllocatorTypeRegionTLAB,
};

如果Heap::TryToAllocate失敗(返回nullptr),會嘗試進行垃圾回收,然後再進行記憶體的分配:

obj = TryToAllocate<kInstrumented, false>(self, allocator, byte_count, &bytes_allocated,
                                              &usable_size, &bytes_tl_bulk_allocated);
    if (UNLIKELY(obj == nullptr)) {
      obj = AllocateInternalWithGc(self,
                                   allocator,
                                   kInstrumented,
                                   byte_count,
                                   &bytes_allocated,
                                   &usable_size,
                                   &bytes_tl_bulk_allocated, &klass);
...

AllocateInternalWithGc方法中,會先嚐試進行記憶體回收,然後再進行記憶體的分配。

垃圾回收

在Dalvik虛擬機器上,垃圾回收會造成兩次停頓,第一次需要3~4毫秒,第二次需要5~6毫秒,雖然兩次停頓累計也只有約10毫秒的時間,但是即便這樣也是不能接受的。因為對於60FPS的渲染要求來說,每秒鐘需要更新60次畫面,那麼留給每一幀的時間最多也就只有16毫秒。如果垃圾回收就造成的10毫秒的停頓,那麼就必然造成丟幀卡頓的現象。

因此垃圾回收機制是ART虛擬機器重點改進的內容之一。

ART虛擬機器垃圾回收概述

ART 有多個不同的 GC 方案,這些方案包括執行不同垃圾回收器。預設方案是 CMS(Concurrent Mark Sweep,併發標記清除)方案,主要使用粘性(sticky)CMS 和部分(partial)CMS。粘性CMS是ART的不移動(non-moving )分代垃圾回收器。它僅掃描堆中自上次 GC 後修改的部分,並且只能回收自上次GC後分配的物件。除CMS方案外,當應用將程式狀態更改為察覺不到卡頓的程式狀態(例如,後臺或快取)時,ART 將執行堆壓縮。

除了新的垃圾回收器之外,ART 還引入了一種基於點陣圖的新記憶體分配程式,稱為 RosAlloc(插槽執行分配器)。此新分配器具有分片鎖,當分配規模較小時可新增執行緒的本地緩衝區,因而效能優於 DlMalloc。

與 Dalvik 相比,ART CMS垃圾回收計劃在很多方面都有一定的改善:

  • 與Dalvik相比,暫停次數2次減少到1次。Dalvik的第一次暫停主要是為了進行根標記。而在ART中,標記過程是併發進行的,它讓執行緒標記自己的根,然後馬上就恢復執行。
  • 與Dalvik類似,ART GC在清除過程開始之前也會暫停1次。兩者在這方面的主要差異在於:在此暫停期間,某些Dalvik的處理階段在ART中以併發的方式進行。這些階段包括 java.lang.ref.Reference處理、系統弱引用清除(例如,jni全域性弱引用等)、重新標記非執行緒根和卡片預清理。在ART暫停期間仍進行的階段包括掃描髒卡片以及重新標記執行緒根,這些操作有助於縮短暫停時間。
  • 相對於Dalvik,ART GC改進的最後一個方面是粘性 CMS回收器增加了GC吞吐量。不同於普通的分代GC,粘性 CMS 不會移動。年輕物件被儲存在一個分配堆疊(基本上是 java.lang. Object 陣列)中,而非為其設定一個專用區域。這樣可以避免移動所需的物件以維持低暫停次數,但缺點是容易在堆疊中加入大量複雜物件影像而使堆疊變長。

ART GC與Dalvik的另一個主要區別在於 ART GC引入了移動垃圾回收器。使用移動 GC的目的在於通過堆壓縮來減少後臺應用使用的記憶體。目前,觸發堆壓縮的事件是 ActivityManager 程式狀態的改變(參見第2章第3節)。當應用轉到後臺執行時,它會通知ART已進入不再“感知”卡頓的程式狀態。此時ART會進行一些操作(例如,壓縮和監視器壓縮),從而導致應用執行緒長時間暫停。目前正在使用的兩個移動GC是同構空間壓縮(Homogeneous Space Compact)和半空間(Semispace Compact)壓縮。

  • 半空間壓縮將物件在兩個緊密排列的碰撞指標空間之間進行移動。這種移動 GC 適用於小記憶體裝置,因為它可以比同構空間壓縮稍微多節省一點記憶體。額外節省出的空間主要來自緊密排列的物件,這樣可以避免 RosAlloc/DlMalloc 分配器佔用開銷。由於 CMS 仍在前臺使用,且不能從碰撞指標空間中進行收集,因此當應用在前臺使用時,半空間還要再進行一次轉換。這種情況並不理想,因為它可能引起較長時間的暫停。
  • 同構空間壓縮通過將物件從一個 RosAlloc 空間複製到另一個 RosAlloc 空間來實現。這有助於通過減少堆碎片來減少記憶體使用量。這是目前非低記憶體裝置的預設壓縮模式。相比半空間壓縮,同構空間壓縮的主要優勢在於應用從後臺切換到前臺時無需進行堆轉換。

GC 驗證和效能選項

你可以採用多種方法來更改ART使用的GC計劃。更改前臺GC計劃的主要方法是更改 dalvik.vm.gctype 屬性或傳遞 -Xgc: 選項。你可以通過以逗號分隔的格式傳遞多個 GC 選項。

為了匯出可用 -Xgc 設定的完整列表,可以鍵入 adb shell dalvikvm -help 來輸出各種執行時命令列選項。

以下是將 GC 更改為半空間並開啟 GC 前堆驗證的一個示例: adb shell setprop dalvik.vm.gctype SS,preverify

  • CMS 這也是預設值,指定併發標記清除 GC 計劃。該計劃包括執行粘性分代 CMS、部分 CMS 和完整 CMS。該計劃的分配器是適用於可移動物件的 RosAlloc 和適用於不可移動物件的 DlMalloc。
  • SS 指定半空間 GC 計劃。該計劃有兩個適用於可移動物件的半空間和一個適用於不可移動物件的 DlMalloc 空間。可移動物件分配器預設設定為使用原子操作的共享碰撞指標分配器。但是,如果 -XX:UseTLAB 標記也被傳入,則分配器使用執行緒區域性碰撞指標分配。
  • GSS 指定分代半空間計劃。該計劃與半空間計劃非常相似,但區別在於其會將存留期較長的物件提升到大型 RosAlloc 空間中。這樣就可明顯減少典型用例中需複製的物件。

內部實現

在ART虛擬機器中,很多場景都會觸發垃圾回收的執行。ART程式碼中通過GcCause這個列舉進行描述,包括下面這些事件:

常量 說明
kGcCauseForAlloc 記憶體分配失敗
kGcCauseBackground 後臺程式的垃圾回收,為了確保記憶體的充足
kGcCauseExplicit 明確的System.gc()呼叫
kGcCauseForNativeAlloc 由於native的記憶體分配
kGcCauseCollectorTransition 垃圾收集器發生了切換
kGcCauseHomogeneousSpaceCompact 當前景和後臺收集器都是CMS時,發生了後臺切換
kGcCauseClassLinker ClassLinker導致

另外,垃圾回收策略有三種型別:

  • Sticky 僅僅釋放上次GC之後建立的物件
  • Partial 僅僅對應用程式的堆進行垃圾回收,但是不處理Zygote的堆
  • Full 會對應用程式和Zygote的堆都會進行垃圾回收

這裡Sticky型別的垃圾回收便是基於“分代”的垃圾回收思想,根據IBM的一項研究表明,新生代中的物件有98%是生命週期很短的。所以將新建立的物件單獨歸為一類來進行GC是一種很高效的做法。

真正負責垃圾回收的邏輯是下面這個方法:

// heap.cc

collector::GcType Heap::CollectGarbageInternal(collector::GcType gc_type,
                                               GcCause gc_cause,
                                               bool clear_soft_references)

CollectGarbageInternal方法中,會根據當前的GC型別和原因,選擇合適的垃圾回收器,然後執行垃圾回收。

ART虛擬機器中內建了多個垃圾回收器,包括下面這些:

art_gc_alg.png

ART虛擬機器中的垃圾回收器

這裡的Compact型別的垃圾回收器便是前面提到“標記-壓縮”演算法。這種型別的垃圾回收器,會在將物件清理之後,將最終還在使用的記憶體空間移動到一起,這樣可以既可以減少堆中的碎片,也節省了堆空間。但是由於這種垃圾回收器需要對記憶體進行移動,所以耗時較多,因此這種垃圾回收器適合於切換到後臺的應用。

前面我們提到過:垃圾收集器會在Heap的建構函式中被建立,然後新增到garbage_collectors_列表中。

儘管各種垃圾回收器演算法不一定,但它們都包含相同的垃圾回收步驟,垃圾回收器的回收過程主要包括下面四個步驟:

gc_phase.png

垃圾回收的四個階段

所以,想要深入明白每個垃圾回收器的演算法細節,只要按照這個邏輯來理解即可。

JIT的迴歸

前面我們提到:在Android 5.0上,系統在安裝APK時會直接將dex檔案中的程式碼編譯成機器碼。我們應該知道,編譯的過程是比較耗時的。因此,用過Android 5.0的使用者應該都會感覺到,在這個版本上安裝應用程式明顯比之前要慢了很多。

編譯一個應用程式已經比較耗時,但如果系統中所有的應用都要重新編譯一遍,那等待時間將是難以忍受的。但不幸的事,這樣的事情卻剛好發生了,相信用過Android 5.0的Nexus使用者都看到過這樣一個畫面:

android-phone-system-update.jpg

Android 5.0的啟動畫面

之所以發生這個問題,是因為:

  • 應用程式編譯生成的OAT檔案會引用Framework中的程式碼。一旦系統發生升級,Framework中的實現發生變化,就需要重新修正所有應用程式的OAT檔案,使得它們的引用是正確的,這就需要重新編譯所有的應用
  • 出於系統的安全性考慮,自2015年8月開始,Nexus裝置每個月都會收到一次安全更新

要讓使用者每個月都要忍受一次這麼長的等待時間,顯然是不能接受的。

由此我們看到,單純的AOT編譯存在如下兩個問題:

  • 應用安裝時間過長
  • 系統升級時,所有應用都需要重新編譯

其實這裡還有另外一個問題,我們也應該能想到:編譯生成的Oat檔案中,既包含了原先的Dex檔案,又包含了編譯後的機器程式碼。而實際上,對於使用者來說,並非會用到應用程式中的所有功能,因此很多時候編譯生成的機器碼是一直用不到的。一份資料存在兩份結果(儘管它們的格式是不一樣的)顯然是一種儲存空間的浪費。

因此,為了解決上面提到的這些問題,在 Android 7.0 中,Google又為Android新增了即時 (JIT) 編譯器。JIT和AOT的配合,是取兩者之長,避兩者之短:在APK安裝時,並不是一次性將所有程式碼全部編譯成機器碼。而是在實際執行過程中,對程式碼進行分析,將熱點程式碼編譯成機器碼,讓它可以在應用執行時持續提升 Android 應用的效能。

JIT編譯器補充了ART當前的預先(AOT)編譯器的功能,有助於提高執行時效能,節省儲存空間,以及加快應用及系統更新速度。相較於 AOT編譯器,JIT編譯器的優勢也更為明顯,因為它不會在應用自動更新期間或重新編譯應用(在無線下載 (OTA) 更新期間)時拖慢系統速度。

儘管JIT和AOT使用相同的編譯器,它們所進行的一系列優化也較為相似,但它們生成的程式碼可能會有所不同。JIT會利用執行時型別資訊,可以更高效地進行內聯,並可讓堆疊替換 (On Stack Replacement) 編譯成為可能,而這一切都會使其生成的程式碼略有不同。

JIT的執行流程如下:

jit-architecture.png

JIT的執行流程

  1. 使用者執行應用,而這隨後就會觸發 ART 載入 .dex 檔案。

    • 如果有 .oat 檔案(即 .dex 檔案的 AOT 二進位制檔案),則 ART 會直接使用該檔案。雖然 .oat 檔案會定期生成,但檔案中不一定會包含經過編譯的程式碼(即 AOT 二進位制檔案)。
    • 如果沒有 .oat 檔案,則 ART 會通過 JIT 或直譯器執行 .dex 檔案。如果有 .oat 檔案,ART 將一律使用這類檔案。否則,它將在記憶體中使用並解壓 APK 檔案,從而得到 .dex 檔案,但是這會導致消耗大量記憶體(相當於 dex 檔案的大小)。
  2. 針對任何未根據speed編譯過濾器編譯(見下文)的應用啟用JIT(也就是說,要儘可能多地編譯應用中的程式碼)。
  3. 將 JIT 配置檔案資料轉存到只限應用訪問的系統目錄內的檔案中。
  4. AOT 編譯 (dex2oat) 守護程式通過解析該檔案來推進其編譯。

控制JIT日誌記錄

要開啟 JIT 日誌記錄,請執行以下命令:

adb root
adb shell stop
adb shell setprop dalvik.vm.extra-opts -verbose:jit
adb shell start

要停用 JIT,請執行以下命令:

adb root
adb shell stop
adb shell setprop dalvik.vm.usejit false
adb shell start

ART虛擬機器的演進與配置

從Android 7.0開始,ART組合使用了AOT和JIT。並且這兩者是可以單獨配置的。例如,在Pixel裝置上,相應的配置如下:

  1. 最初在安裝應用程式的時候不執行任何AOT編譯。應用程式執行的前幾次都將使用解釋模式,並且經常執行的方法將被JIT編譯。
  2. 當裝置處於空閒狀態並正在充電時,編譯守護程式會根據第一次執行期間生成的Profile檔案對常用程式碼執行AOT編譯。
  3. 應用程式的下一次重新啟動將使用Profile檔案引導的程式碼,並避免在執行時為已編譯的方法進行JIT編譯。在新執行期間得到JIT編譯的方法將被新增到Profile檔案中,然後被編譯守護程式使用。

在應用程式安裝時,APK檔案會傳遞給dex2oat工具,該工具會為根據APK檔案生成一個或多個編譯產物,這些產物檔名和副檔名可能會在不同版本之間發生變化,但從Android 8.0版本開始,生成的檔案是:

  • .vdex:包含APK的未壓縮Dex程式碼,以及一些額外的後設資料用來加速驗證。
  • .odex:包含APK中方法的AOT編譯程式碼。(注意,雖然Dalvik虛擬機器時代也會生成odex檔案,但和這裡的odex檔案僅僅是字尾一樣,檔案內容已經完全不同了)
  • .art(可選):包含APK中列出的一些字串和類的ART內部表示,用於加速應用程式的啟動。

ART虛擬機器在演進過程中,提供了很多的配置引數供系統調優,關於這部分內容,請參見這裡:AOSP:配置 ART

參考資料與推薦讀物


相關文章