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


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

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

熟悉 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 堆上。其次,這裡所說的變量初始值是該數據類型的零值。

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

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

安卓面試必備的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 有點內味。

安卓面試必備的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 方法了:

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

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開發七大模塊核心知識筆記》

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

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

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

《379頁Android開發面試寶典》

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

如何使用它?

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

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

《JVM核心知識點》

  1. Java內存模型

  2. GC機制

  3. 類加載

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

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

資料太多,全部展示會影響篇幅,暫時就先列舉這些部分截圖;

需要的朋友,直接轉發+點贊+私信回覆【資料】一鍵領取!!!


分享到:


相關文章: