Android面試必備的JVM虛擬機器制詳解,看完之後簡歷上多一個技能!

南方吳彥祖_藍斯發表於2020-10-20

掌握了本篇知識之後,簡歷上就可以多加一條個人技能了:

熟悉 JVM 相關知識,包括記憶體區域、記憶體模型、GC、類載入機制、編譯最佳化等

下面就是正文了,歡迎討論~:

目錄

  1. 記憶體區域
  2. 記憶體模型
  3. 記憶體分配回收策略
  4. Java 物件的建立、記憶體佈局和訪問定位
  5. GC
    1)引用計數及可達性分析
    2)垃圾回收演算法
    3)G1 及 ZGC
  6. 類載入機制
  7. 雙親委派模型
  8. 編譯器最佳化
    1)方法內聯
    2)逃逸分析
  9. 虛擬機器相關
    1)HotSpot 及 JIT
    2)Dalvik
    3)ART 及 AOT
  10. JVM 是如何執行方法呼叫的?
  11. JVM 是如何實現反射的?
  12. JVM 是如何實現泛型的?
  13. JVM 是如何實現異常的?
  14. JVM 是如何實現註解的?

記憶體區域

Java 中的執行時資料可以劃分為兩部分,一部分是執行緒私有的,包括虛擬機器棧、本地方法棧、程式計數器,另一部分是執行緒共享的,包括方法區和堆。

程式計數器是一塊較小的記憶體空間,它可以看作是當前執行緒所執行的位元組碼的行號指示器。虛擬機器棧描述的是 Java 方法執行的記憶體模型,每個方法在執行的同時都會建立一個棧幀用於儲存區域性變數表、運算元棧、動態連結地址、方法出口等資訊。每一個方法從呼叫直至執行完成的過程,就對應著一個棧楨在虛擬機器中入棧和出棧的過程。本地方法棧和虛擬機器棧所發揮的作用是非常相似的,只不過本地方法棧描述的是 Native 方法執行的記憶體模型。

Java 堆是所有執行緒共享的一塊資料區域,主要用來存放物件例項。它也是垃圾收集器管理的主要區域,從記憶體回收的角度來看,由於現代收集器基本上都採用分代回收,所以 Java 堆還可以細分為新生代和老年代。再細緻一點還可以把新生代劃分為 Eden 區、From Survivor 區和 To Survivor 區。從記憶體分配的角度來看,執行緒共享的 Java 堆中可能劃分為多個執行緒私有的分配緩衝區 TLAB。不過不論如何劃分,都與存放內容無關,無論哪個區域,存放的都是物件例項,進一步劃分的目的是為了更好的回收記憶體或者更快的分配記憶體。方法區是用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即使編譯器編譯後的程式碼等資料。JVM 對方法區的限制比較寬鬆,除了和 Java 堆一樣不需要連續的記憶體和可以選擇固定大小或者可擴充套件外,還可以選擇不實現垃圾回收。相對而言,垃圾回收在這個區域是比較少出現的。執行時常量池是方法區的一部分,它用來儲存編譯期生成的各種字面量和符號引用。執行時常量池相比 Class 檔案常量池一個重要的特點是具備動態性,也就是在執行期間也可能將新的常量放入池中,比如 String 的 intern 方法。

在 Java 6 版本中,永久代在非堆記憶體區;到了 Java 7 版本,永久代的靜態變數和執行時常量池被合併到了堆中;而到了 Java 8,永久代被元空間取代了。很多開發者都習慣將方法區稱為 “永久代”,其實兩者並不是等價的。HotSpot 虛擬機器只是使用永久代來實現方法區,但是在 Java 8 已經將方法區中實現的永久代去掉了,並用元空間替換,元空間的儲存位置是本地記憶體。那麼 Java 8 為什麼使用元空間替換永久代呢?這樣做有什麼好處嘛?

官方給出的解釋是:移除永久代是為了融合 HotSpot JVM 和 JRockit VM 而做出的努力,因為 JRockit 沒有永久代,所以不需要配置永久代;其次,永久代記憶體經常不夠用,易 OOM。這是因為在 Java 7 中,指定的 PermGen 區大小為 8M,由於 PermGen 中類的後設資料資訊在每次 FullGC 的時候回收率都偏低,而且為 PermGen 分配多大的空間很難確定,PermSize 的大小依賴於很多因素,比如 JVM 載入的 class 總數、常量池的大小和方法的大小等等。

記憶體模型

JMM 記憶體模型是用來遮蔽掉各種硬體和作業系統的記憶體訪問差異,以實現讓 Java 程式在各個平臺下都能達到一致的記憶體訪問效果。

Java 記憶體模型規定了所有的共享變數都是儲存在主記憶體,每個執行緒還有自己的工作記憶體,執行緒的工作記憶體儲存了該執行緒使用到的共享變數的主記憶體副本複製,執行緒對變數的操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數,不同的執行緒之間也無法直接訪問對方工作記憶體中的資料,執行緒間變數值的傳遞均需要主記憶體來完成。

那麼為什麼要這麼做呢?

其實就要講到一些硬體知識了,我們知道 CPU 執行的速度是遠超於記憶體訪問速度,為了中和這種速度差異,在 CPU 和記憶體之間會加入多個 CPU 快取,比如 L1、L2、L3。CPU 在處理資料時會先把記憶體中的資料讀到自己的 CPU 快取中,然後在快取中進行運算元據,最後再把資料同步到記憶體中。這裡,就可以把 CPU 的快取看成是執行緒的工作記憶體,而把記憶體看成是主記憶體,雖然這個說法並不嚴謹,但是易於理解。

記憶體分配回收策略

記憶體分配回收策略包含三點:

  1. 物件優先在 Eden 區分配

    準確的來說,是優先在 Eden 區的 TLAB 上分配,如果 Eden 區沒有足夠的空間進行分配時,就會觸發一次 Minor GC。

  2. 大物件直接進入老年代

    所謂的大物件是指需要連續大量記憶體空間的 Java 物件,比如陣列,一般來說,超過 3M 的物件會直接在老年代進行分配。

  3. 長期存活的物件進入老年代

    既然虛擬機器採用了分代收集的思想來管理記憶體,那麼記憶體回收就必須得識別哪些物件應放在新生代還是老年代。為了做到這一點,虛擬機器給每個物件定義了一個物件年齡計數器。如果物件在 Eden 出生並經過一次 Minor GC 後仍然存活,並且能被 Survivor 容納的話,將會被移到 Survivor 空間中,並且物件年齡設定為 1.物件每在 Survivor 區熬過一次 Minor GC,年齡就會增加 1。當年齡增加到一定程度,預設是 15,就將會晉升到老年代中。

最後講一下 Minor GC 和 Full GC。

Minor GC 是指發生在新生代的垃圾回收動作,因為 Java 物件大多都是朝生夕死的,所以 Minor GC 比較頻繁,回收速度也比較快。

Full GC/Major GC 指發生在老年代的 GC,出現 Full GC 經常會伴隨著至少一次的 Minor GC,Full GC 一般會比 Minor GC 慢十倍以上。

Java 物件的建立、記憶體佈局和訪問定位

先說物件建立,在虛擬機器遇到一條 new 指令時,首先將去檢查這個指令的引數是否能在常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已經被載入過了,如果沒有就走類載入流程。在類載入檢查透過之後,虛擬機器就會為新生物件分配記憶體,物件所需記憶體在類載入完成之後就確定了。為物件分配記憶體空間就等同於把一塊確定大小的記憶體從 Java 堆中劃分出來。分配 方式有指標碰撞和空閒列表兩種,選擇哪種分配 方式由 Java 堆是否規整決定,而 Java 堆是否規整又由所採用的垃圾收集器是否具有壓縮整理功能決定。物件建立在虛擬機器是非常頻繁的行為,即使是僅僅修改了一個指標指向的位置,在併發情況下也不是執行緒安全的。解決方案有兩種,一種是採用 CAS 配上失敗重試,另一種是使用執行緒私有的分配緩衝區 TLAB。

接著是物件的記憶體佈局,在 HotSpot 虛擬機器中,物件在記憶體中儲存的佈局可以分為三塊區域:物件頭、例項資料和對其填充。可以使用 OpenJDK 開源的 JOL 工具檢視物件的記憶體佈局,直接 new Object 所佔用的大小為 16 位元組,即 12 個位元組的物件頭 + 4 個位元組的對其填充。JOL 對分析集合原始碼擴容、HashMap 的 hash 衝突等非常有用。

最後是物件的訪問定位,Java 程式需要透過棧上的 reference 資料來操作堆上的具體物件,由於 reference 型別在 Java 虛擬機器規範中只規定了一個指向物件的引用,並沒有規定這個引用應該透過什麼方式去定位和訪問堆中的物件,所以物件訪問方式也是取決於虛擬機器實現而定。目前主流的方式有使用控制程式碼和直接指標兩種。使用控制程式碼,就是相當於加了一箇中間層,在物件移動時只會改變控制程式碼中的例項資料的指標,reference 本身不需要改變。HotSpot 使用的是第二種,使用直接指標的方式訪問的最大好處就是速度很快。

