在之前的文章 一步步解析java執行內幕 中,比較詳細分析了java程式碼是如何一步一步在jvm中執行的,然而涉及到的jvm核心技術點,並未做深入分析,上篇博文 記一次線上商城系統高併發的優化 分享了jvm線上併發調優,基於次,是時候與大家分享交流jvm相關技術了。
本篇文章將重點分析jvm,涉及到的內容包括jvm記憶體模型,類載入器,GC回收演算法,GC回收器,整體偏向於理論。
本篇文章不適合初學者,適合具有3年以上開發經驗的技術人員,歡迎大家一起交流分享,文章若有不足之處,歡迎讀者朋友們指出,先感謝。
一 明確jdk,jre和jvm之間關係
下圖為官閘道器於jdk,jre和jvm的架構圖,從該架構圖,很容易看出三者之間關係:
(1)jdk包含jre,而jre又包含jvm
(2)jdk主要用於開發環境,jre主要用於釋出環境,當然,釋出環境用jdk也沒問題,僅僅是效能可能會有點影響,jdk與jre關係有點類似程式debug版本和release版本之間關係
(3)從檔案大小來說,jdk比jre大。從圖中可以看出,jdk比jre多了一層工具包,如常用的javac,java命令等
二 類載入器
關於jvm類載入器,可概括為如下圖:
1.為什麼要有類載入器?
(1)將位元組碼檔案載入到執行時資料區。.java原始碼通過Javac命令編譯後形成的位元組碼檔案(.class),通過類載入器載入進入jvm中的。
(2)確定位元組碼檔案在執行時資料區的唯一性。相同的位元組碼檔案,通過不同的類載入器,就形成不同的檔案,因此位元組碼檔案在執行時資料區的唯一性是由位元組碼檔案和載入它的類載入器共同決定的
2.類載入器的種類
從種類上來劃分,類載入器主要劃分為四大類
(1)啟動類載入器 (根類載入器Bootstrap ClassLoader):該類載入器位於類載入器的最頂層,主要載入jre核心相關jar包,如 /jre/lib/rt.jar
(2)擴充套件類載入器(Extension ClassLoader):該類載入器位於類載入器層次的第二層,主要載入 jre擴充套件相關jar包,如/jre/lib/ext/*.jar
(3)應用程式類載入器(Application ClassLoader) App:該類載入器位於類載入器的第三層,主要載入類路徑(classpaht)下的相關jar包
(4)使用者自定義類載入器(User ClassLoader):該類載入器為使用者自定義類載入器,主要載入使用者指定的路徑下的相關jar包
3.類載入器的機制(雙親委派)
對於位元組碼的載入,類載入機制為雙親委派,什麼叫雙親委派呢?
類載入器獲取位元組碼檔案後,不是直接載入,而是將該位元組碼檔案傳遞給其直接父級類載入器,其直接父載入器又繼續傳遞給其直接父載入器的直接父載入器,依次類推到根父載入器,若根父載入器
能載入,則載入,否則交給其直接孩子載入器載入,直接孩子載入器能載入就載入,若不能,依次類推其直接孩子類載入器,若都不能載入,最後才由使用者自定義類載入器載入。
4.jdk 1.8 如何實現類載入器?
如下為jdk 1.8 類載入器的實現,採用遞迴方式
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // First, check if the class has already been loaded Class<?> c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) { // If still not found, then invoke findClass in order // to find the class. long t1 = System.nanoTime(); c = findClass(name); // this is the defining class loader; record the stats sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } }
5.破壞雙親委派模型
在某些情況下,由於受載入範圍限制,父類載入器無法載入到需要的檔案,因此父類載入器需要委託其子類載入器去載入相應的位元組碼檔案。
如在jdk中定義的資料庫驅動介面Driver,但該介面的實現卻由不同的資料庫廠商來實現,這就產生這樣一個問題:由啟動類(Bootstrap ClassLoader)
執行的DriverManager要載入實現了Driver介面的相關實現類,從而實現統一管理,但Bootstrap ClassLoader只能載入jre/lib下的相應檔案,不能載入
由各個廠商實現的Dirver介面相關實現類(Dirver實現類是由Application ClassLoader載入),這時就需要Bootstrap ClassLoader委託其子類載入器載入Driver
來實現,從而破壞了雙親委派模型。
三 類的生命週期
java中的類,在jvm中的生命週期,大概分為五個階段:
1.載入階段:獲取位元組碼二進位制流,並將靜態儲存結構轉化成方法區的執行時資料結構,且在方法區生成相應的類物件(java.lang.Class物件),作為該類的資料訪問入口。
2.連線階段:該階段包括三個小階段,即驗證,準備和解析三階段
(1)驗證:確保位元組碼檔案符合虛擬機器規範要求,如後設資料驗證,檔案格式驗證,位元組碼驗證和符號驗證等
(2)準備:為內的靜態表裡分配記憶體,並且設定jvm預設值,對於非靜態變數,此階段,不需分配記憶體。
(3)解析:將常量池內的符號引用轉化為直接引用
3.初始化階段:類物件使用前的一些必要初始化工作
如下引用自一位博友的觀點,個人認為解釋得很好。
在 Java 程式碼中,如果要初始化一個靜態欄位,我們可以在宣告時直接賦值,也可以在靜態程式碼塊中對其賦值。
除了 final static 修飾的常量,直接賦值操作以及所有靜態程式碼塊中的程式碼,則會被 Java 編譯器置於同一方法中,並把它命名為 < clinit > 。初始化的目的是是為標記為
常量值的欄位賦值,以及執行< clinit > 方法的過程。Java 虛擬機器會通過加鎖來確保類的 < clinit > 方法僅被執行一次。
哪些條件會發生類初始化呢?
(1)當虛擬機器啟動時,初始化使用者指定的主類(main函式);
(2)當遇到用於新建目標類例項的 new 指令時,初始化 new 指令的目標類;
(3)當遇到呼叫靜態方法的指令時,初始化該靜態方法所在的類;
(4)子類的初始化會觸發父類的初始化;
(5)如果一個介面定義了 default 方法,那麼直接實現或者間接實現該介面的類的初始化,會觸發該介面的初始化;
(6)使用反射 API 對某個類進行反射呼叫時,初始化這個類;
(7)當初次呼叫 MethodHandle 例項時,初始化該 MethodHandle 指向的方法所在的類。
4.使用階段:jvm中使用物件
5.解除安裝階段:將物件從jvm中解除安裝(unload),哪些條件會使jvm發生類解除安裝呢?
(1)載入該類的類載入器被回收
(2)該類的所有例項已經被回收
(3)該類對應的java.lang.Class物件沒有任何地方被引用
四 jvm記憶體模型
1.JVM記憶體模型是怎樣的?
如下為JVM記憶體模型架構圖,由於在之前的文章中論述過,這裡就不再一 一論述,主要講解堆區。
在jdk 1.8前,堆區主要分為新生代、老年代和永久代。jdk 1.8後,去掉了永久代,增加了MetaSpace區。這裡,主要分享jdk 1.8。
根據jdk1.8,堆區邏輯抽象為三個部分:
(1)新生代:包括Eden區,S0區(也叫from區),S21(也叫TO區)
(2)老年代
(3)Metaspace區
2.新生代和老年代的記憶體大小是怎樣的?
根據官方建議,新生代佔三分之一(Eden:S0:S1=8:1:1),老年代佔三分之二,因此記憶體分配圖如下:
3.GC回收是怎樣進行的?
物件先在Eden區執行,當Eden記憶體用佔用滿時,Eden會進行兩個操作:回收不用的物件和將未回收物件放入s0區,此時s0區和s1區互喚名稱,即s0->s1,s1->s0,Eden區經過一次物件回收後,釋放了空間,當Eden下次再滿時,執行相同步驟,依次迴圈執行,當Eden區回收後,剩下的物件超過s0容量,則將出發一次Minor GC,此時將未回收的物件放入老年區,依次迴圈執行,當Eden區觸發Minor GC時,剩餘的物件容量大於old區剩餘容量時,則old區將觸發一次Major GC,此時便會觸發一次Full GC。需要注意的是,一般發生Major GC,基本都都會伴隨一次Full GC回收,Full GC非常損耗效能,在JVM調優時,要注意。
下圖我在生產環境截的一張GC圖,監控工具VisualVM
4.垃圾回收演算法有哪些?
(1)標記-清除演算法
該演算法分為2個階段,即標記階段和清楚階段,首先標記所有要回收的物件,然後回收被標記的物件。該演算法效率低,且容易產生記憶體碎片。
a.效率低:需要遍歷兩次記憶體,第一次標記,第二次回收被標記物件
b.由於是非連續記憶體片段,容易產生碎片,當物件過大時,容易發生Full GC
下圖為標記-清除演算法 回收前和回收後對比示意圖
(2)標記-複製演算法
該演算法解決了“標記-清除”演算法效率低和大部分記憶體碎片問題,它將記憶體分為大小相等的兩塊,每次只使用其中一塊,當其中一塊需要回收時,只需將該快區域還存活的物件複製到另一塊,然後再把該塊記憶體一次性清理掉,迴圈往復。
下圖為標記-複製演算法回收前和回收收簡要示意圖
然而,由於年輕代大部分物件駐留時間都非常短,98%的物件都很快被回收,存活的物件非常少,不需要按照記憶體1:1來劃分,而是按照8:1:1來劃分,
將2%存活的物件放在s0(from區)即可。
如下為按照Eden:s0:s1 =8:1:1 劃分示意圖
(3)標記-整理演算法
該演算法分為兩階段,即標記和整理,首先標記所有存活物件,將這些物件向一端移動,然後直接清理掉端邊界以外的記憶體。由於老年代的物件存活時間比較長,因此適合用該演算法。
標記過程仍與“標記-清除”過程一致,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活物件向一端移動,然後直接清理掉端邊界以外的記憶體。
如下為"標記-整理演算法"回收期和回收後示意圖
(4)分代收集演算法
該演算法未目前jvm演算法,採用分代思想,模型如下:
5.常見GC回收器有哪些?
(1)SerialGC
SerialGC又叫序列回收器,也是最基礎的GC回收器,主要適用於單核cpu,新生代採用複製演算法, 老年代採用標記-壓縮演算法,在執行的過程中需要暫停應用程式,
因此會造成STW問題,在JVM標註引數為:-XX:+UseSerialGC 。
(2)ParallelGC
ParallelGC基於SerialGC,主要解決SerialGC序列問題,改為並行問題,解決多執行緒問題,但同樣會產生STW問題,jvm關鍵引數:
a.-XX:+UseParNewGC,表示新生代並行(複製演算法) 老年代序列(標記-壓縮)
b.XX:+UseParallelOldGC,老年代也是並行
(3)CMS GC
CMSGC屬於老年代回收器,採用“標記-清除演算法”,不會發生STW問題,在jvm中引數設定:
-XX:+UseConcMarkSweepGC,表示老年代使用CMS收集器
(4)Garbage First
Garbage First面向jvm垃圾收集器 ,它滿足短時間停頓的同時達到一個高的吞吐量,適用於多核cpu和大記憶體的服務端,也是jdk9的預設垃圾回收器。
五 總結
本篇文章在之前文章 一步步解析java執行內幕 基礎上,深入分析了JVM記憶體模型,其中重點分析了jdk,jre和jvm關係,jvm類載入器,jvm堆記憶體劃分,GC回收器和GC回收演算法等,整體偏向於理論,由於篇幅有限,本篇文章未分析這些技術在JVM實際調優中是如何運用的,將在接下來的文章中與大家分享。