筆者最近在面試時經常會被問道JVM及其GC相關方面的問題,在此做一下總結!
作為網際網路公司必問的問題之一,習慣性的問題有哪些:
簡述JVM的分割槽?
物件存亡?
GC演算法?
jvm記憶體模型?
類載入的過程?
類載入機制?
本文相關描述將參考《深入理解Java虛擬機器》。
1、執行時資料區域?
可以按照對執行緒的狀態分為
執行緒私有如:程式計數器、虛擬機器棧(VM Stack)、本地方法棧(Native Method Stack)
執行緒公有如:堆(Heap)、方法區
程式計數器:它可以看作是當前執行緒所執行的位元組碼的行號指示器。位元組碼直譯器工作就是通過改變計數器的值來選取嚇一跳需要執行的位元組碼指令(如分支、迴圈、跳轉、異常處理、執行緒恢復)。
Java虛擬機器棧:它的生命週期與執行緒相同,為虛擬機器執行Java方法(位元組碼)服務。其描述的是Java方法執行的記憶體模型:每個方法在執行的時候會建立一個棧幀用於儲存區域性變數表、運算元棧、動態連結、方法出口等資訊。區域性變數表中存放了編譯期可知的各種基本資料型別、物件引用、returnAddress型別。
(區域性變數所需的記憶體空間在編譯期間完成分配)
本地方法棧:為虛擬機器使用到的Native方法服務。
Java堆:各個執行緒共享的一塊的記憶體,在虛擬機器啟動的時候建立,用於存放例項物件。
堆是垃圾收集器管理的主要區域,因很多時候又稱做“GC堆”。
方法區:各個執行緒共享的記憶體區域,用於儲存已被虛擬機器載入的類的資訊、常量、靜態變數、即時編譯後的程式碼等資料。
有時候也稱為永久代,但並非資料進入方法區就永久存在。該區域回收的主要目標是針對常量池的回收和對型別的解除安裝。
補充:
執行時常量池:存放編譯期生成的各種字面量和符號引用,這部分內容將在類載入後進入方法區的執行時常量池中存放。
直接記憶體:並非虛擬機器執行時資料區的一部分,如果頻繁進行NIO操作,使得各個記憶體區域總和大於實體記憶體限制,導致動態擴充套件時出現OutOfMemoryError異常。
記憶體溢位的關鍵是:垃圾收集進行時,虛擬機器雖然會對Direct Memory進行回收,但是Direct Memory卻不能像新生代、老年代那樣,發現空間不足就通知收集器進行垃圾回收,它只能等待老年代滿了之後Full GC,順便清理掉記憶體的廢棄物件。否則只能等到丟擲記憶體溢位異常時,catch掉“執行”System.gc(),但是隻是通知虛擬機器執行,並非一定執行,所以虛擬這時候可能會出現堆中存在空閒記憶體,但卻出現記憶體溢位的情況。
2、物件的建立與消亡?
建立:當虛擬機器遇到一條new指令時,首先去檢查這個指令的引數是否能在常量池中定位到一個類的符號引用,並檢查這個符號引用代表的類是否被載入、解析和初始化過。如果沒有則執行相應的類載入的過程,並給新生物件分配記憶體。
判斷一個物件是否處於空閒狀態?兩種分配方式
指標碰撞:指標作為分界點的指示器,區分空閒區和非空閒區。(記憶體規整)
空閒列表:虛擬機器維護的列表,做記錄和更新操作。(不規整,相互交錯)
物件記憶體佈局?
物件頭(第一部分:儲存物件自身的執行時資料;第二部分:型別指標(物件指向它的類後設資料的指標,虛擬機器通過指標判斷是哪個類的例項)。)
例項資料(物件真正有效的資訊)
對齊填充 (無真實含義,做為佔位符存在。)
判斷是否存活的兩種方法?
1、引用計數法
給物件中新增一個引用計數器,每當有一個地方引用它時,計數器值就增加1;當引用失效時,計數器值就減1;任何時刻計數器為0的物件就是不可能在被使用了。(Java虛擬機器並未採用引用計數演算法原因:很難解決物件之間相互迴圈引用的問題)
2、可達分析演算法
基本思路就是通過一些列稱為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋走過的路徑稱為引用鏈,當一個物件到GC Roots沒有任何引用鏈相連,證明此物件是不可用的。
在Java語言中,可作為GC Roots的物件包括下面幾種:
虛擬機器棧(棧幀中的本地變數表)中引用的物件。
方法區中類靜態屬性引用的物件。
方法去中常量引用的物件。
本地方法棧JNI(即一般說的Native方法)引用的物件。
即使在可達分析演算法中的不可達物件,也並非是非死不可的,相當於處於緩刑階段,至少經歷兩次標記過程,才會宣告一個物件的死亡:如果物件在進行可達分析演算法後發現沒有與GC Roots相連線的引用鏈,將會第一次標記並且進行一次篩選,篩選條件是此物件是否有必要執行finalize()方法。當物件沒有覆蓋finalize()方法或者finalize()方法已經被虛擬機器呼叫過,虛擬機器將這兩種情況都視為“沒有必要執行”。
如果該物件有必要執行finalize()方法,那麼這個物件將會防止在一個F-Queue的佇列之中,並在稍後由一個虛擬機器自動建立的、低優先順序的Finalizer執行緒去執行它。但並不會承諾等待它的執行結束,finalize()方法是物件逃脫死亡命運的最後一次機會,稍後GC將對F-Queue中的物件進行第二次標記。沒逃脫話意味著需要進行回收,逃脫意味著需要跟引用鏈物件關聯。
3、垃圾收集演算法
1、標記清除演算法
演算法分為標記和清除兩個階段:首先標記出需要回收的物件,在標記完成後統一回收所有被標記的物件。
兩個不足:一個是效率問題,標記和清除兩個過程效率都不高;另一個是空間問題,標記清除之後會產生大連續的記憶體碎片,空間碎片太多可能導致以後程式執行過程中需要分配較大物件的時候,無法找到足夠的連續記憶體而不得不提前觸發一次垃圾收集動作。
2、複製演算法
解決了效率問題。它可將記憶體按容量劃分為大小相等的兩塊,每次只是用其中的一塊。當這一塊記憶體用完了,將還存活著的物件複製到另一塊上面,然後把使用過的記憶體空間一次清理掉。這樣每次都是整個半區域進行記憶體回收,記憶體分配時不需要考慮記憶體碎片的情況,只需要移動堆頂指標按照順序分配記憶體即可。
不足:代價高,將記憶體縮小為原來的一半。
改進:記憶體去不再按照1:1的比例來劃分記憶體空間,二是將記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中的一塊Survivor。當回收時,將Eden和Survivor中海存活的物件一次性複製到另一塊Survivor空間上,最後清理掉Eden和剛才Eden和剛才使用過的空間。當Survivor空間不夠用時,需要依賴其他記憶體進行分配擔保。如果另一塊Survivor空間沒有足夠空間存放上一次新生代收集下來的存活物件時,這些物件直接通過分配擔保機制進入老年代。 新生代特點需滿足“朝生夕死”。
預設Eden:Survivor = 8:1 即預設新生代佔90% ((1+8)/10 )
3、標記整理演算法
標記整理與標記清除演算法一樣,但後續步驟不是直接對可回收物件進行整理,而是讓所有存活物件都向一端移動,然後直接清理掉端邊界以外的記憶體。
4、分代回收演算法 (商業虛擬機器)
根據物件存活週期不同將記憶體劃分為幾塊。
新生代:垃圾回收時有大批物件死去,只有少量存活。使用複製演算法
老年代:存活率高、沒有額外的空間分配擔保,所以使用標記清理或者標記整理演算法進行回收。
4、Java記憶體模型(JMM)
Java記憶體模型主要目標是定義程式中各個變數的訪問規則,即虛擬機器中將變數儲存到記憶體和從記憶體中讀取出變數這樣的底層細節。
Java記憶體模型規定了所有變數都儲存在主記憶體中,每條程式還有自己的工作記憶體,執行緒的工作記憶體儲存了該執行緒使用到的變數的主記憶體副本拷貝,執行緒對變數的所有(讀、寫、賦值)操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體中變數。不同執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞均需要通過主記憶體來完成。
在執行程式的時,為提高效能,編譯器和處理器常常會對指令重排序。分三種型別:
1)編譯器優化重排序。
2)指令級並行重排序。
3)記憶體系統的重排序。
原始碼從編譯到執行要經過的指令序列包含以上三個步驟,重排序可能導致多執行緒程式出現記憶體可見性的問題。 對於處理重排序,JMM的處理器排序規則會要求Java編譯器生成指令序列時,插入特定型別的記憶體屏障指令,通過記憶體屏障指令來禁止特定型別的處理器重排序。
補充:
對此volatile是個很好學習例子,以後再單獨寫文章贅述。
簡單說下其的兩個主要特性:
第一保證所修飾的變數對有執行緒對其的可見性,第二語義是禁止指令重排序優化。
其有些特定場合並不適用,比如運算結果依賴當前值,或有其它狀態變數參與。具體事例可以參考volatile實現單例模式的雙重檢查機制。
這裡不可不說的是雙重檢查機制中為啥要用?
instance = new Singleton()
具體實現分三個過程:
1)分配物件記憶體空間。
2)初始化物件。
3)設定instance指向分配的記憶體地址。
在2、3可能會重排序。
接下來要說就是JMM圍繞併發過程中如何處理原子性、可見性和有序性?(這裡我會單獨寫併發處理的文章來贅述。)
5、常用命令
jps 顯示系統指定的所有HotSpot虛擬機器程式。
jstat 用於收集HotSpot虛擬機器各方面執行資料。
jinfo 顯示虛擬機器配置資訊。
jmap 生成虛擬機器記憶體快照。
jhat 分析heapdump檔案。建立一個Http/html伺服器使用者可以在瀏覽器上檢視分析結果。
jstack 虛擬機器執行緒快照。
常用工具:JConsole、VisualVM (可以在IDEA、Eclipse中配置相關外掛,Eclipse Memory Analysis等)
未完待續