GC

在垃圾收集器回收物件時,先要判斷物件是否已經不再使用了,有引用計數法和可達性分析兩種。

引用計數及可達性分析

引用計數法就是給物件新增一個引用計數器,每當有一個地方引用時就加一,引用失效時就減一。引用計數實現簡單,判斷效率也很高,但是 JVM 並沒有採用引用計數來管理記憶體,其中最主要的原因是它很難解決物件之間的相互迴圈引用問題。可達性分析的思路是透過一系列稱為 GC Roots 的物件作為起始點,從這些起始點出發向下搜尋,當有一個物件到 GC Roots 沒有任何引用鏈時,即不可達,則說明此物件是不可用的。在 Java 中,可作為 GC Roots 的物件有虛擬機器棧和本地方法棧中引用的物件、方法區中類靜態屬性引用的物件、方法區中常量引用的物件等。

但這也並不是說引用計數一無是處,在 Android 的 Framework Native 層用的智慧指標。智慧指標就是一種能夠自動維護物件引用計數的技術,它是一個物件而不是一個指標,但是它引用了一個實際使用的物件。簡單來說,就是在智慧指標構造時,增加它所引用的物件的引用計數;而在智慧指標析構時,就減少它所引用物件的引用物件。但是它是怎樣解決相互引用問題的呢?其實是透過強弱引用來實現,也就是將物件的引用計數分為強引用計數和弱引用計數兩種,其中,物件的生命週期只受強引用計數控制。比如在解決物件 A 和 B 相互引用時,把 A 看成父 B 看成子,物件 A 透過強引用計數來引用 B,B 透過弱引用計數來引用 A。在 A 不再使用時,由於 B 是透過弱引用來引用它的,因此 A 的生命週期是不受 B 影響的,所以 A 可以安全的釋放,在釋放 A 時,同時也會釋放它對 B 的強引用,這時 B 也可以被安全的回收了。在 Android 中,是使用 sp 來表示強引用,wp 表示弱引用。

Java 中的引用可以分為四類,強引用、軟引用、弱引用和虛引用。強引用在程式中普遍存在,類似 new 的這種操作,只要有強引用存在,即使 OOM JVM 也不會回收該物件。軟引用是在記憶體不夠用時,才會去回收,JDK 提供了 SoftReference 類來實現軟引用。弱引用是在 GC 時不管記憶體夠不夠用都會去回收的,可以使用 WeakReference 類來實現弱引用。虛引用對物件的生命週期沒有影響,只是為了能在物件回收時收到一個系統通知,可以使用 PhantomReference 類來實現虛引用。

接下來就是要講垃圾回收演算法了。

垃圾回收演算法

垃圾回收演算法主要有標記清除、複製演算法、標記整理。標記清除是先透過 GC Roots 標記所存活的物件,然後再統一清除未被標記的物件,它的主要問題是會產生記憶體碎片。老年代使用的 CMS 收集器就是基於標記清除演算法。複製演算法是把記憶體空間劃分為兩塊,每次分配物件只在一塊記憶體上進行分配,在這一塊記憶體使用完時,就直接把存活的物件複製到另外一塊上,然後把已使用的那塊空間一次清理掉,但是這種演算法的代價就是記憶體的使用量縮小了一半。現代虛擬機器都採用複製演算法回收新生代,不過是把記憶體劃分為了一個 Eden 區和兩個 Survivor 區,比例是 8:1:1,每次使用 Eden 和其中一塊 Survivor 區,也就是隻有 10% 的記憶體會浪費掉。如果 Survivor 空間不夠用,需要依賴其他記憶體比如老年代進行分配擔保。複製演算法在物件存活率比較高時效率是比較低下的,所以老年代一般不使用複製演算法。標記整理演算法即是在標記清除之後,把所有存活的物件都向一端移動,然後清理掉邊界以外的記憶體區域。

最後就是講垃圾回收演算法的具體應用了,也就是垃圾收集器。

G1 及 ZGC

