Java 相比 C/C++ 最顯著的特色即是引入了自動垃圾回收 (下文統一用 GC 指代自動垃圾回收),它解決了 C/C++ 最使人頭疼的內存管理問題,讓程序員專一於程序自己,不用關心內存回收這些惱人的問題,這也是 Java 能大行其道的重要緣由之一,GC 真正讓程序員的生產力獲得了釋放,可是程序員很難感知到它的存在,這就比如,咱們吃完飯後在桌上放下餐盤即走,服務員會替你收拾好這些餐盤,你不會關心服務員何時來收,怎麼收。html
有人說既然 GC 已經自動咱們完成了清理,不瞭解 GC 貌似也沒啥問題。在大多數狀況下確實沒問題,不過若是涉及到一些性能調優,問題排查等,深刻地瞭解 GC 仍是必不可少的,曾經美團經過調整 JVM 相關 GC 參數讓服務響應時間 TP90,TP99都降低了10ms+,服務可用性獲得了很大的提高!因此深刻了解 GC 是成爲一名優秀 Java 程序員的必修課!java
垃圾回收分上下篇,上篇會先講垃圾回收理論,主要包括程序員
下篇主要講垃圾回收的實踐,主要包括算法
本文會從如下幾方面來闡述垃圾回收數組
如何識別垃圾緩存
垃圾回收主要方法安全
要搞懂垃圾回收的機制,咱們首先要知道垃圾回收主要回收的是哪些數據,這些數據主要在哪一塊區域,因此咱們一塊兒來看下 JVM 的內存區域微信
記錄這些數字(指令地址)有啥用呢,咱們知道 Java 虛擬機的多線程是經過線程輪流切換並分配處理器的時間來完成的,在任何一個時刻,一個處理器只會執行一個線程,若是這個線程被分配的時間片執行完了(線程被掛起),處理器會切換到另一個線程執行,當下次輪到執行被掛起的線程(喚醒線程)時,怎麼知道上次執行到哪了呢,經過記錄在程序計數器中的行號指示器便可知道,因此程序計數器的主要做用是記錄線程運行時的狀態,方便線程被喚醒時能從上一次被掛起時的狀態繼續執行,須要注意的是,程序計數器是惟一一個在 Java 虛擬機規範中沒有規定任何 OOM 狀況的區域,因此這塊區域也不須要進行 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 呢,有如下幾類
以下代碼所示,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; } }
這是簡單給不清楚本地方法爲什麼物的童鞋簡單解釋一下:所謂本地方法就是一個 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 的對象引用,所以只會在此本地方法執行完成後纔會被釋放。
上一節咱們知道了能夠經過可達性算法來識別哪些數據是垃圾,那該怎麼對這些垃圾進行回收呢。
主要有如下幾種方式方式
步驟很簡單
操做起來確實很簡單,也不用作移動數據的操做,那有啥問題呢?仔細看上圖,沒錯,內存碎片!假如咱們想在上圖中的堆中分配一塊須要連續內存佔用 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)。
畫外音:思考一下,新生代爲啥要分這麼多區?
那麼分代垃圾收集是怎麼工做的呢,咱們一塊兒來看看
一、對象在新生代的分配與回收
由以上的分析可知,大部分對象在很短的時間內都會被回收,對象通常分配在 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 區域也比較小,因此最大限度地下降了複製算法形成的對象頻繁拷貝帶來的開銷。
二、對象什麼時候晉升老年代
如圖示:年齡閾值設置爲 15, 當發生下一次 Minor GC 時,S0 中有個對象年齡達到 15,達到咱們的設定閾值,晉升到老年代!
三、空間分配擔保
四、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 主要指的是如下特定位置:
另外須要注意的是因爲新生代的特色(大部分對象通過 Minor GC後會消亡), Minor GC 用的是複製算法,而在老生代因爲對象比較多,佔用的空間較大,使用複製算法會有較大開銷(複製算法在對象存活率較高時要進行屢次複製操做,同時浪費一半空間)因此根據老生代特色,在老年代進行的 GC 通常採用的是標記整理法來進行回收。
若是說收集算法是內存回收的方法論,那麼垃圾收集器就是內存回收的具體實現。Java 虛擬機規範並無規定垃圾收集器應該如何實現,所以通常來講不一樣廠商,不一樣版本的虛擬機提供的垃圾收集器實現可能會有差異,通常會給出參數來讓用戶根據應用的特色來組合各個年代使用的收集器,主要有如下垃圾收集器
圖片的垃圾收集器若是存在連線,則表明它們之間能夠配合使用,接下來咱們來看看各個垃圾收集器的具體功能。
Serial 收集器是工做在新生代的,單線程的垃圾收集器,單線程意味着它只會使用一個 CPU 或一個收集線程來完成垃圾回收,不只如此,還記得咱們上文提到的 STW 了嗎,它在進行垃圾收集時,其餘用戶線程會暫停,直到垃圾收集結束,也就是說在 GC 期間,此時的應用不可用。
看起來單線程垃圾收集器不太實用,不過咱們須要知道的任何技術的使用都不能脫離場景,在 Client 模式下,它簡單有效(與其餘收集器的單線程比),對於限定單個 CPU 的環境來講,Serial 單線程模式無需與其餘線程交互,減小了開銷,專心作 GC 能將其單線程的優點發揮到極致,另外在用戶的桌面應用場景,分配給虛擬機的內存通常不會很大,收集幾十甚至一兩百兆(僅是新生代的內存,桌面應用基本不會再大了),STW 時間能夠控制在一百多毫秒內,只要不是頻繁發生,這點停頓是能夠接受的,因此對於運行在 Client 模式下的虛擬機,Serial 收集器是新生代的默認收集器
ParNew 收集器是 Serial 收集器的多線程版本,除了使用多線程,其餘像收集算法,STW,對象分配規則,回收策略與 Serial 收集器完成同樣,在底層上,這兩種收集器也共用了至關多的代碼,它的垃圾收集過程以下
ParNew 主要工做在 Server 模式,咱們知道服務端若是接收的請求多了,響應時間就很重要了,多線程可讓垃圾回收得更快,也就是減小了 STW 時間,能提高響應時間,因此是許多運行在 Server 模式下的虛擬機的首選新生代收集器,另外一個與性能無關的緣由是由於除了 Serial 收集器,只有它能與 CMS 收集器配合工做,CMS 是一個劃時代的垃圾收集器,是真正意義上的併發收集器,它第一次實現了垃圾收集線程與用戶線程(基本上)同時工做,它採用的是傳統的 GC 收集器代碼框架,與 Serial,ParNew 共用一套代碼框架,因此能與這二者一塊兒配合工做,然後文提到的 Parallel Scavenge 與 G1 收集器沒有使用傳統的 GC 收集器代碼框架,而是另起爐竈獨立實現的,另一些收集器則只是共用了部分的框架代碼,因此沒法與 CMS 收集器一塊兒配合工做。
在多 CPU 的狀況下,因爲 ParNew 的多線程回收特性,毫無疑問垃圾收集會更快,也能有效地減小 STW 的時間,提高應用的響應速度。
Parallel Scavenge 收集器也是一個使用複製算法,多線程,工做於新生代的垃圾收集器,看起來功能和 ParNew 收集器同樣,它有啥特別之處嗎
關注點不一樣,CMS 等垃圾收集器關注的是儘量縮短垃圾收集時用戶線程的停頓時間,而 Parallel Scavenge 目標是達到一個可控制的吞吐量(吞吐量 = 運行用戶代碼時間 / (運行用戶代碼時間+垃圾收集時間)),也就是說 CMS 等垃圾收集器更適合用到與用戶交互的程序,由於停頓時間越短,用戶體驗越好,而 Parallel Scavenge 收集器關注的是吞吐量,因此更適合作後臺運算等不須要太多用戶交互的任務。
Parallel Scavenge 收集器提供了兩個參數來精確控制吞吐量,分別是控制最大垃圾收集時間的 -XX:MaxGCPauseMillis 參數及直接設置吞吐量大小的 -XX:GCTimeRatio(默認99%)
除了以上兩個參數,還能夠用 Parallel Scavenge 收集器提供的第三個參數 -XX:UseAdaptiveSizePolicy,開啓這個參數後,就不須要手工指定新生代大小,Eden 與 Survivor 比例(SurvivorRatio)等細節,只須要設置好基本的堆大小(-Xmx 設置最大堆),以及最大垃圾收集時間與吞吐量大小,虛擬機就會根據當前系統運行狀況收集監控信息,動態調整這些參數以儘量地達到咱們設定的最大垃圾收集時間或吞吐量大小這兩個指標。自適應策略也是 Parallel Scavenge 與 ParNew 的重要區別!
上文咱們知道, Serial 收集器是工做於新生代的單線程收集器,與之相對地,Serial Old 是工做於老年代的單線程收集器,此收集器的主要意義在於給 Client 模式下的虛擬機使用,若是在 Server 模式下,則它還有兩大用途:一種是在 JDK 1.5 及以前的版本中與 Parallel Scavenge 配合使用,另外一種是做爲 CMS 收集器的後備預案,在併發收集發生 Concurrent Mode Failure 時使用(後文講述),它與 Serial 收集器配合使用示意圖以下
Parallel Old 是相對於 Parallel Scavenge 收集器的老年代版本,使用多線程和標記整理法,二者組合示意圖以下,這二者的組合因爲都是多線程收集器,真正實現了「吞吐量優先」的目標
CMS 收集器是以實現最短 STW 時間爲目標的收集器,若是應用很重視服務的響應速度,但願給用戶最好的體驗,則 CMS 收集器是個很不錯的選擇!
咱們以前說老年代主要用標記整理法,而 CMS 雖然工做於老年代,但採用的是標記清除法,主要有如下四個步驟
從圖中能夠的看到初始標記和從新標記兩個階段會發生 STW,形成用戶線程掛起,不過初始標記僅標記 GC Roots 能關聯的對象,速度很快,併發標記是進行 GC Roots Tracing 的過程,從新標記是爲了修正併發標記期間因用戶線程繼續運行而致使標記產生變更的那一部分對象的標記記錄,這一階段停頓時間通常比初始標記階段稍長,但遠比並發標記時間短。
整個過程當中耗時最長的是併發標記和標記清理,不過這兩個階段用戶線程均可工做,因此不影響應用的正常使用,因此整體上看,能夠認爲 CMS 收集器的內存回收過程是與用戶線程一塊兒併發執行的。
可是 CMS 收集器遠達不到完美的程度,主要有如下三個缺點
G1 收集器是面向服務端的垃圾收集器,被稱爲駕馭一切的垃圾回收器,主要有如下幾個特色
與 CMS 相比,它在如下兩個方面表現更出現
爲何G1能創建可預測的停頓模型呢,主要緣由在於 G1 對堆空間的分配與傳統的垃圾收集器不一器,傳統的內存分配就像咱們前文所述,是連續的,分紅新生代,老年代,新生代又分 Eden,S0,S1,以下
而 G1 各代的存儲地址不是連續的,每一代都使用了n個不連續的大小相同的Region,每一個Region佔有一塊連續的虛擬內存地址,如圖示
除了和傳統的新老生代,倖存區的空間區別,Region還多了一個H,它表明Humongous,這表示這些Region存儲的是巨大對象(humongous object,H-obj),即大小大於等於region一半的對象,這樣超大對象就直接分配到了老年代,防止了反覆拷貝移動。那麼 G1 分配成這樣有啥好處呢?
傳統的收集器若是發生 Full GC 是對整個堆進行全區域的垃圾收集,而分配成各個 Region 的話,方便 G1 跟蹤各個 Region 裏垃圾堆積的價值大小(回收所得到的空間大小及回收所需經驗值),這樣根據價值大小維護一個優先列表,根據容許的收集時間,優先收集回收價值最大的 Region,也就避免了整個老年代的回收,也就減小了 STW 形成的停頓時間。同時因爲只收集部分 Region,可就作到了 STW 時間的可控。
G1 收集器的工做步驟以下
能夠看到總體過程與 CMS 收集器很是相似,篩選階段會根據各個 Region 的回收價值和成本進行排序,根據用戶指望的 GC 停頓時間來制定回收計劃。
本文簡述了垃圾回收的原理與垃圾收集器的種類,相信你們對開頭提的一些問題應該有了更深入的認識,在生產環境中咱們要根據不一樣的場景來選擇垃圾收集器組合,若是是運行在桌面環境處於 Client 模式的,則用 Serial + Serial Old 收集器綽綽有餘,若是須要響應時間快,用戶體驗好的,則用 ParNew + CMS 的搭配模式,即便是號稱是「駕馭一切」的 G1,也須要根據吞吐量等要求適當調整相應的 JVM 參數,沒有最牛的技術,只有最合適的使用場景,切記!
理論有了,下一篇咱們會進入手動操做環節,咱們會一塊兒來動手操做一些 demo,作一些實驗,來驗證咱們看到的一些現象,好比對象通常分配在新生代,什麼狀況下會直接到老年代呢,發生了OOM,該用哪些工具調試呢?等等,敬請期待!
參考
堆外內存的回收機制分析 https://www.jianshu.com/p/35c...
java調用本地方法--jni簡介 https://blog.csdn.net/w1992wi...
我們從頭至尾說一次 Java 垃圾回收 https://mp.weixin.qq.com/s/pR...
深刻理解 Java 虛擬機
Java Hotspot G1 GC的一些關鍵技術 https://tech.meituan.com/2016...
更多算法 + 計算機基礎知識 + Java 等文章,歡迎關注個人微信公衆號哦。