Android 高階面試-4:虛擬機器相關

WngShhng發表於2019-02-21

記憶體管理屬於基礎知識組織下語言即可,記憶體模型放在 Java 併發相關;虛擬機器執行系統是重點,包括,類載入機制(類的載入、校驗階段,與熱補丁原理相關)

學習這一塊的內容可以參考:

1、記憶體管理

  • GC 回收策略
  • Java 中記憶體區域與垃圾回收機制
  • 垃圾回收機制與呼叫 System.gc() 區別
  1. 標記-清除演算法:這種演算法直接在記憶體中把需要回收的物件“摳”出來。效率不高,清除之後會產生內容碎片,造成記憶體不連續,當分配較大記憶體物件時可能會因記憶體不足而觸發垃圾收集動作。
  2. 標記-整理演算法:類似於標記-清除演算法,只是回收了之後,它要對記憶體空間進行整理,以使得剩餘的物件佔用連續的儲存空間。
  3. 複製演算法:將記憶體分成兩塊,一次只在一塊記憶體中進行分配,垃圾回收一次之後,就將該記憶體中的未被回收的物件移動到另一塊記憶體中,然後將該記憶體一次清理掉。
  4. 分代收集演算法:根據物件存活週期的不同將記憶體劃分成幾塊,然後根據其特點採用不同的回收演算法。

System.gc() 函式的作用只是提醒虛擬機器:程式設計師希望進行一次垃圾回收。但是它不能保證垃圾回收一定會進行,而且具體什麼時候進行是取決於具體的虛擬機器的,不同的虛擬機器有不同的對策。

  • Java 中物件的生命週期

一個類從被載入到虛擬機器記憶體到解除安裝的整個生命週期包括:載入-驗證-準備-解析-初始化-使用-解除安裝 7 個階段。其中 驗證-準備-解析 3 個階段稱為連線。

載入發生在類被使用的時候,如果一個類之前沒有被載入,那麼就會執行載入邏輯,比如當使用new 建立類、呼叫靜態類物件和使用反射的時候等。載入過程主要工作包括:1). 從磁碟或者網路中獲取類的二進位制位元組流;2). 將該位元組流的靜態儲存結構轉換為方法取的執行時資料結構;3). 在記憶體中生成表示這個類的 Class 物件,作為方法區訪問該類的各種資料結構的入口。

驗證階段會對載入的位元組流中的資訊進行各種校驗以確保它符合JVM的要求。

準備階段會正式為類變數分配記憶體並設定類變數的初始值。注意這裡分配記憶體的只包括類變數,也就是靜態的變數(例項變數會在物件例項化的時候分配在堆上),並且這裡的設定初始值是指‘零值’,比如int型別的會被初始化為 0,引用型別的會被初始化為 null,即使你在程式碼中為其賦了值。

解析階段是將常量池中的符號引用替換為直接引用的過程。符號引用與虛擬機器實現的佈局無關,引用的目標並不一定要已經載入到記憶體中。各種虛擬機器實現的記憶體佈局可以各不相同,但是它們能接受的符號引用必須是一致的,只要能正確定位到它們在記憶體中的位置就行。直接引用可以是指向目標的指標,相對偏移量或是一個能間接定位到目標的控制程式碼。如果有了直接引用,那引用的目標必定已經在記憶體中存在。

初始化是執行類構造器 <client> 方法的過程。<client> 方法是由編譯器自動收集類中的類變數的賦值操作和靜態語句塊中的語句合併而成的。虛擬機器會保證 <client> 方法執行之前,父類的 <client> 方法已經執行完畢。

  • JVM 記憶體區域,開執行緒影響哪塊記憶體

記憶體區域大致的分佈圖,與執行緒對應之後的分佈圖

JVM記憶體區域

