91超碰碰碰碰久久久久久综合_超碰av人澡人澡人澡人澡人掠_国产黄大片在线观看画质优化_txt小说免费全本

溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點擊 登錄注冊 即表示同意《億速云用戶服務條款》

如何正確理解GC

發布時間:2021-10-19 15:36:38 來源:億速云 閱讀:349 作者:iii 欄目:編程語言

這篇文章主要介紹“如何正確理解GC”,在日常操作中,相信很多人在如何正確理解GC問題上存在疑惑,小編查閱了各式資料,整理出簡單好用的操作方法,希望對大家解答”如何正確理解GC”的疑惑有所幫助!接下來,請跟著小編一起來學習吧!

1. JVM運行時數據區

在聊GC前,有必要先了解一下JVM的內存模型,知道JVM是如何規劃內存的,以及GC的主要作用區域。 如何正確理解GC 如圖所示,JVM運行時會將內存劃分為五大塊區域,其中「方法區」和「堆」隨著JVM的啟動而創建,是所有線程共享的內存區域。虛擬機棧、本地方法棧、程序計數器則是隨著線程的創建被創建,線程運行結束后也就被銷毀了。

1.1 程序計數器

程序計數器(Program Counter Register)是一塊非常小的內存空間,幾乎可以忽略不計。 它可以看作是線程所執行字節碼的行號指數器,指向當前線程下一條應該執行的指令。對于:條件分支、循環、跳轉、異常等基礎功能都依賴于程序計數器。

對于CPU的一個核心來說,任意時刻只能跑一個線程。如果線程的CPU時間片用完就會被掛起,等待OS重新分配時間片再繼續執行,那線程如何知道上次執行到哪里了呢?就是通過程序計數器來實現的,每個線程都需要維護一個私有的程序計數器。

如果線程在執行Java方法,計數器記錄的是JVM字節碼指令地址。如果執行的是Native方法,計數器值則為Undefined

程序計數器是唯一一個沒有規定任何OutOfMemoryError情況的內存區域,意味著在該區域不可能發生OOM異常,GC不會對該區域進行回收!

1.2 虛擬機棧

虛擬機棧(Java Virtual Machine Stacks)也是線程私有的,生命周期和線程相同。

虛擬機棧描述的是Java方法執行的內存模型,JVM要執行一個方法時,首先會創建一個棧幀(Stack Frame)用于存放:局部變量表、操作數棧、動態鏈接、方法出口等信息。棧幀創建完畢后開始入棧執行,方法執行結束后即出棧。

方法執行的過程就是一個個棧幀從入棧到出棧的過程。

局部變量表主要用來存放編譯器可知的各種基本數據類型、對象引用、returnAddress類型。局部變量表所需的內存空間在編譯時就已經確認,運行期間不會修改局部變量表的大小。

在JVM規范中,虛擬機棧規定了兩種異常:

  • StackOverflowError 線程請求的棧深度大于JVM所允許的棧深度。 棧的容量是有限的,如果線程入棧的棧幀超過了限制就會拋出StackOverflowError異常,例如:方法遞歸。

  • OutOfMemoryError 虛擬機棧是可以動態擴展的,如果擴展時無法申請到足夠的內存,則會拋出OOM異常。

1.3. 本地方法棧

本地方法棧(Native Method Stack)也是線程私有的,與虛擬機棧的作用非常類似。 區別是虛擬機棧是為執行Java方法服務的,而本地方法棧是為執行Native方法服務的。

與虛擬機棧一樣,JVM規范中對本地方法棧也規定了StackOverflowError和OutOfMemoryError兩種異常。

1.4. Java堆

Java堆(Java Heap)是線程共享的,一般來說也是JVM管理最大的一塊內存區域,同時也是垃圾收集器GC的主要管理區域。

Java堆在JVM啟動時創建,作用是:存放對象實例。 幾乎所有的對象都在堆中創建,但是隨著JIT編譯器的發展和逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術使得“所有對象都分配在堆上”不那么絕對了。

由于是GC主要管理的區域,所以也被稱為:GC堆。 為了GC的高效回收,Java堆內部又做了如下劃分: 如何正確理解GC

JVM規范中,堆在物理上可以是不連續的,只要邏輯上連續即可。通過-Xms -Xmx參數可以設置最小、最大堆內存。

1.5. 方法區

方法區(Method Area)與Java堆一樣,也是線程共享的一塊內存區域。 它主要用來存儲:被JVM加載的類信息,常量,靜態變量,即時編譯器產生的代碼等數據。 也被稱為:非堆(Non-Heap),目的是與Java堆區分開來。

JVM規范對方法區的限制比較寬松,JVM甚至可以不對方法區進行垃圾回收。這就導致在老版本的JDK中,方法區也別稱為:永久代(PermGen)。

使用永久代來實現方法區不是個好主意,容易導致內存溢出,于是從JDK7開始有了“去永久代”行動,將原本放在永久代中的字符串常量池移出。到JDK8中,正式去除永久代,迎來元空間。


