轉載
https://mp.weixin.qq.com/s?__...
JVM 的內存區域
1、虛擬機棧:主要是局域變量。描述的是方法執行時的內存模型,是線程私有的,生命週期與線程相同,每個方法被執行的同時會創建棧楨(下文會看到),主要保存執行方法時的局部變量表、操作數棧、動態連接和方法返回地址等信息,方法執行時入棧,方法執行完出棧,出棧就相當於清空了數據,入棧出棧的時機很明確,所以這塊區域不需要進行 GC。
2、本地方法棧:主要是native方法。與虛擬機棧功能非常類似,主要區別在於虛擬機棧為虛擬機執行 Java 方法時服務,而本地方法棧為虛擬機執行本地方法時服務的。這塊區域也不需要進行 GC
3、程序計數器:線程獨有的, 可以把它看作是當前線程執行的字節碼的行號指示器,比如如下字節碼內容,在每個字節碼前面都有一個數字(行號),我們可以認為它就是程序計數器存儲的內容
記錄這些數字(指令地址)有啥用呢,我們知道 Java 虛擬機的多線程是通過線程輪流切換並分配處理器的時間來完成的,在任何一個時刻,一個處理器只會執行一個線程,如果這個線程被分配的時間片執行完了(線程被掛起),處理器會切換到另外一個線程執行,當下次輪到執行被掛起的線程(喚醒線程)時,怎麼知道上次執行到哪了呢,通過記錄在程序計數器中的行號指示器即可知道,所以程序計數器的主要作用是記錄線程運行時的狀態,方便線程被喚醒時能從上一次被掛起時的狀態繼續執行,需要注意的是,程序計數器是唯一一個在 Java 虛擬機規範中沒有規定任何 OOM 情況的區域,所以這塊區域也不需要進行 GC
本地內存:線程共享區域,Java 8 中,本地內存,也是我們通常説的堆外內存,包含元空間和直接內存,注意到上圖中 Java 8 和 Java 8 之前的 JVM 內存區域的區別了嗎,在 Java 8 之前有個永久代的概念,實際上指的是 HotSpot 虛擬機上的永久代,它用永久代實現了 JVM 規範定義的方法區功能,主要存儲類的信息,常量,靜態變量,即時編譯器編譯後代碼等,這部分由於是在堆中實現的,受 GC 的管理,不過由於永久代有 -XX:MaxPermSize 的上限,所以如果動態生成類(將類信息放入永久代)或大量地執行 String.intern (將字段串放入永久代中的常量區),很容易造成 OOM,有人説可以把永久代設置得足夠大,但很難確定一個合適的大小,受類數量,常量數量的多少影響很大。所以在 Java 8 中就把方法區的實現移到了本地內存中的元空間中,這樣方法區就不受 JVM 的控制了,也就不會進行 GC,也因此提升了性能(發生 GC 會發生 Stop The Word,造成性能受到一定影響,後文會提到),也就不存在由於永久代限制大小而導致的 OOM 異常了(假設總內存1G,JVM 被分配內存 100M, 理論上元空間可以分配 2G-100M = 1.9G,空間大小足夠),也方便在元空間中統一管理。綜上所述,在 Java 8 以後這一區域也不需要進行 GC
堆:前面幾塊數據區域都不進行 GC,那隻剩下堆了,是的,這裏是 GC 發生的區域!對象實例和數組都是在堆上分配的,GC 也主要對這兩類數據進行回收,這塊也是我們之後重點需要分析的區域
如何識別垃圾
上一節我們詳細講述了 JVM 的內存區域,知道了 GC 主要發生在堆,那麼 GC 該怎麼判斷堆中的對象實例或數據是不是垃圾呢,或者説判斷某些數據是否是垃圾的方法有哪些。
引用計數法
最容易想到的一種方式是引用計數法,啥叫引用計數法,簡單地説,就是對象被引用一次,在它的對象頭上加一次引用次數,如果沒有被引用(引用次數為 0),則此對象可回收
String ref = new String("Java");
以上代碼 ref1 引用了右側定義的對象,所以引用次數是 1
如果在上述代碼後面添加一個 ref = null,則由於對象沒被引用,引用次數置為 0,由於不被任何變量引用,此時即被回收,動圖如下
看起來用引用計數確實沒啥問題了,不過它無法解決一個主要的問題:循環引用!啥叫循環引用
public class TestRC {
TestRC instance;
public TestRC(String name) {
}
public static void main(String[] args) {
// 第一步
A a = new TestRC("a");
B b = new TestRC("b");
// 第二步
a.instance = b;
b.instance = a;
// 第三步
a = null;
b = null;
}
}
按步驟一步步畫圖
到了第三步,雖然 a,b 都被置為 null 了,但是由於之前它們指向的對象互相指向了對方(引用計數都為 1),所以無法回收,也正是由於無法解決循環引用的問題,所以現代虛擬機都不用引用計數法來判斷對象是否應該被回收。
可達性算法
現代虛擬機基本都是採用這種算法來判斷對象是否存活,可達性算法的原理是以一系列叫做 GC Root 的對象為起點出發,引出它們指向的下一個節點,再以下個節點為起點,引出此節點指向的下一個結點。。。(這樣通過 GC Root 串成的一條線就叫引用鏈),直到所有的結點都遍歷完畢,如果相關對象不在任意一個以 GC Root 為起點的引用鏈中,則這些對象會被判斷為「垃圾」,會被 GC 回收。
如圖示,如果用可達性算法即可解決上述循環引用的問題,因為從GC Root 出發沒有到達 a,b,所以 a,b 可回收a, b 對象可回收,就一定會被回收嗎?
並不是,對象的 finalize 方法給了對象一次垂死掙扎的機會,當對象不可達(可回收)時,當發生GC時,會先判斷對象是否執行了 finalize 方法,如果未執行,則會先執行 finalize 方法,我們可以在此方法裏將當前對象與 GC Roots 關聯,這樣執行 finalize 方法之後,GC 會再次判斷對象是否可達,如果不可達,則會被回收,如果可達,則不回收!
注意: finalize 方法只會被執行一次,如果第一次執行 finalize 方法此對象變成了可達確實不會回收,但如果對象再次被 GC,則會忽略 finalize 方法,對象會被回收!這一點切記!
那麼這些 GC Roots 到底是什麼東西呢,哪些對象可以作為 GC Root 呢,有以下幾類
1、虛擬機棧(棧幀中的本地變量表)中引用的對象方法區中類靜態屬性引用的對象。
2、方法區中常量引用的對象。
3、本地方法棧中 JNI(即一般説的 Native 方法)引用的對象
可達性算法除了GC Roots,還有一個引用,引用分以下幾種:
強引用(Strong Reference):只要強引用還存在,垃圾收集器永遠不會回收被引用的對象。
軟引用(Soft Reference):在系統將要發生內存溢出異常之前,將會把這些對象列進回收範圍之中進行第二次回收。如果這次回收還沒有足夠的內存,才會拋出內存溢出異常。
弱引用(Weak Reference ): 被弱引用關聯的對象只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前內存是否足夠, 都會回收掉只被弱引用關聯的對象。
虛引用(Phantom Reference):一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。為一個對象設置虛引用關聯的唯一目的就是能在這個對象被收集器回收時收到一個系統通知。
虛擬機棧中引用的對象
如下代碼所示,a 是棧幀中的本地變量,當 a = null 時,由於此時 a 充當了 GC Root 的作用,a 與原來指向的實例 new Test() 斷開了連接,所以對象會被回收。
public class Test {
public static void main(String[] args) {
Test a = new Test();
a = null;
}
}
方法區中類靜態屬性引用的對象
如下代碼所示,當棧幀中的本地變量 a = null 時,由於 a 原來指向的對象與 GC Root (變量 a) 斷開了連接,所以 a 原來指向的對象會被回收,而由於我們給 s 賦值了變量的引用,s 在此時是類靜態屬性引用,充當了 GC Root 的作用,它指向的對象依然存活!
public class Test {
public static Test s;
public static void main(String[] args) {
Test a = new Test();
a.s = new Test();
a = null;
}
}
方法區中常量引用的對象
如下代碼所示,常量 s 指向的對象並不會因為 a 指向的對象被回收而回收
public class Test {
public static final Test s = new Test();
public static void main(String[] args) {
Test a = new Test();
a = null;
}
}
本地方法棧中 JNI 引用的對象
這是簡單給不清楚本地方法為何物的童鞋簡單解釋一下:所謂本地方法就是一個 java 調用非 java 代碼的接口,該方法並非 Java 實現的,可能由 C 或 Python等其他語言實現的, Java 通過 JNI 來調用本地方法, 而本地方法是以庫文件的形式存放的(在 WINDOWS 平台上是 DLL 文件形式,在 UNIX 機器上是 SO 文件形式)。通過調用本地的庫文件的內部方法,使 JAVA 可以實現和本地機器的緊密聯繫,調用系統級的各接口方法,還是不明白?見文末參考,對本地方法定義與使用有詳細介紹。當調用 Java 方法時,虛擬機會創建一個棧楨並壓入 Java 棧,而當它調用的是本地方法時,虛擬機會保持 Java 棧不變,不會在 Java 棧禎中壓入新的禎,虛擬機只是簡單地動態連接並直接調用指定的本地方法。
JNIEXPORT void JNICALL Java_com_pecuyu_jnirefdemo_MainActivity_newStringNative(JNIEnv *env, jobject instance,jstring jmsg) {
...
// 緩存String的class
jclass jc = (*env)->FindClass(env, STRING_PATH);
}
如上代碼所示,當 java 調用以上本地方法時,jc 會被本地方法棧壓入棧中, jc 就是我們説的本地方法棧中 JNI 的對象引用,因此只會在此本地方法執行完成後才會被釋放。
垃圾回收主要方法
上一節我們知道了可以通過可達性算法來識別哪些數據是垃圾,那該怎麼對這些垃圾進行回收呢。主要有以下幾種方式方式
1、標記清除算法
2、複製算法
3、標記整理法
標記清除算法
步驟很簡單
1、先根據可達性算法標記出相應的可回收對象(圖中黃色部分)
2、對可回收的對象進行回收
操作起來確實很簡單,也不用做移動數據的操作,那有啥問題呢?仔細看上圖,沒錯,內存碎片!假如我們想在上圖中的堆中分配一塊需要連續內存佔用 4M 或 5M 的區域,顯然是會失敗,怎麼解決呢,如果能把上面未使用的 2M, 2M,1M 內存能連起來就能連成一片可用空間為 5M 的區域即可,怎麼做呢?
複製算法
把堆等分成兩塊區域, A 和 B,區域 A 負責分配對象,區域 B 不分配, 對區域 A 使用以上所説的標記法把存活的對象標記出來(下圖有誤無需清除),然後把區域 A 中存活的對象都複製到區域 B(存活對象都依次緊鄰排列)最後把 A 區對象全部清理掉釋放出空間,這樣就解決了內存碎片的問題了。
不過複製算法的缺點很明顯,比如給堆分配了 500M 內存,結果只有 250M 可用,空間平白無故減少了一半!這肯定是不能接受的!另外每次回收也要把存活對象移動到另一半,效率低下(我們可以想想刪除數組元素再把非刪除的元素往一端移,效率顯然堪憂)
標記整理法
前面兩步和標記清除法一樣,不同的是它在標記清除法的基礎上添加了一個整理的過程 ,即將所有的存活對象都往一端移動,緊鄰排列(如圖示),再清理掉另一端的所有區域,這樣的話就解決了內存碎片的問題。
但是缺點也很明顯:每進一次垃圾清除都要頻繁地移動存活的對象,效率十分低下。
分代收集算法
分代收集算法整合了以上算法,綜合了這些算法的優點,最大程度避免了它們的缺點,所以是現代虛擬機採用的首選算法,與其説它是算法,倒不是説它是一種策略,因為它是把上述幾種算法整合在了一起,為啥需要分代收集呢,來看一下對象的分配有啥規律
如圖示:縱軸代表已分配的字節,而橫軸代表程序運行時間由圖可知,大部分的對象都很短命,都在很短的時間內都被回收了(IBM 專業研究表明,一般來説,98% 的對象都是朝生夕死的,經過一次 Minor GC 後就會被回收),所以分代收集算法根據對象存活週期的不同將堆分成新生代和老生代(Java8以前還有個永久代),默認比例為 1 : 2,新生代又分為 Eden 區, from Survivor 區(簡稱S0),to Survivor 區(簡稱 S1),三者的比例為 8: 1 : 1,這樣就可以根據新老生代的特點選擇最合適的垃圾回收算法,我們把新生代發生的 GC 稱為 Young GC(也叫 Minor GC),老年代發生的 GC 稱為 Old GC(也稱為 Full GC)。
分代收集工作原理
1、對象在新生代的分配與回收
由以上的分析可知,大部分對象在很短的時間內都會被回收,對象一般分配在 Eden 區
當 Eden 區將滿時,觸發 Minor GC
我們之前怎麼説來着,大部分對象在短時間內都會被回收, 所以經過 Minor GC 後只有少部分對象會存活,它們會被移到 S0 區(這就是為啥空間大小 Eden: S0: S1 = 8:1:1, Eden 區遠大於 S0,S1 的原因,因為在 Eden 區觸發的 Minor GC 把大部對象(接近98%)都回收了,只留下少量存活的對象,此時把它們移到 S0 或 S1 綽綽有餘)同時對象年齡加一(對象的年齡即發生 Minor GC 的次數),最後把 Eden 區對象全部清理以釋放出空間,動圖如下
當觸發下一次 Minor GC 時,會把 Eden 區的存活對象和 S0(或S1) 中的存活對象(S0 或 S1 中的存活對象經過每次 Minor GC 都可能被回收)一起移到 S1(Eden 和 S0 的存活對象年齡+1), 同時清空 Eden 和 S0 的空間。
若再觸發下一次 Minor GC,則重複上一步,只不過此時變成了 從 Eden,S1 區將存活對象複製到 S0 區,每次垃圾回收, S0, S1 角色互換,都是從 Eden ,S0(或S1) 將存活對象移動到 S1(或S0)。也就是説在 Eden 區的垃圾回收我們採用的是複製算法,因為在 Eden 區分配的對象大部分在 Minor GC 後都消亡了,只剩下極少部分存活對象(這也是為啥 Eden:S0:S1 默認為 8:1:1 的原因),S0,S1 區域也比較小,所以最大限度地降低了複製算法造成的對象頻繁拷貝帶來的開銷。
2、對象何時晉升老年代
當對象的年齡達到了我們設定的閾值,則會從S0(或S1)晉升到老年代
如圖示:年齡閾值設置為 15, 當發生下一次 Minor GC 時,S0 中有個對象年齡達到 15,達到我們的設定閾值,晉升到老年代!
大對象 當某個對象分配需要大量的連續內存時,此時對象的創建不會分配在 Eden 區,會直接分配在老年代,因為如果把大對象分配在 Eden 區, Minor GC 後再移動到 S0,S1 會有很大的開銷(對象比較大,複製會比較慢,也佔空間),也很快會佔滿 S0,S1 區,所以乾脆就直接移到老年代.還有一種情況也會讓對象晉升到老年代,即在 S0(或S1) 區相同年齡的對象大小之和大於 S0(或S1)空間一半以上時,則年齡大於等於該年齡的對象也會晉升到老年代。
3、空間分配擔保
在發生 MinorGC 之前,虛擬機會先檢查老年代最大可用的連續空間是否大於新生代所有對象的總空間,如果大於,那麼Minor GC 可以確保是安全的,如果不大於,那麼虛擬機會查看 HandlePromotionFailure 設置值是否允許擔保失敗。如果允許,那麼會繼續檢查老年代最大可用連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於則進行 Minor GC,否則可能進行一次 Full GC。
4、Stop The World
如果老年代滿了,會觸發 Full GC, Full GC 會同時回收新生代和老年代(即對整個堆進行GC),它會導致 Stop The World(簡稱 STW),造成挺大的性能開銷。
什麼是 STW ?所謂的 STW, 即在 GC(minor GC 或 Full GC)期間,只有垃圾回收器線程在工作,其他工作線程則被掛起。
一般 Full GC 會導致工作線程停頓時間過長(因為Full GC 會清理整個堆中的不可用對象,一般要花較長的時間),如果在此 server 收到了很多請求,則會被拒絕服務!所以我們要儘量減少 Full GC(Minor GC 也會造成 STW,但只會觸發輕微的 STW,因為 Eden 區的對象大部分都被回收了,只有極少數存活對象會通過複製算法轉移到 S0 或 S1 區,所以相對還好)。
現在我們應該明白把新生代設置成 Eden, S0,S1區或者給對象設置年齡閾值或者默認把新生代與老年代的空間大小設置成 1:2 都是為了儘可能地避免對象過早地進入老年代,儘可能晚地觸發 Full GC。想想新生代如果只設置 Eden 會發生什麼,後果就是每經過一次 Minor GC,存活對象會過早地進入老年代,那麼老年代很快就會裝滿,很快會觸發 Full GC,而對象其實在經過兩三次的 Minor GC 後大部分都會消亡,所以有了 S0,S1的緩衝,只有少數的對象會進入老年代,老年代大小也就不會這麼快地增長,也就避免了過早地觸發 Full GC。由於 Full GC(或Minor GC) 會影響性能,所以我們要在一個合適的時間點發起 GC,這個時間點被稱為 Safe Point,這個時間點的選定既不能太少以讓 GC 時間太長導致程序過長時間卡頓,也不能過於頻繁以至於過分增大運行時的負荷。一般當線程在這個時間點上狀態是可以確定的,如確定 GC Root 的信息等,可以使 JVM 開始安全地 GC。Safe Point 主要指的是以下特定位置:
1、循環的末尾
2、方法返回前
3、調用方法的 call 之後
4、拋出異常的位置 另外需要注意的是由於新生代的特點(大部分對象經過 Minor GC後會消亡), Minor GC 用的是複製算法,而在老生代由於對象比較多,佔用的空間較大,使用複製算法會有較大開銷(複製算法在對象存活率較高時要進行多次複製操作,同時浪費一半空間)所以根據老生代特點,在老年代進行的 GC 一般採用的是標記整理法來進行回收。
類加載機制
類加載過程
1、加載:通過類完全限定名,創建Class對象
2、驗證:檢驗加載文件是否符合滿足虛擬機格式
3、準備:將static變量分配內存,設置默認值。(final修飾的靜態變量在編譯時期就分配了,這裏不做操作)
4、解析:將二進制符號解析為直接引用
5、初始化:加載static代碼塊,父類構造器,構造函數
類加載時機
1、new 關鍵字創建
2、子類創建
3、通過反射創建類
4、調用靜態方法或者靜態變量時
類加載器
1、classLoader主要加載是java核心包(rt.jar)
2、ExtensionClassLoader主要加載jre的拓展包(ext文件夾)
3、appClassLoader主要加載自定義類
類加載機制特點
1、全盤負責:當一個類加載某個class時,該class依賴和引用的class都由該類的加載器加載。
2、雙親委派:當前類加載時候,會去用父類加載嘗試加載,依次遞歸,如果父類加載成功着返回,如果父類加載器加載不成功就自己去加載。
3、緩存機制:緩存機制確保所有加載的class緩存。當加載某個class時回去緩存中尋找,如果存在直接返回,不存在去加載。所以有點修改class後都需要重啓jvm才會生效。
雙親委派優勢
1、父類加載器加載了,子類加載器就不會加載。防止重複加載。
2、防止核心API被修改。比如核心包中的integer類加載後,再來一個自定義一模一樣的類,到classLoader加載時會發現Integer已經加載了。