圖中由淺藍色標識的部分是所有執行緒共享的資料區;淡紫色標識的部分是每個執行緒私有的資料區域

  1. 程式計數器執行緒私有,用來指示當前執行緒所執行的位元組碼的行號,就是用來標記執行緒現在執行的程式碼的位置;對 Java 方法,它儲存的是位元組碼指令的地址;對於 Native 方法,該計數器的值為空。
  2. 執行緒私有,與執行緒同時建立,總數與執行緒關聯,代表Java方法執行的記憶體模型。每個方法執行時都會建立一個棧楨來儲存方法的的變數表、運算元棧、動態連結方法、返回值、返回地址等資訊。一個方法的執行和退出就是用一個棧幀的入棧和出棧表示的。通常我們不允許你使用遞迴就是因為,方法就是一個棧,太多的方法只執行而沒有退出就會導致棧溢位,不過可以通過尾遞迴優化。棧又分為虛擬機器棧和本地方法棧,一個對應 Java 方法,一個對應 Native 方法。
  3. :用來給物件分配記憶體的,幾乎所有的物件例項(包括陣列)都在上面分配。它是垃圾收集器的主要管理區域,因此也叫 GC 堆。它實際上是一塊記憶體區域,由於一些收集演算法的原因,又將其細化分為新生代和老年代等。如果在堆中沒有記憶體完成例項分配,並且堆也無法再擴充套件時,將會丟擲 OutOfMemoryError 異常。
  4. 方法區:方法區由多執行緒共享,用來儲存類資訊、常量、靜態變數、即使編譯後的程式碼等資料。執行時常量池是方法區的一部分,它用於存放編譯器生成的各種字面量和符號引用,比如字串常量等。根據 Java 虛擬機器規範的規定,當方法區無法滿足記憶體分配需求時,將丟擲 OutOfMemoryError 異常。
  • 軟引用、弱引用區別
  • Java 中的四種引用
  • 強引用置為 null,會不會被回收?

四種引用型別:強引用、軟引用、弱引用和虛引用。

  1. 當使用 new 關鍵字建立一個物件的時候,這個物件就是強引用的,它絕對不會被回收,即使記憶體耗盡。你可以通過將其置為 null 來弱化對其的引用,但什麼時候被回收還要取決於 GC 演算法。
  2. 軟引用和弱引用相似,你可以分別通過 SoftReference<T>WeakReference<T> 來使用它們,它們的區別在於後者更弱一些。當 JVM 進行垃圾回收時,無論記憶體是否充足,都會回收被弱引用關聯的物件;而軟引用關聯著的物件,只有在記憶體不足的時候 JVM 才會回收該物件。軟引用可以用來做快取,因為當 JVM 記憶體不足的時候才會被回收;而弱引用適合 Android 上面引用 Activity 等的時候使用,因為 Activity 被銷燬不一定是因為記憶體不足,可能是正常的生命週期結束。如果此時使用軟引用,而 JVM 記憶體仍然足夠,則仍然會持有 Activity 的引用而造成記憶體洩漏。
  3. 虛引用在任何時候都可能被垃圾回收器回收。

當一個物件不再被引用的時候,該物件也不一定被回收,理論上它還有一次救贖的機會,即通過覆寫 finilize() 方法把對自己的引用從弱變強,即把自己賦值給全域性的物件等。因為當物件不可達的時候,只有當 finilize() 沒被覆寫,或者 finilize() 已經被呼叫過,則該物件會被回收。否則,它會被放在一個佇列中,並在稍後由一個低優先順序的 Finilizer 執行緒執行它。

  • 垃圾收集機制 物件建立,新生代與老年代

實際虛擬機器的記憶體區域就是一整塊記憶體,不區分新生代與老年代。新生代與老年代是垃圾收集器為了使用不同收集策略而定義的名稱。

JVM 記憶體各個區域的名稱

記憶體分配的策略是:1). 物件優先在Eden分配;2). 大物件直接進入老年代;3). 長期存活物件將進入老年代。

我們之前有一次線上的問題就是程式碼中查詢了太多的資料,導致大物件直接進入了老年代,查詢頻繁,導致虛擬機器 GC 頻繁,進入假死狀態(停頓)。

新生代:主要是用來存放新生的物件。一般佔據堆的 1/3 空間。由於頻繁建立物件,所以新生代會頻繁觸發 MinorGC 進行垃圾回收。

新生代又分為 Eden 區、ServivorFrom、ServivorTo 三個區。

Eden 區:Java 新物件的出生地(如果新建立的物件佔用記憶體很大,則直接分配到老年代)。當Eden 區記憶體不夠的時候就會觸發 MinorGC,對新生代區進行一次垃圾回收。
ServivorTo:保留了一次 MinorGC 過程中的倖存者。
ServivorFrom:上一次 GC 的倖存者,作為這一次 GC 的被掃描者。

MinorGC 的過程:MinorGC 採用複製演算法。首先,把 Eden 和 ServivorFrom 區域中存活的物件複製到 ServicorTo 區域(如果有物件的年齡以及達到了老年的標準,則賦值到老年代區),同時把這些物件的年齡+1(如果 ServicorTo 不夠位置了就放到老年區);然後,清空 Eden 和 ServicorFrom 中的物件;最後,ServicorTo 和 ServicorFrom 互換,原 ServicorTo 成為下一次 GC 時的 ServicorFrom 區。