Garbage First(G1)收集器是垃圾收集器技術發展歷史上的里程碑式的成果,它開創了收集器面向區域性收集的設計思路和基於 Region 的記憶體佈局形式。它和 CMS 同樣是一款主要面向服務端應用的垃圾收集器,不過在 JDK9 之後,CMS 就被標記為廢棄了,G1 作為預設的垃圾收集器,在 JDK 14 已經正式移除 CMS 了。在 G1 收集器出現之前的所有其他收集器,包括 CMS 在內,垃圾收集的目標範圍要麼是整個新生代(Minor GC),要麼就是整個老年代(Major GC),在要麼就是整個 Java 堆(Full GC)。而 G1 是基於 Region 堆記憶體佈局,雖然 G1 也仍是遵循分代收集理論設計的,但其堆記憶體的佈局與其他收集器有非常明顯的差異:G1 不再堅持固定大小以及固定數量的分代區域劃分,而是把連續的 Java 堆劃分為多個大小相等的獨立區域(Region),每一個 Region 都可以根據需要,扮演新生代的 Eden 空間、Survivor 空間或者老年代。收集器根據 Region 的不同角色採用不同的策略去處理。G1 會根據使用者設定允許的收集停頓時間去優先處理回收價值收益最大的那些 Region 區,也就是垃圾最多的 Region 區,這就是 Garbage First 名字的由來。

G1 收集器的運作過程大致可劃分為以下四個步驟:

1.初始標記

僅僅只是標記一下 GC Roots 能直接關聯到的物件,這個階段需要停頓執行緒,但耗時很短。

2.併發標記

從 GC Root 開始對堆中物件進行可達性分析,遞迴掃描整個堆裡的物件圖,找出要回收的物件,這階段耗時較長,但是可與使用者程式併發執行。

3.最終標記

對使用者執行緒做另一個短暫的暫停,用於處理在併發標記階段新產生的物件引用鏈變化。

4.篩選回收

負責更新 Region 的統計資料,對各個 Region 的回收價值和成本進行排序,根據使用者所期望的停頓時間來制定回收計劃。

G1 的目標是在可控的停頓時間內完成垃圾回收,所以進行了分割槽設計,但是 G1 也存在一些問題,比如停頓時間過長,通常 G1 的停頓時間在幾十到幾百毫秒之間,雖然這個數字其實已經非常小了,但是在使用者體驗有較高要求的情況下還是不能滿足實際需求,而且 G1 支援的記憶體空間有限,不適用於超大記憶體的系統,特別是在記憶體容量高於 100GB 的系統上,會因記憶體過大而導致停頓時間增長。

ZGC 在 JDK11 被引入,作為新一代的垃圾回收器,在設計之初就定義了三大目標:支援 TB 級記憶體,停頓時間控制在 10ms 之內,對程式吞吐量影響小於 15%。

類載入機制

虛擬機器把描述類的資料從 Class 檔案載入到記憶體,並對資料進行校驗、解析和初始化,最終形成可以被虛擬機器直接使用的 Java 物件,這就是虛擬機器的類載入機制。

類載入流程分為五個階段,分別是載入、驗證、準備、解析和初始化。

載入階段,就是透過一個類的全限定名來獲取定義此類的二進位制位元組流,將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。載入階段是開發人員可控性最強的階段,因為開發人員可以自定義類載入器。對於陣列而言,情況有所不同,陣列類本身不透過類載入器建立,它是由 Java 虛擬機器直接建立。

驗證是連結階段的第一步,這一階段的目的是為了確保 Class 檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。它包括檔案格式校驗、後設資料校驗、位元組碼校驗等。

準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些變數所使用的記憶體都將在方法區中進行分配。需要注意的是,這時候進行記憶體分配的僅僅包含類變數,不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在 Java 堆上。其次,這裡所說的變數初始值是該資料型別的零值。

解析階段是虛擬機器將常量池內的符號引用替換為直接引用的過程。符號引用以一組符號來描述所引用的目標,直接引用可以是直接指向目標的指標。

初始化階段是執行類構造器 () 方法的過程。() 方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序所決定的。虛擬機器會保證一個類的 () 方法在多執行緒環境中被正確的加鎖同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的 () 方法,其他執行緒都需要阻塞等待,這也是靜態內部類能實現單例的主要原因之一。

Android面試必備的JVM虛擬機器制詳解,看完之後簡歷上多一個技能!

雙親委派模型

雙親委派模型的工作過程是:如果一個類載入器收到類載入的請求,它首先不會自己去嘗試載入這個類,而是把這個請求委派給父類載入器去完成,每一層次的類載入器都是如此,因此所有的類載入請求最終都應該傳送給頂層的啟動類載入器中,只有當父類載入器反饋自己無法完成這個載入請求時,子載入器才會嘗試自己去載入。

使用雙親委派模型來組織類載入器之間的關係,有一個顯而易見的好處就是 Java 類隨著它的類載入器一起具備了一種帶有優先順序的層次關係。比如 Object 類,無論哪個類載入器去載入,應用程式各種載入器環境中都是同一個類,同時也避免了重複載入。而且,雙親委派模型也保證了 Java 程式的穩定運作。比如在應用程式中你是不能直接使用 UnSafe 這一不安全操作的類的。