2. GC概述

垃圾收集(Garbage Collection)簡稱為「GC」,它的歷史遠比Java語言本身久遠,在1960年誕生于麻省理工學院的Lisp是第一門開始使用內存動態分配和垃圾收集技術的語言。

要想實現自動垃圾回收,首先需要思考三件事情: 如何正確理解GC 前面介紹了JVM的五大內存區域,程序計數器占用內存極少,幾乎可以忽略不計,而且永遠不會內存溢出,GC不需要對其進行回收。虛擬機棧、本地方法棧隨線程“同生共死”,棧中的棧幀隨著方法的運行有條不紊的入棧、出棧,每個棧幀分配多少內存在編譯期就已經基本確定,因此這兩塊區域內存的分配和回收都具備確定性,不太需要考慮如何回收的問題。

方法區就不一樣了,一個接口到底有多少個實現類?每個類占用的內存是多少?你甚至可以在運行時動態的創建類,因此GC需要針對方法區進行回收。

Java堆也是如此,堆中存放著幾乎所有的Java對象實例,一個類到底會創建多少個對象實例,只有在程序運行時才知道,這部分內存的分配和回收是動態的,GC需要重點關注。

2.1 哪些對象需要回收

實現自動垃圾回收的第一步,就是判斷到底哪些對象是可以被回收的。一般來說有兩種方式:引用計數算法和可達性分析算法,商用JVM幾乎采用的都是后者。

2.1.1 引用計數算法

在對象中添加一個引用計數器,每引用一次計數器就加1,每取消一次引用計數器就減1,當計數器為0時表示對象不再被引用,此時就可以將對象回收了。

引用計數算法(Reference Counting)雖然占用了一些額外的內存空間,但是它原理簡單,也很高效,在大多數情況下是一個不錯的實現方案,但是它存在一個嚴重的弊端:無法解決循環引用

例如一個鏈表,按理只要沒有引用指向鏈表,鏈表就應該被回收,但是很遺憾,由于鏈表中所有的元素引用計數器都不為0,因此無法被回收,造成內存泄漏。

2.1.2 可達性分析算法

目前主流的商用JVM都是通過可達性分析來判斷對象是否可以被回收的。 如何正確理解GC 這個算法的基本思路是:

通過一系列被稱為「GC Roots」的根對象作為起始節點集,從這些節點開始,通過引用關系向下搜尋,搜尋走過的路徑稱為「引用鏈」,如果某個對象到GC Roots沒有任何引用鏈相連,就說明該對象不可達,即可以被回收。

對象可達指的就是:雙方存在直接或間接的引用關系。 根可達或GC Roots可達就是指:對象到GC Roots存在直接或間接的引用關系。

可以作為GC Roots的對象有以下幾類: 如何正確理解GC 可達性分析就是JVM首先枚舉根節點,找到一些為了保證程序能正常運行所必須要存活的對象,然后以這些對象為根,根據引用關系開始向下搜尋,存在直接或間接引用鏈的對象就存活,不存在引用鏈的對象就回收。

關于可達性分析的詳細描述,可以看筆者的文章:《大白話理解可達性分析算法》。

2.2 何時回收

JVM將內存劃分為五大塊區域,不同的GC會針對不同的區域進行垃圾回收,GC類型一般有以下幾大類:

  • Minor GC 也被稱為“Young GC”、“輕GC”,只針對新生代進行的垃圾回收。

  • Major GC 也被稱為“Old GC”,只針對老年代進行的垃圾回收。

  • Mixed GC 混合GC,針對新生代和部分老年代進行垃圾回收,部分垃圾收集器才支持。

  • Full GC 整堆GC、重GC,針對整個Java堆和方法區進行的垃圾回收,耗時最久的GC。

什么時候觸發GC,以及觸發什么類型的GC呢?不同的垃圾收集器實現不一樣,你還可以通過設置參數來影響JVM的決策。

一般來說,新生代會在Eden區用盡后才會觸發GC,而Old區卻不能這樣,因為有的并發收集器在清理過程中,用戶線程可以繼續運行,這意味著程序仍然在創建對象、分配內存,這就需要老年代進行「空間分配擔保」,新生代放不下的對象會被放入老年代,如果老年代的回收速度比對象的創建速度慢,就會導致「分配擔保失敗」,這時JVM不得不觸發Full GC,以此來獲取更多的可用內存。

2.3 如何回收

定位到需要回收的對象以后,就要開始進行回收了。如何回收對象又成了一個問題。 什么樣的回收方式會更加的高效呢?回收后是否需要對內存進行壓縮整理,避免碎片化呢?針對這些問題,GC的回收算法大致分為以下三類:

  1. 標記-清除算法

  2. 標記-復制算法

  3. 標記-整理算法

具體算法的回收細節,下面會介紹到。