老年代:主要存放應用程式中生命週期長的記憶體物件。老年代的物件比較穩定,所以 MajorGC 不會頻繁執行。MajorGC 前一般都先進行了一次 MinorGC,使得有新生代的物件進入老年代,導致空間不夠用時才觸發。當無法找到足夠大的連續空間分配給新建立的較大物件時也會提前觸發一次 MajorGC 進行垃圾回收騰出空間。

當老年代也滿了裝不下的時候,就會丟擲 OOM 異常。

至於老年代究竟使用哪種垃圾收集演算法實際上是由垃圾收集器來決定的。老年代、新生代以及新生代的各個記憶體區域之間的比例並不是固定的,我們可以使用引數來配置。

2、虛擬機器執行系統

  • 談談類載入器 classloader
  • 類載入機制,雙親委派模型

Android 中的類載入器與 Java 中的類載入器基本一致,都分成系統類載入器和使用者自定義類載入器兩種型別。Java 中的系統類載入器包括,Bootstrap 類載入器,主要用來載入 java 執行時下面的 lib 目錄;擴充類載入器 ExtClassLoader,用來載入 Java 執行時的 lib 中的擴充目錄;應用程式類載入器,用來載入當前程式的 ClassPath 目錄下面的類。其中,引導類載入器與其他兩個不同,它是在 C++ 層實現的,沒有繼承 ClassLoader 類,也無法獲取到。

Android 中的系統類載入器包括,BootClassLoader, 用來載入常用的啟動類;DexClassLoader 用來載入 dex 及包含 dex 的壓縮檔案;PathClassLoader 用來載入系統類和應用程式的類。三種類載入器都是在系統啟動的過程中建立的。DexClassLoader 和 PathClassLoader 都繼承於 BaseDexClassLoader。區別在於呼叫父類構造器時,DexClassLoader 多傳了一個 optimizedDirectory 引數,這個目錄必須是內部儲存路徑,用來快取系統建立的 Dex 檔案。而 PathClassLoader 該引數為 null,只能載入內部儲存目錄的 Dex 檔案。所以我們可以用 DexClassLoader 去載入外部的 Apk.

public DexClassLoader(String dexPath, String optimizedDirectory, String libraryPath, ClassLoader parent) {
    super(dexPath, new File(optimizedDirectory), libraryPath, parent);
}
複製程式碼

DexClassLoader 過載了 findClass() 方法,在載入類時會呼叫其內部的 DexPathList 去載入。DexPathList 是在構造 DexClassLoader 時生成的,其內部包含了 DexFile。騰訊的 qq 空間熱修復技術正是利用了 DexClassLoader 的載入機制,將需要替換的類新增到 dexElements 的前面,這樣系統會使用先找到的修復過的類。

    private final DexPathList pathList;
    public BaseDexClassLoader(String dexPath, File optimizedDirectory, String libraryPath, ClassLoader parent) {
        super(parent);
        this.originalPath = dexPath;
        this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
    }
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Class clazz = pathList.findClass(name);
        return clazz;
    }
複製程式碼

本質上不論哪種載入器載入類的時候的都是分成兩個步驟進行的,第一是使用雙親委派模型的規則從資源當中載入類的資料到記憶體中。通常類都被儲存在各種檔案中,所以這無非就是一些檔案的讀寫操作。當將資料讀取到記憶體當中之後會呼叫 defineClass() 方法,並返回類的型別 Class 物件。這個方法底層會呼叫一個 native 的方法來最終完成類的載入工作。

至於雙親委派模型,這是 Java 中類載入的一種規則,比較容易理解。前提是類載入器之間存在著繼承關係,那麼當一個類進行載入之前會先判斷這個類是否已經存在。如果已經存在,則直接返回對應的 Class 即可。如果不存在則先交給自己的父類進行載入,父類載入不到,然後自己再進行載入。這樣一層層地傳遞下去,一個類的載入將是從父類開始到子類的過程,所以叫雙親委派模型。這種載入機制的好處是:第一,它可以避免重複載入,已經載入一次的類就無需再次載入;第二,更加安全,因為類優先交給父類進行載入,按照傳遞規則,也就是先交給系統的類進行載入。那麼如果有人想要偽造一個 Object 型別,想要矇混過關的話,顯然是逃不過虛擬機器的法眼了。