雙親委派模型的實現相對簡單,程式碼都集中在 ClassLoader 的 loadClass 方法中先檢查是否已經被載入過了,如果沒載入則先呼叫父載入器的 loadClass 方法,若父載入器為空則使用預設的啟動類載入器作為父載入器。如果父載入器載入失敗,丟擲 ClassNotFoundException 異常,然後呼叫自己的 findClass 方法進行載入。

編譯器最佳化

在公司內部,我是分享過一次關於編譯最佳化的相關知識。課題是 “從 final '能夠' 提升效能,談編譯最佳化”。

對於 Java 程式碼的編譯,分為前端編譯和後端編譯。前端編譯是指透過 javac 工具,將 Java 程式碼轉化為位元組碼的過程。既然 javac 負責位元組碼的生成,那肯定就會有一些通用的最佳化手段。比如常量摺疊、自動裝拆箱、條件編譯等,其次還有 JDK9 使用 StringContactFactory 對 "+" 的過載提供的統一入口等。後端編譯則指 JVM 內建的直譯器和即時編譯器(C1、C2)。JVM 在對程式碼執行的最佳化可以分為執行時最佳化和即時編譯器(JIT)最佳化。執行時最佳化主要是解釋執行和動態編譯通用的一些機制,比如說鎖機制(如偏斜鎖)、記憶體分配機制(如 TLAB)等。除此之外,還有一些專門用於最佳化解釋執行效率的,比如說模版直譯器、內聯快取(inline cache,用於最佳化虛方法呼叫的動態繫結)。JVM 的即時編譯器最佳化是指將熱點程式碼以方法為單位轉化成機器碼,直接執行在底層硬體之上。它採用了多種最佳化方式,包括靜態編譯器可以使用的如方法內聯、逃逸分析,也包括基於程式的執行 profile 的投機性最佳化。

下面我就主要講一下方法內聯和逃逸分析。

方法內聯,它指的是在編譯的過程中遇到方法呼叫時,將目標方法的方法體納入編譯範圍之中,並取代原方法呼叫的最佳化手段。方法內聯不僅可以消除呼叫本身帶來的效能開銷,還可以進一步觸發更多的最佳化。因此,它可以算是編譯最佳化裡最為重要的一環。以 getter/setter 為例,如果沒有方法內聯,在呼叫 getter/setter 時,程式需要儲存當前方法的執行位置,建立並壓入用於 getter/setter 的棧楨、訪問欄位、彈出棧楨,最後再恢復當前方法的執行。而當內聯了對 getter/setter 的方法呼叫後,上述操作就只剩下欄位訪問了。但是即時編譯器不會無限制的進行方法內聯,它會根據方法的呼叫次數、方法體大小、Code cache 的空間等去決定是否要進行內聯。比如即使是熱點程式碼,如果方法體太大,也不會進行內聯,因為會佔用更多記憶體空間。所以平時編碼中,儘可能使用小方法體。對於需要動態繫結的虛方法呼叫來說,即時編譯器則需要先對虛方法呼叫進行去虛化,即轉化為一個或多個直接呼叫,然後才能進行方法內聯。說到這,你應該就明白 final/static 的好處了。所以儘量使用 final、private、static 關鍵字修飾方法,虛方法因為繼承,會需要額外的型別檢查才能知道實際上呼叫的是哪個方法。

逃逸分析是判斷一個物件是否被外部方法引用或外部執行緒訪問的分析技術,即時編譯器可以根據逃逸分析的結果進行諸如鎖消除、棧上分配以及標量替換的最佳化。我們先看一下鎖消除,如果即時編譯器能夠證明鎖物件不逃逸,那麼對該鎖物件的加鎖、解鎖操作沒有任何意義,因為其他執行緒並不能獲得該鎖物件,在這種情況下,即時編譯器就可以消除對該不逃逸物件的加鎖、解鎖操作。比如 synchronized(new Object) 這種操作會被完全最佳化掉。不過一般不會有人這麼寫,事實上,逃逸分析的結果更多被用於將新建物件操作轉換成棧上分配或者標量替換。我們知道,Java 虛擬機器中物件都是在堆上進行分配的,而堆上的內容對任何執行緒可見,與此同時,JVM 需要對所分配的堆記憶體進行管理,並且在物件不再被引用時回收其所佔據的記憶體。如果逃逸分析能夠證明某些新建的物件不逃逸,那麼 JVM 完全可以將其分配至棧上,並且在方法退出時,透過彈出當前方法的棧楨來自動回收所分配的記憶體空間。不過,由於實現起來需要更改大量假設了 “物件只能堆分配” 的程式碼,因此 HotSpot 虛擬機器並沒有採用棧上分配,而是使用了標量替換這麼一項技術。所謂的標量,就是僅能儲存一個值的變數,比如 Java 程式碼中的區域性變數。標量替換這項最佳化技術,可以看成將原本對物件的欄位的訪問,替換成一個個的區域性變數的訪問。

