篇文章將重點分析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的默認垃圾回收器。
五 總結
深入分析了JVM內存模型,其中重點分析了jdk,jre和jvm關系,jvm類加載器,jvm堆內存劃分,GC回收器和GC回收算法等,整體偏向于理論,由于篇幅有限,本篇文章未分析這些技術在JVM實際調優中是如何運用的,將在接下來的文章中與大家分享。