3. GC回收算法

JVM將堆劃分成不同的代,不同的代中存放的對象特點不一樣,針對不同的代使用不同的GC回收算法進行回收可以提升GC的效率。

3.1 分代收集理論

目前大多數JVM的垃圾收集器都遵循“分代收集”理論,分代收集理論建立在三個假說之上。

3.1.1 弱分代假說

絕大多數對象都是朝生夕死的。

想想看我們寫的程序是不是這樣,絕大多數時候,我們創建一個對象,只是為了進行一些業務計算,得到計算結果后這個對象也就沒什么用了,即可以被回收了。 再例如:客戶端要求返回一個列表數據,服務端從數據庫查詢后轉換成JSON響應給前端后,這個列表的數據就可以被回收了。 諸如此類,都可以被稱為「朝生夕死」的對象。

3.1.2 強分代假說

熬過越多次GC的對象就越難以回收。

這個假說完全是基于概率學統計來的,經歷過多次GC都無法被回收的對象,可以假定它下次GC時仍然無法被回收,因此就沒必要高頻率的對其進行回收,將其挪到老年代,減少回收的頻率,讓GC去回收效益更高的新生代。

3.1.3 跨代引用假說

跨代引用相對于同代引用是極少的。

這是根據前兩條假說邏輯推理得出的隱含推論:存在互相引用關系的兩個對象,應該傾向于同時生存或者同時消亡的。 舉個例子,如果某個新生代對象存在跨代引用,由于老年代對象難以消亡,該引用會使得新生代對象在收集時同樣得以存活,進而在年齡增長之后晉升到老年代中,這時跨代引用也隨即被消除了。

3.2 解決跨代引用

跨代引用雖然極少,但是它還是可能存在的。如果為了極少的跨代引用而去掃描整個老年代,那每次GC的開銷就太大了,GC的暫停時間會變得難以接受。如果忽略跨代引用,會導致新生代的對象被錯誤的回收,導致程序錯誤。

3.2.1 Remembered Set

JVM是通過記憶集(Remembered Set)來解決的,通過在新生代建立記憶集的數據結構,來避免回收新生代時把整個老年代也加進GC Roots的掃描范圍,減少GC的開銷。

記憶集是一種由「非收集區域」指向「收集區域」的指針集合的抽象數據結構,說白了就是把「年輕代中被老年代引用的對象」給標記起來。記憶集可以有以下三種記錄精度:

  1. 字長精度:記錄精確到一個機器字長,也就是處理器的尋址位數。

  2. 對象精度:精確到對象,對象的字段是否存在跨代引用指針。

  3. 卡精度:精確到一塊內存區域,該區域內的對象是否存在跨代引用。

字長精度和對象精度太精細化了,需要花費大量的內存來維護記憶集,因此許多JVM都是采用的「卡精度」,也被稱作:“卡表”(Card Table)。卡表是記憶集的一種實現,也是目前最常用的一種形式,它定義了記憶集的記錄精度、與對內存的映射關系等。

HotSpot使用一個字節數組來實現卡表,它將堆空間劃分成一系列2次冪大小的內存區域,這個內存區域就被稱作「卡頁」(Card Page),卡頁的大小一般都是2的冪次方數,HotSpot采用2的9次冪,即512字節。字節數組的每一個元素都對應著一個卡頁,如果某個卡頁內的對象存在跨代引用,JVM就會將這個卡頁標記為「Dirty」臟的,GC時只需要掃描臟頁對應的內存區域即可,避免掃描整個堆。

卡表的結構如下圖所示: 如何正確理解GC

3.2.2 寫屏障

卡表只是用來標記哪一塊內存區域存在跨代引用的數據結構,JVM如何來維護卡表呢?什么時候將卡頁變臟呢?

HotSpot是通過「寫屏障」(Write Barrier)來維護卡表的,JVM攔截了「對象屬性賦值」這個動作,類似于AOP的切面編程,JVM可以在對象屬性賦值前后介入處理,賦值前的處理叫作「寫前屏障」,賦值后的處理叫作「寫后屏障」,偽代碼如下:

void setField(Object o){
	before();//寫前屏障
	this.field = o;
	after();//寫后屏障
}

開啟寫屏障后,JVM會為所有的賦值操作生成相應的指令,一旦出現老年代對象的引用指向了年輕代的對象,HotSpot就會將對應的卡表元素置為臟的。

請將這里的「寫屏障」和并發編程中內存指令重排序的「寫屏障」區分開,避免混淆。

除了寫屏障本身的開銷外,卡表在高并發場景下還面臨著「偽共享」的問題,現代CPU的緩存系統是以「緩存行」(Cache Line)為單位存儲的,Intel的CPU緩存行的大小一般是64字節,多線程修改互相獨立的變量時,如果這些變量在同一個緩存行中,就會導致彼此的緩存行無故失效,線程不得不頻繁發起load指令重新加載數據,而導致性能降低。