虛擬機器相關

先說 HotSpot 虛擬機器。

從硬體視角來看呢,Java 位元組碼是無法直接執行的,因此 JVM 需要將位元組碼翻譯成機器碼。在 HotSpot 裡面,翻譯過程有兩種,一種是解釋執行,即逐條將位元組碼翻譯成機器碼並執行,第二種是即時編譯執行,即以方法為單位整體編譯為機器碼後再執行。前者的優勢在於無需等待編譯,而後者的優勢在於實際執行速度更快。HotSpot 預設採用混合模式,綜合瞭解釋執行和編譯執行兩者的優點。它會先解釋執行位元組碼,而後將其中反覆執行的熱點程式碼,以方法為單位進行編譯執行。

HotSpot 內建了多個 JIT 即時編譯器,C1 和 C2,之所以引入多個即時編譯器,是為了在編譯時間和生成程式碼的執行效率之間進行取捨。Java 7 引入了分層編譯,分層編譯將 JVM 的執行狀態分為 5 個層次。第 0 層是解釋執行,預設開啟效能監控;第 1 層到第 3 層都是稱為 C1 編譯,將位元組碼編譯成原生程式碼,進行簡單、可靠的最佳化;第 4 層是 C2 編譯,也是將位元組碼編譯成原生程式碼,但是會啟用一些編譯耗時較長的最佳化,甚至會根據效能監控資訊進行一些不可靠的激進最佳化。

至此,HotSpot 及 JIT 就講完了。

再說 Dalvik 和 ART。

HotSpot 是基於棧結構的,而 Dalvik 是基於暫存器結構。在官方文件上,已經沒有 Dalvik 相關的資訊了,Android 5 後,ART 全面取代了 Dalvik。Dalvik 使用 JIT 而 ART 使用 AOT。AOT 和 JIT 的不同之處在於,JIT 是在執行時進行編譯,是動態編譯,並且每次執行程式的時候都需要對 odex 重新進行編譯;而 AOT 是靜態編譯,應用在安裝的時候會啟動 dex2oat 過程把 dex 預編譯成 oat 檔案,每次執行程式的時候不用重新編譯。另外,相比於 Dalvik,ART 對 GC 過程也進行了改進,只有一次 GC 暫停,而 Dalvik 需要兩次,而且在 GC 保持暫停狀態期間並行處理。AOT 解決了應用啟動和執行速度問題的同時也帶來了另外兩個問題,一個是應用安裝和系統升級之後的應用安裝時間比較長,二是最佳化後的檔案會佔用額外的儲存空間。在 Android 7 之後,JIT 迴歸,形成了 AOT/JIT 混合編譯模式,這種混合編譯模式的特點是:應用在安裝的時候 dex 不會被編譯,應用在執行時 dex 檔案先透過直譯器執行,熱點程式碼會被識別並被 JIT 編譯後儲存在 Code cache 中生成 profile 檔案,再手機進入 IDLE(空閒)或者 Charging(充電)狀態的時候,系統會掃描 App 目錄下的 profile 檔案並執行 AOT 過程進行編譯。這樣一說,其實是和 HotSpot 有點內味。

Android面試必備的JVM虛擬機器制詳解,看完之後簡歷上多一個技能!

面試問的關於JVM問題

JVM 是如何執行方法呼叫的?

其實呢就是了解 Java 編譯器和 JVM 是如何區分方法的。方法過載在編譯階段就能確定下來,而方法重寫則需要執行時才能確定。

Java 編譯器會根據所傳入的引數的宣告型別來選取過載方法,而 JVM 識別方法依賴於方法描述符,它是由方法的引數型別以及返回型別所構成。JVM 內建了五個與方法呼叫相關的指令,分別是 invokestatic 呼叫靜態方法、invokespecial 呼叫私有例項方法、invokevirtual 呼叫非私有例項方法、invokeinterface 呼叫介面方法以及 invokedynamic 呼叫動態方法。對於 invokestatic 以及 invokespecial 而言,JVM 能夠直接識別具體的目標方法,而對於 invokevirtual 和 invokeinterface 而言,在絕大多數情況下,JVM 需要在執行過程中,根據呼叫者的動態型別來確定具體的目標方法。唯一的例外在於,如果虛擬機器能夠確定目標方法有且只有一個,比如方法被 final 修飾,那麼它就可以不透過動態型別,直接確定目標方法。