Android 的 ClassLoader 定義在 Dalivk 目錄下面,這裡是它在 AOSP 中的位置:dalvik-system

  • 動態載入
  • 對動態載入(OSGI)的瞭解?

OSGI 一種用來實現 Java 模組化的方式,在 2010 年左右的時候比較火,現在用得比較少了。

3、記憶體模型

梳理下記憶體模型,組織一下語言

  • JVM 記憶體模型,記憶體區域
  • JVM 記憶體模型

Java 記憶體模型,即 Java Memory Model,簡稱 JMM,它是一種抽象的概念,或者是一種協議,用來解決在併發程式設計過程中記憶體訪問的問題,同時又可以相容不同的硬體和作業系統。

在 Java 記憶體模型中,所有的變數都儲存在主記憶體。每個 Java 執行緒都存在著自己的工作記憶體,工作記憶體中儲存了該執行緒用得到的變數的副本,執行緒對變數的讀寫都在工作記憶體中完成,無法直接操作主記憶體,也無法直接訪問其他執行緒的工作記憶體。當一個執行緒之間的變數的值的傳遞必須經過主記憶體。

當兩個執行緒 A 和執行緒 B 之間要完成通訊的話,要經歷如下兩步:首先,執行緒 A 從主記憶體中將共享變數讀入執行緒 A 的工作記憶體後並進行操作,之後將資料重新寫回到主記憶體中;然後,執行緒 B 從主存中讀取最新的共享變數。

此外,記憶體模型還規定了

  1. 主記憶體和工作記憶體互動的 8 種操作及其規則;
  2. 提供了 voliate 關鍵字用來,保證變數的可見性,和遮蔽指令重排序;
  3. 對 long 及 double 的特殊規定:讀寫操作分成兩個 32 位操作;
  4. 先行發生原則 (happens-before) 和 as-if-serial 語義(不管怎麼重排序,程式的執行結果不能被改變)。

4、Android 虛擬機器

  • ART 和 Dalvik (DVM) 的區別

ART 4.4 時釋出,5.0 之後預設使用 ART.

  1. ART 在應用安裝時會進行預編譯 (ahead of time compilation, AOT),將位元組碼編譯成機器碼並儲存在本地,這樣每次執行程式時就無需編譯了,提升了效率。缺點是:1).安裝耗時更長了;2).佔用更多儲存空間。7.0 之後,ART 引入 JIT,安裝時不會將位元組碼全部編譯成機器碼,而是執行時將熱點程式碼編譯成機器碼。
  2. DVM 是為 32 位 CPU 設計的,ART 支援 64 位且相容 32 位 CPU.
  3. ART 對垃圾收集機制進行了改進,將 GC 暫停由 2 次改成了 1 次等。
  4. ART 的執行時堆空間劃分與 DVM 不同
  • DVM 與 JVM 的區別
  1. 基於的架構不同:DVM 基於暫存器,相比於 JVM(基於棧),執行速度更快(因為無需到棧中讀取資料)。
  2. 執行的位元組碼不同:DVM 在執行的是 dex 檔案,經過 class 經 dx 轉換之後的。dex 會對 class 進行優化,整個 class,取出冗餘資訊,加快載入方式。
  3. DVM 允許在有限的空間內同時執行多個程式
  4. DVM 由 Zygote 建立和初始化 Zygote 是一個 DVM 程式,當需要建立一個應用程式時,Zygote 通過 fork 自身來建立新的 DVM 例項。
  5. DVM 有共享機制,不同應用在執行時可以共享相同的類。
  6. DVM 早期沒有使用 JIT 編譯器,JIT 就是即時編譯器,早期的 DVM 需要經過直譯器將 dex 碼編譯成機器碼,效率不高。2.2 之後使用了 JIT,會對熱點程式碼進行編譯,生成本地機器碼,下次執行到相同的邏輯時,可以直接執行本地機器碼,無需每次編譯。
  • DVM 與 ART 的誕生

init 程式啟動 Zygote 時會呼叫 app_main.cpp,它會呼叫 AndroidRuntime 的 start() 函式,在其中通過 startVM() 方法啟動虛擬機器。在啟動虛擬機器之前會通過讀取系統的屬性,判斷使用 DVM 還是 ART 虛擬機器例項。


Android 高階面試系列文章,關注作者及時獲取更多面試資料

本系列以及其他系列的文章均維護在 Github 上面:Github / Android-notes,歡迎 Star & Fork. 如果你喜歡這篇文章,願意支援作者的工作,請為這篇文章點個贊?!

相關文章