一個Cache Line是64字節,每個卡頁是512字節,64??512字節就是32KB,如果不同的線程更新的對象處在這32KB之內,就會導致更新卡表時正好寫入同一個緩存行而影響性能。為了避免這個問題,HotSpot支持只有當元素未被標記時,才將其置為臟的,這樣會增加一次判斷,但是可以避免偽共享的問題,設置-XX:+UseCondCardMark來開啟這個判斷。

3.3 標記清除

標記清除算法分為兩個過程:標記、清除。

收集器首先標記需要被回收的對象,標記完成后統一清除。也可以標記存活對象,然后統一清除沒有被標記的對象,這取決于內存中存活對象和死亡對象的占比。

缺點:

  1. 執行效率不穩定 標記和清除的時間消耗隨著Java堆中的對象不斷增加而增加。

  2. 內存碎片 標記清除后內存會產生大量不連續的空間碎片,不利于后續繼續為新生對象分配內存。

如何正確理解GC

3.4 標記復制

為了解決標記清除算法產生的內存碎片問題,標記復制算法進行了改進。

標記復制算法會將內存劃分為兩塊區域,每次只使用其中一塊,垃圾回收時首先進行標記,標記完成后將存活的對象復制到另一塊區域,然后將當前區域全部清理。

缺點是:如果大量對象無法被回收,會產生大量的內存復制開銷。可用內存縮小為一半,內存浪費也比較大。 如何正確理解GC 由于絕大多數對象都會在第一次GC時被回收,需要被復制的往往是極少數對象,那么就完全沒必要按照1:1去劃分空間。 HotSpot虛擬機默認Eden區和Survivor區的大小比例是8:1,即Eden區80%,From Survivor區10%,To Survivor區10%,整個新生代可用內存為Eden區+一個Survivor區即90%,另一個Survivor區10%用于分區復制。

如果Minor GC后仍存活大量對象,超出了一個Survivor區的范圍,那么就會進行分配擔保(Handle Promotion),將對象直接分配進老年代。

3.5 標記整理

標記復制算法除了在對象大量存活時需要進行較多的復制操作外,還需要額外的內存空間老年代來進行分配擔保,所以在老年代中一般不采用這種回收算法。

能夠在老年代中存活的對象,一般都是歷經多次GC后仍無法被回收的對象,基于“強分代假說”,老年代中的對象一般很難被回收。針對老年代對象的生存特征,引入了標記整理算法。

標記整理算法的標記過程與標記清除算法一致,但是標記整理算法不會像標記清除算法一樣直接清理標記的對象,而是將存活的對象都向內存區域的一端移動,然后直接清理掉邊界外的內存空間。 如何正確理解GC 標記整理算法相較于標記清除算法,最大的區別是:需要移動存活的對象。 GC時移動存活的對象既有優點,也有缺點。

缺點 基于“強分代假說”,大部分情況下老年代GC后會存活大量對象,移動這些對象需要更新所有reference引用地址,這是一項開銷極大的操作,而且該操作需要暫停所有用戶線程,即程序此時會阻塞停頓,JVM稱這種停頓為:Stop The World(STW)。

優點 移動對象對內存空間進行整理后,不會產生大量不連續的內存碎片,利于后續為對象分配內存。

由此可見,不管是否移動對象都有利弊。移動則內存回收時負責、內存分配時簡單,不移動則內存回收時簡單、內存分配時復雜。從整個程序的吞吐量來考慮,移動對象顯然更劃算一些,因為內存分配的頻率比內存回收的頻率要高的多的多。

還有一種解決方式是:平時不移動對象,采用標記清除算法,當內存碎片影響到大對象分配時,才啟用標記整理算法。


4. 垃圾收集器

按照《Java虛擬機規范》實現的JVM就不勝枚舉,且每個JVM平臺都有N個垃圾收集器供用戶選擇,這些不是一篇文章可以說的清楚的。當然,開發者也沒必要了解所有的垃圾收集器,以Hotspot JVM為例,主流的垃圾收集器主要有以下幾大類: 如何正確理解GC 串行:單線程收集,用戶線程暫停。 并行:多線程收集,用戶線程暫停。 并發:用戶線程和GC線程同時運行。

前面已經說過,大多數JVM的垃圾收集器都遵循“分代收集”理論,不同的垃圾收集器回收的內存區域會有所不同,大多數情況下,JVM需要兩個垃圾收集器配合使用,下圖有虛線連接的代表兩個收集器可以配合使用。 如何正確理解GC

4.1 新生代收集器

4.1.1 Serial

最基礎,最早的垃圾收集器,采用標記復制算法,僅開啟一個線程完成垃圾回收,回收時會暫停所有用戶線程(STW)。 如何正確理解GC 使用-XX:+UseSerialGC參數開啟Serial收集器,由于是單線程回收,因此Serial的應用范圍很受限制:

  1. 應用程序很輕量,堆空間不到百MB。

  2. 服務器CPU資源緊張。