上面所說的 invokespecial、invokeinterface 也被稱為虛方法呼叫或者說動態繫結,相比於直接能定位方法的靜態繫結而言,虛方法呼叫更加耗時。JVM 採用了一種空間換時間的策略來實現動態繫結。它為每個類生成一張方法表,用於快速定位目標方法,這個發生在類載入的準備階段。方法表本質上是一個陣列,它有兩個特性,首先是子類方法表中包含父類方法表中所有的方法,其次是子類方法在方法表中的索引,與它所重寫的父類方法的索引值相同。我們知道,方法呼叫指令中的符號引用會在執行之前解析為實際引用。對於靜態繫結的方法呼叫而言,實際引用將指向具體的方法,對於動態繫結而言,實際引用則是方法表的索引值。

JVM 也提供了內聯快取來加快動態繫結,它能夠快取虛方法呼叫中呼叫者的動態型別,以及該型別所對應的目標方法。

JVM 是如何實現反射的?

反射呢是 Java 語言中一個相當重要的特性,它允許正在執行的 Java 程式觀測,甚至是修改程式的動態行為。表現為兩點,一是對於任意一個類,都能知道這個類的所有屬性和方法,二是對於任意一個物件,都能呼叫它的任意屬性和方法。

反射的使用還是比較簡單的,涉及的 API 分為三類,Class、Member(Filed、Method、Constructor)、Array and Enumerated。我當時是直接扒 Oracle 官方文件看的,講的很詳細。

我對反射的好奇是來源於,經常會聽說反射影響效能,那麼效能開銷在哪以及如何最佳化?

在此之前,我先講講 JVM 是如何實現反射的。

我們可以直接 new Exception 來檢視方法呼叫的棧軌跡,在呼叫 Method.invoke() 時,是去呼叫 DelegatingMethodAccessorImpl 的 invoke,它的實際呼叫的是 NativeMethodAccessorImpl 的 invoke 方法。前者稱為委派實現,後者稱為本地實現。既然委派實現的具體實現是一個本地實現,那麼為啥還需要委派實現這個中間層呢?其實,Java 反射呼叫機制還設立了另一種動態生成位元組碼的實現,成為動態實現,直接使用 invoke 指令來呼叫目標方法。之所以採用委派實現,是在本地實現和動態實現直接做切換。依據註釋資訊,動態實現比本地實現相比,其執行效率要快上 20 倍。這是因為動態實現無需經過 Java 到 C++ 再到 Java 的切換,但由於生產位元組碼比較耗時,僅呼叫一次的話,反而是本地實現要快上三四倍。考慮到很多反射呼叫僅會執行一次,JVM 設定了閾值 15,在 15 之下使用本地實現,高於 15 時便開始動態生成位元組碼採用動態實現。這也被稱為 Inflation 機制。

在反手說一下反射的效能開銷在哪呢?平時我們會呼叫 Class.forName、Class.getMethod、以及 Method.invoke 這三個操作。其中,Class.forName 會呼叫本地方法,Class.getMethod 則會遍歷該類的公有方法,如果沒有匹配到,它還將遍歷父類的公有方法,可想而知,這兩個操作都非常耗時。下面就是 Method.invoke 呼叫本身的開銷了,首先是 invoke 方法的引數是一個可變長引數,也就是構建一個 Object 陣列存引數,這也同時帶來了基本資料型別的裝箱操作,在 invoke 內部會進行執行時許可權檢查,這也是一個損耗點。普通方法呼叫可能有一系列最佳化手段,比如方法內聯、逃逸分析,而這又是反射呼叫所不能做的,效能差距再一次被放大。

最佳化反射呼叫,可以儘量避免反射呼叫虛方法、關閉執行時許可權檢查、可能需要增大基本資料型別對應的包裝類快取、如果呼叫次數可知可以關閉 Inflation 機制,以及增加內聯快取記錄的型別數目。

JVM 是如何實現泛型的?

Java 中的泛型不過是一個語法糖,在編譯時還會將實際型別給擦除掉,不過會新增一個 checkcast 指令來做編譯時檢查,如果型別不匹配就丟擲 ClassCastException。

不過呢,位元組碼中仍然存在泛型引數的資訊,如方法宣告裡的 T foo(T),以及方法簽名 Signature 中的 "(TT;)TT",這些資訊可以透過反射 Api getGenericXxx 拿到。

