每個java開發同學不管是日常工作中還是面試裡,都會遇到JDK、JVM和GC的問題。本文會從以下10個問題為切入點,帶著大家一起全面瞭解一下JVM的方方面面。
- JVM、JRE和JDK的區別和聯繫
- JVM是什麼?以及它的主要作用
- JVM的核心功能有哪些
- 類加載機制和過程
- 運行時數據區的邏輯結構
- JVM的內存模型
- 如何確定對象是垃圾
- 垃圾收集的算法有哪些
- 各種問世的垃圾收集器
- JVM調優的參數配置
上一篇文章結尾時我們談到,就JVM的設計規範,從使用用途角度JVM的內存大體的分為:線程私有內存區 和 線程共享內存區。
線程私有內存區在類加載器編譯某個class文件時就確定了執行時需要的“程序計數器”和“虛擬棧幀”等所需的空間,並且會伴隨著當前執行線程的產生而產生,執行線程的消亡而消亡,因此“線程私有內存區”並不需要考慮內存管理和垃圾回收的問題。線程共享內存區在虛擬機啟動時創建,被所有線程共享,是Java虛擬機所管理內存中最應該關注的和最大的一塊。首先我們來一起看一下“線程共享內存區”的內存模型是什麼樣的?
6、JVM的內存模型
如圖所示,JVM的內存結構分為堆和非堆兩大塊區域。
- 其中“非堆”就是上篇文章我們提到的方法區或叫元數據區,用來存儲class類信息的。
- 而“堆”是用來存儲JVM各線程執行期間所創建的實例對象或數組的。堆區分為兩大塊,一個是Old區,一個是Young區。Young區分為兩大塊,一個是Survivor區(S0+S1),一塊是Eden區S0和S1一樣大,也可以叫From和To。
之所以這樣劃分,設計者的目的無非就是為了內存管理,也就是我們說的垃圾回收。那麼什麼樣的對象是垃圾?垃圾回收算法有哪些?目前常用的垃圾回收器又有哪些?這篇文章我們一起弄清楚這些問題和知識點。
7、如何確定一個對象是垃圾?
要想進行垃圾回收,得先知道什麼樣的對象是垃圾。目前確認對象是否為垃圾的算法主要有兩種:引用計數法和可達性分析法。
- 1、引用計數法:在對象中添加了一個引用計數器,當有地方引用這個對象時,引用計數器的值就加1,當引用失效的時候,引用計數器的值就減1。當引用計數器的值為0時,JVM就開始回收這個對象。
對於某個對象而言,只要應用程序中持有該對象的引用,就說明該對象不是垃圾,如果一個對象沒有任何指針對其引用,它就是垃圾。這種方法雖然很簡單、高效,但是JVM一般不會選擇這個方法,因為這個方法會出現一個弊端:當對象之間相互指向時,兩個對象的引用計數器的值都會加1,而由於兩個對象時相互指向,所以引用不會失效,這樣JVM就無法回收。
- 2、可達性分析法:針對引用計數算法的弊端,JVM採用了另一種算法,以一些"GC Roots"的對象作為起始點向下搜索,搜索所走過的路徑稱為引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連時,則證明此對象是不可用的,即可以進行垃圾回收。否則,證明這個對象有用,不是垃圾。
上圖中的obj7和obj8雖然它們互相引用,但從GC Roots出發這兩個對象不可達,所以會被標記為垃圾。JVM會把以下幾類對象作為GC Roots:
- (1) 虛擬機棧(棧幀中本地變量表)中引用的對象;
- (2) 方法區中類靜態屬性引用的對象;
- (3) 方法區中常量引用的對象;
- (4) 本地方法棧中JNI(Native方法)引用的對象。
注:在可達性分析算法中不可達的對象,並不是直接被回收,這時它們處於緩刑狀態,至少需要進行兩次標記才會確定該對象是否被回收:
第一次標記:如果對象在進行可達性分析後發現沒有與GC Roots相連接的引用鏈,那它將會被第一次標記;
第二次標記:第一次標記後接著會進行一次篩選,篩選的條件是此對象是否有必要執行finalize()方法(該方法可將此對象與GC Roots建立聯繫)。在finalize()方法中沒有重新與引用鏈建立關聯關係的,將被進行第二次標記。
第二次標記成功的對象將真的會被回收,如果對象在finalize()方法中重新與引用鏈建立了關聯關係,那麼將會逃離本次回收,繼續存活。
8、垃圾收集的算法有哪些
知道了如何JVM確定哪些對象是垃圾後,下面我們來看一下,面對這些垃圾對象,JVM的回收算法都有哪些。
1、 標記-清除算法(Mark-Sweep)
- 第一步“標記”,如下圖所示把堆裡所有的對象都掃描一遍,找出哪些是垃圾需要回收的對象,並且把它們標記出來。
- 第二步“清除”,把第一步標記為“UnReference Object”(無引用或不可達)的對象清除掉,釋放內存空間。
這種算法的缺點主要有兩點:
(1) 標記和清除兩個過程都比較耗時,效率不高
(2) 清除後會產生大量不連續的內存碎片空間,碎片空間太多可能會導致當程序後續需要創建較大對象時,無法找到足夠連續的內存空間而不得不再次觸發垃圾回收。
2、 標記-複製算法(Mark-Copying)
將內存劃分為兩塊區域,每次使用其中一塊,當其中一塊用滿,觸發垃圾回收的時候,將存活的對象複製到另一塊上去,然後把之前使用的那一塊進行格式化,一次性清除乾淨。
(清除前)
(清除後)
“標記-複製”算法的缺點顯而易見,就是內存空間利用率低。
3、 標記-整理算法(Mark-Compact)
標記整理算法標記過程仍然與"標記-清除"算法一樣,但是後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的內存。
將所有存活的對象向一邊移動,清理掉存活邊界以外的全部內存空間。
結合這三種算法我們可以看到,
- “標記-複製”算法的優點是回收效率高,但空間利用率上有一定的浪費。
- 而“標記-整理”算法由於需要向一側移動等一系列操作,其效率相對低一些,但對內存空間管理上十分優異。
- 因此,“標記-複製”算法適用於那些生命週期短、回收頻率高的內存對象,
- 而標記-整理”算法適用於那些生命週期長、回收頻率低,但注重回收一次內存空間得到足夠釋放的場景。
因此JVM的設計者將JVM的堆內存,分為了兩大塊區域Young區和Old區,Young區存儲的就是那些生命週期短,使用一兩次就不再使用的對象,回收一次基本上該區域十之有八的對象全部被回收清理掉,因此Young區採用的垃圾回收算法也就是“標記-複製”算法。Old區存儲的是那些生命週期長,經過多次回收後仍然存活的對象,就把它們放到Old區中,平時不再去判斷這些對象的可達性,直到Old區不夠用為止,再進行一次統一的回收,釋放出足夠的連續的內存空間。
9、各種問世的垃圾收集器
鑑於Young區和Old區需要採用不同的垃圾回收算法,因此在JVM的整個垃圾收集器的演進各個時代裡,針對Young區和Old區每個時代都是不同的垃圾收集機制。從JDK1.3開始到目前,JVM垃圾收集器的演進大體分為四個時代:串行時代、並行時代、併發時代和G1時代。
1、串行時代:Serial(Young區)+ Serial Old(Old區)
JDK3(1.3)的時候,大概是2000年左右,那個時代基本計算機都是單核一個CPU的,因此垃圾回收最初的設計實現也是基於單核單線程工作的。並且垃圾回收線程的執行相對於正常業務線程執行來說還是STW(stop the world)的,使用一個CPU或者一條收集線程去完成垃圾收集工作,這個線程執行的時候其它線程需要停止。
串行收集器採用單線程stop-the-world的方式進行收集。當內存不足時,串行GC設置停頓標識,待所有線程都進入安全點(Safepoint)時,應用線程暫停,串行GC開始工作,採用單線程方式回收空間並整理內存。單線程也意味著複雜度更低、佔用內存更少,但同時也意味著不能有效利用多核優勢。因此,串行收集器特別適合堆內存不高、單核甚至雙核CPU的場合。
2、並行時代:Parallel Scavenge(Young區) + Parallel Old(Old區)
並行收集器是以關注吞吐量為目標的垃圾收集器,也是server模式下的默認收集器配置,對吞吐量的關注主要體現在年輕代Parallel Scavenge收集器上。
並行收集器與串行收集器工作模式相似,都是stop-the-world方式,只是暫停時並行地進行垃圾收集。年輕代採用複製算法,老年代採用標記-整理,在回收的同時還會對內存進行壓縮。關注吞吐量主要指年輕代的Parallel Scavenge收集器,通過兩個目標參數-XX:MaxGCPauseMills和-XX:GCTimeRatio,調整新生代空間大小,來降低GC觸發的頻率。並行收集器適合對吞吐量要求遠遠高於延遲要求的場景,並且在滿足最差延時的情況下,並行收集器將提供最佳的吞吐量。
3、 併發時代:CMS(Old區)
併發標記清除(CMS)是以關注延遲為目標、十分優秀的垃圾回收算法,CMS是針對Old區的垃圾回收實現。
老年代CMS每個收集週期都要經歷:初始標記、併發標記、重新標記、併發清除。其中,初始標記以STW的方式標記所有的根對象;併發標記則同應用線程一起並行,標記出根對象的可達路徑;在進行垃圾回收前,CMS再以一個STW進行重新標記,標記那些由mutator線程(指引起數據變化的線程,即應用線程)修改而可能錯過的可達對象;最後得到的不可達對象將在併發清除階段進行回收。值得注意的是,初始標記和重新標記都已優化為多線程執行。CMS非常適合堆內存大、CPU核數多的服務器端應用,也是G1出現之前大型應用的首選收集器。
- 但CMS有以下兩個缺陷:
- (1)由於它是標記-清除不是標記-整理,因此會產生內存碎片,Old區會隨著時間的推移而終究被耗盡或產生無法分配大對象的情況。最後不得不通過底層的擔保機制(CMS背後有串行的回收作為兜底)進行一次Full GC,並進行內存壓縮。
- (2)由於標記和清除都是通應用線程併發進行,兩類線程同時執行時會增加堆內存的佔用,一旦某一時刻內存不夠用,就會觸發底層擔保機制,又採用串行回收進行一次STW的垃圾回收。
4、G1時代:Garbage First
G1收集器時代,Java堆的內存佈局與就與其他收集器有很大差別,它將整個Java堆劃分為多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,它們都是一部分Region(不需要連續)的集合。
如上圖所示,每一個Region(分區)大小都是一樣的,1~32M之間的數值,但必須是2的指數。設置Region大小通過以下參數:-XX:G1HeapRegionSize=M。
G1收集器的原理或特點主要有以下三點:
(1)內存邏輯上仍保留的分代的概念,每一個Region同一時間要麼被標記為新生代,要麼被標記為老年代,要麼處於空閒;
(2)整體上採用了“標記-整理算法”,不會產生內存碎片
(3)可預測的停頓,G1整體採用的策略是“篩選回收”,也就是回收前會對各個待回收的Region的回收價值和成本進行排序,根據G1配置所期望的回收時間,選擇排在前面的幾個Region進行回收。
其實之所以叫G1(Garbage First)就是因為它優先選擇回收垃圾比較多的Region分區。
整體G1的垃圾回收工作步驟分為:初始標記、併發標記、最終標記和篩選回收。
5、ZGC:Zero GC
這篇文章簡單提一下這個最新問世的垃圾收集器,之所以叫“Zero GC”是因為它追求的是更低的GC停頓時間,追求的目標是:支持TB級堆內存(最大4T)、最大GC停頓10ms。JDK11新引入的ZGC收集器,不管是物理上還是邏輯上,ZGC中已經不存在新老年代的概念了會分為一個個page,當進行GC操作時會對page進行壓縮,因此沒有碎片問題。由於其是JDK11和只能在64位的linux上使用,因此目前用得還比較少。
結語
以上總體兩篇文章七千字,就是我從JVM的作用、設計框架到JVM內存管理的整體的體系化理解。感謝。
拓展閱讀:十個問題弄清JVM&GC(二)
作者:宜信技術學院 譚文濤