4.1.2 Parallel Scavenge

使用標記復制算法,多線程的新生代收集器。 如何正確理解GC 使用參數-XX:+UseParallelGC開啟,ParallelGC的特點是非常關注系統的吞吐量,它提供了兩個參數來由用戶控制系統的吞吐量: -XX:MaxGCPauseMillis:設置垃圾回收最大的停頓時間,它必須是一個大于0的整數,ParallelGC會朝著這個目標去努力,如果這個值設置的過小,ParallelGC就不一定能保證了。如果用戶希望GC停頓的時間很短,ParallelGC就會嘗試減小堆空間,因為回收一個較小的堆肯定比回收一個較大的堆耗時短嘛,但是這樣會更頻繁的觸發GC,從而降低系統的吞吐量。

-XX:GCTimeRatio:設置吞吐量的大小,它的值是一個0~100的整數。假設GCTimeRatio為n,那么ParallelGC將花費不超過1/(1+n)的時間進行垃圾回收,默認值為19,意味著ParallelGC用于垃圾回收的時間不會超過5%。

ParallelGC是JDK8的默認垃圾收集器,它是一款吞吐量優先的垃圾收集器,用戶可以通過-XX:MaxGCPauseMillis-XX:GCTimeRatio來設置GC最大的停頓時間和吞吐量。但這兩個參數是互相矛盾的,更小的停頓時間就意味著GC需要更頻繁進行回收,從而增加GC回收的整體時間,導致吞吐量下降。

4.1.3 ParNew

ParNew也是一個使用標記復制算法,多線程的新生代垃圾收集器。它的回收策略、算法、及參數都和Serial一樣,只是簡單的將單線程改為多線程而已,它的誕生只是為了配合CMS收集器使用而存在的。CMS是老年代的收集器,但是Parallel Scavenge不能配合CMS一起工作,Serial是串行回收的,效率又太低了,因此ParNew就誕生了。

使用參數-XX:+UseParNewGC開啟,不過這個參數已經在JDK9之后的版本中刪除了,因為JDK9默認G1收集器,CMS已經被取代,而ParNew就是為了配合CMS而誕生的,CMS廢棄了,ParNew也就沒有存在價值了。

4.2 老年代收集器

4.2.1 Serial Old

使用標記整理算法,和Serial一樣,單線程獨占式的針對老年代的垃圾收集器。老年代的空間通常比新生代要大,而且標記整理算法在回收過程中需要移動對象來避免內存碎片化,因此老年代的回收要比新生代更耗時一些。

Serial Old作為最早的老年代垃圾收集器,還有一個優勢,就是它可以和絕大多數新生代垃圾收集器配合使用,同時它還可以作為CMS并發失敗的備用收集器。

使用參數-XX:+UseSerialGC開啟,新生代老年代都將使用串行收集器。和Serial一樣,除非你的應用非常輕量,或者CPU的資源十分緊張,否則都不建議使用該收集器。

4.2.2 Parallel Old

ParallelOldGC是一款針對老年代,多線程并行的獨占式垃圾收集器,和Parallel Scavenge一樣,屬于吞吐量優先的收集器,Parallel Old的誕生就是為了配合Parallel Scavenge使用的。

ParallelOldGC使用的是標記整理算法,使用參數-XX:+UseParallelOldGC開啟,參數-XX:ParallelGCThreads=n可以設置垃圾收集時開啟的線程數量,同時它也是JDK8默認的老年代收集器。

4.2.3 CMS

CMS(Concurrent Mark Sweep)是一款里程碑式的垃圾收集器,為什么這么說呢?因為在它之前,GC線程和用戶線程是無法同時工作的,即使是Parallel Scavenge,也不過是GC時開啟多個線程并行回收而已,GC的整個過程依然要暫停用戶線程,即Stop The World。這帶來的后果就是Java程序運行一段時間就會卡頓一會,降低應用的響應速度,這對于運行在服務端的程序是不能被接收的。

GC時為什么要暫停用戶線程? 首先,如果不暫停用戶線程,就意味著期間會不斷有垃圾產生,永遠也清理不干凈。 其次,用戶線程的運行必然會導致對象的引用關系發生改變,這就會導致兩種情況:漏標和錯標。

  1. 漏標 原本不是垃圾,但是GC的過程中,用戶線程將其引用關系修改,導致GC Roots不可達,成為了垃圾。這種情況還好一點,無非就是產生了一些浮動垃圾,下次GC再清理就好了。

  2. 錯標 原本是垃圾,但是GC的過程中,用戶線程將引用重新指向了它,這時如果GC一旦將其回收,將會導致程序運行錯誤。