除此之外,需要注意的是,泛型結合陣列會有一些容易忽視的問題。陣列是協變且具體化的,陣列會在執行時才知道並檢查它們的元素型別約束,可能出現編譯時正常但執行時丟擲 ArrayStoreException,所以儘可能的使用列表,這就是 Effective Java 中推薦的列表優先於陣列的建議。這在我們看集合原始碼時也能發現的到,比如 ArrayList,它裡面存資料是一個 Object[],而不是 E[],只不過在取的時候進行了強轉。還有就是利用萬用字元來提升 API 的靈活性,簡而言之即 PECS 原則,上取下存。典型的案例即 Collections.copy 方法了:

Collections.copy(List<? super T> dest, List<? extends T> src);

JVM 是如何實現異常的?

在 Java 中,所有的異常都是 Throwable 類或其子類,它有兩大子類 Error 和 Exception。 當程式觸發 Error 時,它的執行狀態已經無法恢復,需要終止執行緒或者終止虛擬機器,常見的比如記憶體溢位、堆疊溢位等;Exception 又分為兩類,一類是受檢異常,比如 IOException,一類是執行時異常 RuntimeException,比如空指標、陣列越界等。

接下來我會從三個方面闡述這個問題。

首先是,異常例項的構造十分昂貴。這是由於在構造異常例項時,JVM 需要生成該異常的棧軌跡,該操作逐一訪問當前執行緒的 Java 棧楨,並且記錄下各種除錯資訊,包括棧楨所指向方法的名字、方法所在的類名以及方法在原始碼中的位置等資訊。

其次是,JVM 捕獲異常需要異常表。每個方法都有一個異常表,異常表中的每一個條目都代表一個異常處理器,並且由 from、to、target 指標及其異常型別所構成。form-to 其實就是 try 塊,而 target 就是 catch 的起始位置。當程式觸發異常時,JVM 會檢測觸發異常的位元組碼的索引值落到哪個異常表的 from-to 範圍內,然後再判斷異常型別是否匹配,匹配就開始執行 target 處位元組碼處理該異常。

最後是 finally程式碼塊的編譯。我們知道 finally 程式碼塊一定會執行的(除非虛擬機器退出了)。那麼它是如何實現的呢?其實是一個比較笨的辦法,當前 JVM 的做法是,複製 finally 程式碼塊的內容,分別放在所有可能的執行路徑的出口中。

JVM 是如何實現註解的?

其實也沒啥銀彈,主要就是要知道註解資訊是存放在哪的?在 Java 位元組碼中呢是透過 RuntimeInvisibleAnnotations 結構來儲存的,它是一個 Annotations 陣列,畢竟類、方法、屬性是可以加多個註解的嘛。在陣列中的每一個元素又是一個 ElementValuePair 陣列,這個裡面儲存的就是註解的引數資訊。

執行時註解可以透過反射去拿這些資訊,編譯時註解可透過 APT 去拿,基本上就沒啥東西了。

Android及JVM學習資源

其實客戶端開發的知識點就那麼多,面試問來問去還是那麼點東西。所以面試沒有其他的訣竅,只看你對這些知識點準備的充分程度。so,出去面試時先看看自己複習到了哪個階段就好。

這裡再分享一下我面試期間的複習路線:(以下體系的複習資料是我從各路大佬收集整理好的)

《Android開發七大模組核心知識筆記》

Android面試必備的JVM虛擬機器制詳解,看完之後簡歷上多一個技能!
Android面試必備的JVM虛擬機器制詳解,看完之後簡歷上多一個技能!
Android面試必備的JVM虛擬機器制詳解,看完之後簡歷上多一個技能!

《379頁Android開發面試寶典》

歷時半年,我們整理了這份市面上最全面的安卓面試題解析大全
包含了騰訊、百度、小米、阿里、樂視、美團、58、360、新浪、搜狐等一線網際網路公司面試被問到的題目。熟悉本文中列出的知識點會大大增加透過前兩輪技術面試的機率。

如何使用它?

1.可以透過目錄索引直接翻看需要的知識點,查漏補缺。
2.五角星數表示面試問到的頻率,代表重要推薦指數

Android面試必備的JVM虛擬機器制詳解,看完之後簡歷上多一個技能!

《JVM核心知識點》

  1. Java記憶體模型
  2. GC機制
  3. 類載入
Android面試必備的JVM虛擬機器制詳解,看完之後簡歷上多一個技能!
Android面試必備的JVM虛擬機器制詳解,看完之後簡歷上多一個技能!

資料太多,全部展示會影響篇幅,暫時就先列舉這些部分截圖,以上資源均免費分享,以上內容均放在了開源專案: github  中已收錄,大家可以自行獲取(或者 關注主頁掃描加微信獲取)。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69983917/viewspace-2728251/,如需轉載,請註明出處,否則將追究法律責任。

相關文章