為了實現并發收集,CMS的實現比前面介紹的幾種垃圾收集器都要復雜的多,整個GC過程可以大概分為以下四個階段: 如何正確理解GC 1、初始標記 初始標記僅僅只是標記一下GC Roots能直接關聯到的對象,速度很快。初始標記的過程是需要觸發STW的,不過這個過程非常快,而且初試標記的耗時不會因為堆空間的變大而變慢,是可控的,因此可以忽略這個過程導致的短暫停頓。

2、并發標記 并發標記就是將初始標記的對象進行深度遍歷,以這些對象為根,遍歷整個對象圖,這個過程耗時較長,而且標記的時間會隨著堆空間的變大而變長。不過好在這個過程是不會觸發STW的,用戶線程仍然可以工作,程序依然可以響應,只是程序的性能會受到一點影響。因為GC線程會占用一定的CPU和系統資源,對處理器比較敏感。CMS默認開啟的GC線程數是:(CPU核心數+3)/4,當CPU核心數超過4個時,GC線程會占用不到25%的CPU資源,如果CPU數不足4個,GC線程對程序的影響就會非常大,導致程序的性能大幅降低。

3、重新標記 由于并發標記時,用戶線程仍在運行,這意味著并發標記期間,用戶線程有可能改變了對象間的引用關系,可能會發生兩種情況:一種是原本不能被回收的對象,現在可以被回收了,另一種是原本可以被回收的對象,現在不能被回收了。針對這兩種情況,CMS需要暫停用戶線程,進行一次重新標記。

4、并發清理 重新標記完成后,就可以并發清理了。這個過程耗時也比較長,且清理的開銷會隨著堆空間的變大而變大。不過好在這個過程也是不需要STW的,用戶線程依然可以正常運行,程序不會卡頓,不過和并發標記一樣,清理時GC線程依然要占用一定的CPU和系統資源,會導致程序的性能降低。

CMS開辟了并發收集的先河,讓用戶線程和GC線程同時工作成為了可能,但是缺點也很明顯: 1、對處理器敏感 并發標記、并發清理階段,雖然CMS不會觸發STW,但是標記和清理需要GC線程介入處理,GC線程會占用一定的CPU資源,進而導致程序的性能下降,程序響應速度變慢。CPU核心數多的話還稍微好一點,CPU資源緊張的情況下,GC線程對程序的性能影響非常大。

2、浮動垃圾 并發清理階段,由于用戶線程仍在運行,在此期間用戶線程制造的垃圾就被稱為“浮動垃圾”,浮動垃圾本次GC無法清理,只能留到下次GC時再清理。

3、并發失敗 由于浮動垃圾的存在,因此CMS必須預留一部分空間來裝載這些新產生的垃圾。CMS不能像Serial Old收集器那樣,等到Old區填滿了再來清理。在JDK5時,CMS會在老年代使用了68%的空間時激活,預留了32%的空間來裝載浮動垃圾,這是一個比較偏保守的配置。如果實際引用中,老年代增長的不是太快,可以通過-XX:CMSInitiatingOccupancyFraction參數適當調高這個值。到了JDK6,觸發的閾值就被提升至92%,只預留了8%的空間來裝載浮動垃圾。 如果CMS預留的內存無法容納浮動垃圾,那么就會導致「并發失敗」,這時JVM不得不觸發預備方案,啟用Serial Old收集器來回收Old區,這時停頓時間就變得更長了。

4、內存碎片 由于CMS采用的是「標記清除」算法,這就意味這清理完成后會在堆中產生大量的內存碎片。內存碎片過多會帶來很多麻煩,其一就是很難為大對象分配內存。導致的后果就是:堆空間明明還有很多,但就是找不到一塊連續的內存區域為大對象分配內存,而不得不觸發一次Full GC,這樣GC的停頓時間又會變得更長。 針對這種情況,CMS提供了一種備選方案,通過-XX:CMSFullGCsBeforeCompaction參數設置,當CMS由于內存碎片導致觸發了N次Full GC后,下次進入Full GC前先整理內存碎片,不過這個參數在JDK9被棄用了。

4.2.3.1 三色標記算法

介紹完CMS垃圾收集器后,我們有必要了解一下,為什么CMS的GC線程可以和用戶線程一起工作。

JVM判斷對象是否可以被回收,絕大多數采用的都是「可達性分析」算法,關于這個算法,可以查看筆者以前的文章:大白話理解可達性分析算法。

從GC Roots開始遍歷,可達的就是存活,不可達的就回收。

CMS將對象標記為三種顏色: 如何正確理解GC 標記的過程大致如下:

  1. 剛開始,所有的對象都是白色,沒有被訪問。

  2. 將GC Roots直接關聯的對象置為灰色。

  3. 遍歷灰色對象的所有引用,灰色對象本身置為黑色,引用置為灰色。

  4. 重復步驟3,直到沒有灰色對象為止。

  5. 結束時,黑色對象存活,白色對象回收。

這個過程正確執行的前提是沒有其他線程改變對象間的引用關系,然而,并發標記的過程中,用戶線程仍在運行,因此就會產生漏標和錯標的情況。

漏標 假設GC已經在遍歷對象B了,而此時用戶線程執行了A.B=null的操作,切斷了A到B的引用。 如何正確理解GC 本來執行了A.B=null之后,B、D、E都可以被回收了,但是由于B已經變為灰色,它仍會被當做存活對象,繼續遍歷下去。 最終的結果就是本輪GC不會回收B、D、E,留到下次GC時回收,也算是浮動垃圾的一部分。

實際上,這個問題依然可以通過「寫屏障」來解決,只要在A寫B的時候加入寫屏障,記錄下B被切斷的記錄,重新標記時可以再把他們標為白色即可。

錯標 假設GC線程已經遍歷到B了,此時用戶線程執行了以下操作:

B.D=null;//B到D的引用被切斷
A.xx=D;//A到D的引用被建立

如何正確理解GC B到D的引用被切斷,且A到D的引用被建立。 此時GC線程繼續工作,由于B不再引用D了,盡管A又引用了D,但是因為A已經標記為黑色,GC不會再遍歷A了,所以D會被標記為白色,最后被當做垃圾回收。 可以看到錯標的結果比漏表嚴重的多,浮動垃圾可以下次GC清理,而把不該回收的對象回收掉,將會造成程序運行錯誤。

錯標只有在滿足下面兩種情況下才會發生:

  1. 灰色指向白色的引用全部斷開。

  2. 黑色指向白色的引用被建立。

只要打破任一條件,就可以解決錯標的問題。

原始快照和增量更新 原始快照打破的是第一個條件:當灰色對象指向白色對象的引用被斷開時,就將這條引用關系記錄下來。當掃描結束后,再以這些灰色對象為根,重新掃描一次。相當于無論引用關系是否刪除,都會按照剛開始掃描時那一瞬間的對象圖快照來掃描。

增量更新打破的是第二個條件:當黑色指向白色的引用被建立時,就將這個新的引用關系記錄下來,等掃描結束后,再以這些記錄中的黑色對象為根,重新掃描一次。相當于黑色對象一旦建立了指向白色對象的引用,就會變為灰色對象。

CMS采用的方案就是:寫屏障+增量更新來實現的,打破的是第二個條件。

當黑色指向白色的引用被建立時,通過寫屏障來記錄引用關系,等掃描結束后,再以引用關系里的黑色對象為根重新掃描一次即可。

偽代碼大致如下:

class A{
	private D d;

	public void setD(D d) {
		writeBarrier(d);// 插入一條寫屏障
		this.d = d;
	}

	private void writeBarrier(D d){
		// 將A -> D的引用關系記錄下來,后續重新掃描
	}
}

4.3 混合收集器

4.3.1 G1

G1的全稱是「Garbage First」垃圾優先的收集器,JDK7正式使用,JDK9默認使用,它的出現是為了替代CMS收集器。

既然要替代CMS,那么毫無疑問,G1也是并發并行的垃圾收集器,用戶線程和GC線程可以同時工作,關注的也是應用的響應時間。

G1最大的一個變化就是,它只是邏輯分代,物理結構上已經不分代了。它將整個Java堆劃分成多個大小不等的Region,每個Region可以根據需要扮演Eden區、Survivor區、或者是老年代空間,G1可以對扮演不同角色的Region采用不同的策略去處理。

G1之前的所有垃圾收集器,回收的范圍要么是整個新生代(Minor GC)、要么是整個老年代(Major GC)、再就是整個Java堆(Full GC)。而G1跳出了這個樊籠,它可以面向堆內任何部分來組成回收集(Collection Set,簡稱CSet)進行回收,衡量標準不再是它屬于哪個分代,而是判斷哪個Region垃圾最多,選擇回收價值最高的Region回收,這也是「Garbage First」名稱的由來。

雖然G1仍然保留了分代的概念,但是新生代和老年代不再是固定不變的兩塊連續的內存區域了,它們都是由一系列Region組成的,而且每次GC時,新生代和老年代的空間大小會動態調整。G1之所以能控制GC的停頓時間,建立可預測的停頓時間模型,就是因為它將Region作為單次回收的最小單元,每次回收的內存空間都是Region大小的整數倍,這樣就可以避免在整個Java堆內進行全區域的垃圾收集。

G1會跟蹤每個Region的垃圾數量,計算每個Region的回收價值,在后臺維護一個優先級列表,然后根據用戶設置的允許GC停頓的時間來優先回收“垃圾最多”的Region,這樣就保證了G1能夠在有限的時間內回收盡可能多的可用內存。

G1的整個回收周期大概可以分為以下幾個階段:

  1. Eden區內存耗盡,觸發新生代GC開始回收Eden區和Survivor區。新生代GC后,Eden區會被清空,Survivor區至少會保留一個,其余的對象要么被清理,要么被晉升到老年代。這個過程中,新生代的大小可能會被調整。

  2. 并發標記周期 2.1 初始標記:僅標記GC Roots直接關聯的對象,會伴隨一次新生代GC,且會導致STW。 2.2 根區域掃描:初始標記時觸發的新生代GC會將Eden區清空,存活對象會移動到Survivor區,這時就需要掃描由Survivor區直接可達的老年代區域,并標記這些對象,這個過程可以并發執行。 2.3 并發標記:和CMS類似會掃描并查找整個堆內存活的對象并標記,不會觸發STW。 2.4 重新標記:觸發STW,修正并發標記期間因為用戶線程繼續執行而導致對象間的引用被改變。 2.5 獨占清理:觸發STW,計算各個Region的回收價值,對Region進行排序,識別可供混合回收的區域。 2.6 并發清理:識別并清理完全空閑的Region,不會造成停頓。

  3. 混合回收:并發標記周期中的并發清理階段,G1雖然也回收了部分空間,但是比例還是相當低的。但是在這之后,G1已經明確知道各個Region的回收價值了。在混合回收階段G1會優先回收垃圾最多的Region,這些Region既包含了新生代,也包含了老年代,故稱之為“混合回收”。被清理的Region內的存活對象會被移動到其他Region,這也避免了內存碎片。

和CMS一樣,因為并發回收時用戶線程仍然在運行,即分配內存,因此如果回收速度跟不上內存分配的速度,G1也會在必要的時候觸發一個Full GC來獲取更多的可用內存。

使用參數-XX:+UseG1GC來開啟G1收集器,-XX:MaxGCPauseMillis來設置目標最大停頓時間,G1會朝著這個目標去努力,如果GC停頓時間超過了目標時間,G1就會嘗試調整新生代和老年代的比例、堆大小、晉升年齡等一系列參數來企圖達到預設目標。 -XX:ParallelGCThreads用來設置并行回收時GC的線程數量,-XX:InitiatingHeapOccupancyPercent用來指定整個Java堆的使用率達到多少時觸發并發標記周期的執行,默認值是45。

4.3.2 面向未來的ZGC

ZGC是在JDK11才加入的具有實現性質的低延遲垃圾收集器,它的目標是希望在盡可能對吞吐量影響不大的前提下,實現在任意堆內存大小下都可以把GC的停頓時間控制在十毫秒以內。

ZGC面向的是超大堆,最大支持4TB的堆空間,它和G1一樣,也是采用Region的內存布局形式。

ZGC最大的一個特點就是它采用著色指針Colored Pointer技術來標記對象。以往,如果JVM需要在對象上存儲一些額外的、只供GC或JVM本身使用的數據時(如GC年齡、偏向線程ID、哈希碼),通常會在對象的對象頭上增加額外的字段來記錄。ZGC就厲害了,直接把標記信息記錄在對象的引用指針上。

Colored Pointer是什么?為什么對象引用的指針本身也可以存儲數據呢? 在64位系統中,理論上可以訪問的內存大小為2的64次冪字節,即16EB。但是實際上,目前遠遠用不到這么大的內存,因此基于性能和成本的考慮,CPU和操作系統都會施加自己的約束。例如AMD64架構只支持54位(4PB)的地址總線,Linux只支持46位(64TB)的物理地址總線,Windows只支持44位(16TB)的物理地址總線。

在Linux系統下,高18位不能用來尋址,剩余的46位能支持最大64TB的內存大小。事實上,64TB的內存大小在目前來說也遠遠超出了服務器的需要。于是ZGC就盯上了這剩下的46位指針寬度,將其高4位提取出來存儲四個標志信息。通過這些標志位,JVM可以直接從指針中看到其引用對象的三色標記狀態、是否進入了重分配集(即被移動過)、是否只能通過finalize()方法才能被訪問到。這就導致JVM能利用的物理地址總線只剩下42位了,即ZGC能管理的最大內存空間為2的42次冪字節,即4TB。 如何正確理解GC 

到此,關于“如何正確理解GC”的學習就結束了,希望能夠解決大家的疑惑。理論與實踐的搭配能更好的幫助大家學習,快去試試吧!若想繼續學習更多相關知識,請繼續關注億速云網站,小編會繼續努力為大家帶來更多實用的文章!

向AI問一下細節
推薦閱讀:
  1. php gc
  2. java GC筆記

免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。

AI

子洲县| 泗洪县| 黄梅县| 兴和县| 桑日县| 高唐县| 浙江省| 白沙| 财经| 华阴市| 民县| 墨江| 烟台市| 商洛市| 建湖县| 册亨县| 阳信县| 同江市| 新密市| 通城县| 夏津县| 衡阳县| 泽州县| 隆化县| 兰州市| 景德镇市| 蓬溪县| 河池市| 信宜市| 广宗县| 江油市| 阳春市| 新田县| 昌平区| 大姚县| 蚌埠市| 凌海市| 思茅市| 南城县| 清苑县| 平阳县|