JVM垃圾回收概述

垃圾回收概述

什麼是垃圾

什麼是垃圾( Garbage) 呢?

垃圾是指在運行程式中沒有任何指針指向的對象,這個對象就是需要被回收的垃圾。

如果不及時對記憶體中的垃圾進行清理,那麼,這些垃圾對象所佔的記憶體空間會一直保留到應用程式結束,被保留的空間無法被其他對象使用。甚至可能導致記憶體溢出。

為什麼要進行垃圾回收

對於高級語言來說,個基本認知是 如果不進行垃圾回收,記憶體遲早都會被消耗完,因為不斷地分配記憶體空間而不進行回收,就好像不停地生產生活垃圾而從來不打掃樣。

除了釋放沒用的對象,垃圾回收也可以清除記憶體里的記錄碎片。碎片整理將所佔用的堆記憶體移到堆的一端,以便JVM將整理出的記憶體分配給新的對象。

隨著應用程式所應付的業務越來越龐大、複雜,用戶越來越多,沒有GC就不能保證應用程式的正常進行。而經常造成STW的GC又跟不上實際的需求,所以才會不斷地嘗試對GC進行優化。

Java的垃圾回收機制

自動記憶體管理,無需開發人員手動參與記憶體的分配與回收,這樣降低記憶體泄漏和記憶體溢出的風險

沒有垃圾回收器,java也會和cpp- 樣,各種懸垂指針,野指針,泄露問題讓你頭疼不已。

自動記憶體管理機制,將程式設計師從繁重的記憶體管理中釋放出來,可以更專心地專註於業務開發

對於Java開發人員而言,自動記憶體管理就像是一個黑匣子,如果過度依賴於「自動」,那麼這將會是一場災難,最嚴重的就會弱化Java開發人員在程式出現記憶體溢出時定位問題和解決問題的能力。

此時,了 解JVM的自動記憶體分配和記憶體回收原理就顯得非常重要,只有在真正了解JVM是如何管理記憶體後,我們才能夠在遇見OutOfMemoryError時, 快速地根據錯誤異常日誌定位問題和解決問題。

當需要排查各種記憶體溢出、記憶體泄漏問題時,當垃圾收集成為系統達到更高並發量的瓶頸時,我們就必須對這些「自動化」的技術實施必要的監控和調節

垃圾回收器可以對年輕代回收,也可以對老年代回收,甚至是全堆和方法區的回收。

其中,Java堆是垃圾收集器的工作重點。

從次數數上講:

頻繁收集Young區
較少收集01d區
基本不動Perm區(或元空間)

垃圾回收的相關演算法

在堆里存放著幾乎所有的Java對象實例,在GC執行垃圾回收之前,首先需要區分出記憶體中哪些是存活對象,哪些是已經死亡的對象。只有被標記為己經死亡的對象,GC才會在執行垃圾回收時,釋放掉其所佔用的記憶體空間,因此這個過程我們可以稱為垃圾標記階段

那麼在JVM中究竟是如何標記-一個死亡對象呢?簡單來說,當一個對象已經不再被任何的存活對象繼續引用時,就可以宣判為已經死亡。

判斷對象存活一般有兩種方式:引用計數演算法可達性分析演算法

垃圾標記階段的演算法:引用計數演算法

引用計數演算法(Reference Counting) 比較簡單,對每個對象保存一個整型的引用計數器屬性。用於記錄對象被引用的情況。
對於一個對象A,只要有任何一個對象引用了A,則A的引用計數器就加1;當引用失效時,引用計數器就減1。只要對象A的引用計數器的值為0,即表示對象A不可能再被使用,可進行回收。

優點:實現簡單,垃圾對象便於辨識;判定效率高,回收沒有延遲性。

缺點:

它需要單獨的欄位存儲計數器,這樣的做法增加了存儲空間的開銷。
每次賦值都需要更新計數器,伴隨著加法和減法操作,這增加了時間開銷。
引用計數器有一個嚴重的問題,即無法處理循環引用的情況。這是一條致命缺陷,導致在Java的垃圾回收器中沒有使用這類演算法。

引用計數演算法,是很多語言的資源回收選擇,例如因人工智慧而更加火熱的Python,它更是同時支援引用計數和垃圾收集機制。

具體哪種最優是要看場景的,業界有大規模實踐中僅保留引用計數機制,以提高吞吐量的嘗試。

Java並沒有選擇引用計數,是因為其存在一個基本的難題,也就是很難處理循環引用關係。

Python如何解決循環引用?

手動解除:很好理解,就是在合適的時機,解除引用關係。
使用弱引用weakref,weakref是Python提供的標準庫,旨在解決循環引用。

垃圾標記階段的演算法:可達性分析演算法

相對於引用計數演算法而言,可達性分析演算法不僅同樣具備實現簡單和執行高效等特點,更重要的是該演算法可以有效地解決在引用計數演算法中循環引用的問題,防止記憶體泄漏的發生。

相較於引用計數演算法,這裡的可達性分析就是Java、C#選擇的。這種類型的垃圾收集通常也叫作追蹤性垃圾收集(Tracing Garbage Collection)。

所謂”GC Roots” 根集合就是一組必須活躍的引用。

基本思路:

可達性分析演算法是以根對象集合(GC Roots) 為起始點,按照從上至下的方式搜索被根對象集合所連接的目標對象是否可達

使用可達性分析演算法後,記憶體中的存活對象都會被根對象集合直接或間接連接著,搜索所走過的路徑稱為引用鏈(Reference Chain)

如果目標對象沒有任何引用鏈相連,則是不可達的,就意味著該對象己經死亡,可以標記為垃圾對象。

在可達性分析演算法中,只有能夠被根對象集合直接或者間接連接的對象才是存活對象。

GC Roots中一般包含以下幾類元素

虛擬機棧中引用的對象

比如:各個執行緒被調用的方法中使用到的參數、局部變數等。

本地方法棧內JNI (通常說的本地方法)引用的對象

方法區中類靜態屬性引用的對象

比如: Java類的引用類型靜態變數

方法區中常量引用的對象.

比如:字元串常量池(String Table)里的引用

所有被同步鎖synchronized持有的對象

Java虛擬機內部的引用。

基本數據類型對應的Class對象,一些常駐的異常對象(如:NullPointerException、OutOfMemoryError),系統類載入器。

反映java虛擬機內部情況的JMXBean、JVMTI中註冊的回調、本地程式碼快取等。

GC Roots Set

除了這些固定的GC Roots集 合以外,根據用戶所選用的垃圾收集器以及當前回收的記憶體區域不同,還可以有其他對象「臨時性」地加入,共同構成完整GC Roots集合。 比如:分代收集和局部回收(Partial GC)。

如果只針對Java堆中的某一塊區域進行垃圾回收(比如:典型的只針對新生代),必須考慮到記憶體區域是虛擬機自己的實現細節,更不是孤立封閉的,這個區域的對象完全有可能被其他區域的對象所引用,這時候就需要一併將關聯的區域對象也加入GC Roots集 合中去考慮,才能保證可達性分析的準確性。(如:元空間方法內引用新生代方法,這時也必須將元空間的方法加入GC Roots Set)

小技巧:

由於Root採用棧方式存放變數和指針,所以如果一個指針,它保存了堆記憶體裡面的對象,但是自己又不存放在堆記憶體裡面,那它就是一個Root。

注意

如果要使用可達性分析演算法來判斷記憶體是否可回收,那麼分析工作必須在一個能保障一致性的快照中進行。這點不滿足的話分析結果的準確性就無法保證。

這點也是導致GC進行時必須”StopTheWorld”的一個重要原因。

即使是號稱(幾乎)不會發生停頓的CMS收集器中,枚舉根節點時也是必須要停頓的

對象的finalization機制

Java語言提供了對象終止(finalization)機制來允許開發人員提供對象被銷毀之前的自定義處理邏輯。

當垃圾回收器發現沒有引用指向一個對象,即:垃圾回收此對象之前,總會先調用這個對象的finalize()方法。

finalize()方法允許在子類中被重寫,用於在對象被回收時進行資源釋放。

通常在這個方法中進行一些資源釋放和清理的工作,比如關閉文件、套接字和資料庫連接等。

永遠不要主動調用某個對象的finalize()方法,應該交給垃圾回收機制調用。理由包括下面三點:

在finalize() 時可能會導致對象復活。

finalize() 方法的執行時間是沒有保障的,它完全由Gc執行緒決定,極端情況下,若不發生GC,則finalize ()方法將沒有執行機會。

一個糟糕的finalize ()會嚴重影響GC的性能。

從功能上來說,finalize() 方法與C++中的析構函數比較相似,但是Java採用的是基於垃圾回收器的自動記憶體管理機制,所以finalize() 方法在本質上不同於C++中的析構函數。

由於finalize()方法的存在,虛擬機中的對象一般處於三種可能的狀態

生存or死亡?

如果從所有的根節點都無法訪問到某個對象,說明對象己經不再使用了。一般來說,此對象需要被回收。但事實上,也並非是「非死不可」的,這時候它們暫時處於「緩刑」階段。一個無法觸及的對象有可能在某一個條件下「復活」自己,如果這樣,那麼對它的回收就是不合理的,為此,定義虛擬機中的對象可能的三種狀態。如下:

可觸及的:從根節點開始,可以到達這個對象。

可復活的:對象的所有引用都被釋放,但是對象有可能在finalize()中復活。

不可觸及的:對象的finalize()被調用,並且沒有復活,那麼就會進入不可觸及狀態。不可 觸及的對象不可能被複活,因為finalize()只會被調用一次

以上3種狀態中,是由於finalize()方法的存在,進行的區分。只有在對象不可觸及時才可以被回收。

具體過程

判定一個對象objA是否可回收,至少要經歷兩次標記過程: .

1.如果對象objA到GC Roots沒有引用鏈,則進行第一次標記。

2.進行篩選,判斷此對象是否有必要執行finalize()方法

①如果對 象objA沒有重寫finalize()方法,或者finalize ()方法已經被虛擬機調用過,則虛擬機視為「沒有必要執行」,objA被判定為不可觸及的。

②如果對象objA重寫 了finalize()方法,且還未執行過,那麼objA會被插入到r-Queue隊列中,由一個虛擬機自動創建的、低優先順序的Finalizer執行緒觸發其finalize()方法執行。

finalize() 方法是對象逃脫死亡的最後機會,稍後GC會對F-Queue隊列中的對象進行第二次標記。如果objA在finalize()方法中與引用鏈上的任何-一個對象建立了聯繫,那麼在第二次標記時,objA會被移出「即將回收」集合。之後,對象會再次出現沒有引用存在的情況。在這個情況下,finalize方法不 會被再次調用,對象會直接變成不可觸及的狀態,也就是說,一個對象的finalize方法只會被調用一次。

*MAT的簡單使用方法

1.生成dump文件

1.1.命令行生成

jsp->
jmap -dump:format=b,live,file=test1.bin xxxx(進程id)->

1.2.Jvisualvm生成

在監視介面點擊堆(dump),之後滑鼠右鍵另存為…

2.open file

使用MAT打開dump文件,選擇java basics ->CG Roots 查看CG Roots Set

JProFiler簡單用法詳見影片p145-p146

垃圾清除階段演算法:標記清除演算法

當成功區分出記憶體中存活對象和死亡對象後,GC接下來的任務就是執行垃圾回收,釋放掉無用對象所佔用的記憶體空間,以便有足夠的可用記憶體空間為新對象分配記憶體。

目前在JVM中比較常見的三種垃圾收集演算法是標記-清除演算法( Mark-Sweep)、複製演算法(Copying)、標記-壓縮演算法(Mark-Compact )

執行過程:

當堆中的有效記憶體空間(available memory) 被耗盡的時候,就會停止整個程式(也被稱為stop the world),然後進行兩項工作,第一項則是標記,第二項則是清除。

標記: Collector從 引用根節點開始遍歷,標記所有被引用的對象。一般是在對象的Header中記錄為可達對象。

清除: Collector對 堆記憶體從頭到尾進行線性的遍歷,如果發現某個對象在其Header中沒有標記為可達對象,則將其回收。

缺點

效率不算高。

在進行GC的時候,需要停止整個應用程式,導致用戶體驗差。

這種方式清理出來的空閑記憶體是不連續的,產生記憶體碎片。需要維護一個空閑列表。

注意:何為清除?

這裡所謂的清除並不是真的置空,而是把需要清除的對象地址保存在空閑的地址列表裡。下次有新對象需要載入時,判斷垃圾的位置空間是否夠,如果夠,就存放。

垃圾清除階段演算法:複製演算法

核心思想:

活著的記憶體空間分為兩塊,每次只使用其中-一塊,在垃圾回收時將正在使用的記憶體中的存活對象複製到未被使用的記憶體塊中,之後清除正在使用的記憶體塊中的所有對象,交換兩個記憶體的角色,最後完成垃圾回收。`

優點:

沒有標記和清除過程,實現簡單,運行高效複製過去以後保證空間的連續性,不會出現「碎片」問題。

缺點:

此演算法的缺點也是很明顯的,就是需要兩倍的記憶體空間。對於G1這種分拆成為大量region的GC,複製而不是移動,意味著GC需要維護region之間對象引用關係,不管是記憶體佔用或者時間開銷也不小。

特別的:

如果系統中的垃圾對象很多,複製演算法不會很理想。因為複製演算法需要複製的存活對象數量並不會太大,或者說非常低才行。

應用場景

在新生代中,大部分對象都是朝生夕死的,一次回收通常可以回收70%-90%記憶體空間。回收性價比很高。所以現在的商業虛擬機都是使用這種演算法回收新生代。

垃圾清除階段演算法:標記-壓縮(整理)演算法

執行過程:

第一階段和標記-清除演算法一樣,從根節點開始標記所有被引用對象

第二階段將所有的存活對象壓縮到記憶體的一端,按順序排放。

之後,清理邊界外所有的空間。

標記-壓縮演算法的最終效果等同於標記-清除演算法執行完成後,再進行一次記憶體碎片整理,因此,也可以把它稱為標記-清除-壓縮(Mark- sweep-Compact)演算法。

二者的本質差異在於標記-清除演算法是一種非移動式的回收演算法,標記-壓縮是移動式的。是否移動回收後的存活對象是一項優缺點並存的風險決策。

可以看到,標記的存活對象將會被整理,按照記憶體地址依次排列,而未被標記的記憶體會被清理掉。如此一來,當我們需要給新對象分配記憶體時,JVM只需要持有一個記憶體的起始地址即可,這比維護-一個空閑列表顯然少了許多開銷。

優點:

消除了標記-清除演算法當中,記憶體區域分散的缺點,我們需要給新對象分配記憶體時,JVM只需要持有一個記憶體的起始地址即可。

消除了複製演算法當中,記憶體減半的高額代價。

缺點:

從效率上來說,標記-整理演算法要低於複製演算法。

移動對象的同時,如果對象被其他對象引用,則還需要調整引用的地址。移動過程中,需要全程暫停用戶應用程式。即: STW

對比三種演算法

★分代收集演算法

前面所有這些演算法中,並沒有一種演算法可以完全替代其他演算法,它們都具有自己獨特的優勢和特點。分代收集演算法應運而生。

分代收集演算法,是基於這樣一個事實:不同的對象的生命周期是不-一樣的。因此,不同生命周期的對象可以採取不同的收集方式,以便提高回收效率。一般是把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點使用不同的回收演算法,以提高垃圾回收的效率。

在Java程式運行的過程中,會產生大量的對象,其中有些對象是與業務資訊相關,比如Http請求中的Session對象、執行緒、Socket連接,這類對象跟業務直接掛鉤,因此生命周期比較長。但是還有一一些對象,主要是程式運行過程中生成的臨時變數,這些對象生命周期會比較短,比如: String對象, 由於其不變類的特性,系統會產生大量的這些對象,有些對象甚至只用一次即可回收。

目前幾乎所有的GC都是採用分代收集(Generational Collecting) 演算法執行垃圾回收的。在HotSpot中,基於分代的概念,GC所使用的記憶體回收演算法必須結合年輕代和老年代各自的特點。

年輕代(Young Gen)

年輕代特點:區域相對老年代較小,對象生命周期短、存活率低,回收頻繁。

這種情況複製演算法的回收整理,速度是最快的。複製演算法的效率只和當前存活對象大小有關,因此很適用於年輕代的回收。而複製演算法記憶體利用率不高的問題,通過hotspot中的兩個survivor的設計得到緩解。

老年代(Tenured Gen)

老年代特點:區域較大,對象生命周期長、存活率高,回收不及年輕代頻繁。

這種情況存在大量存活率高的對象,複製演算法明顯變得不合適。-般是由標記-清除或者是標記-清除與標記-整理的混合實現。

Mark階 段的開銷與存活對象的數量成正比。
sweep階段的開銷與所管理區域的大小成正相關。
compact階段的開銷與存活對象的數據成正比。

以HotSpot中的CMS回收器為例,CMS是 基於Mark- Sweep實現的,對於對象的回收效率很高。而對於碎片問題,CMS採用基於Mark-Compact演算法的Serial 0ld回收器作為補償措施:當記憶體回收不佳(碎片導致的Concurrent Mode Failure時),將採用Serial 0ld執行Full GC以達到對老年代記憶體的整理。
分代的思想被現有的虛擬機廣泛使用。幾乎所有的垃圾回收器都區分新生代和老年代。

增量收集演算法、分區演算法

增量收集演算法

上述現有的演算法,在垃圾回收過程中,應用軟體將處於一種stop the world的狀態。在Stop the World 狀態下,應用程式所有的執行緒都會掛起,暫停一切正常的工作,等待垃圾回收的完成。如果垃圾回收時間過長,應用程式會被掛起很久,將嚴重影響用戶體驗或者系統的穩定性。為了解決這個問題,即對實時垃圾收集演算法的研究直接導致了增量收集( Incremental Collecting) 演算法的誕生。

基本思想

如果一次性將所有的垃圾進行處理,需要造成系統長時間的停頓,那麼就可以讓垃圾收集執行緒和應用程式執行緒交替執行。每次,垃圾收集執行緒只收集有一小片區域的記憶體空間,接著切換到應用程式執行緒。依次反覆,直到垃圾收集完成。

總的來說,增量收集演算法的基礎仍是傳統的標記-清除和複製演算法。增量收集演算法通過對執行緒間衝突的妥善處理,允許垃圾收集執行緒以分階段的方式完成標記、清理或複製工作。

缺點:

使用這種方式,由於在垃圾回收過程中,間斷性地還執行了應用程式程式碼,所以能減少系統的停頓時間。但是,因為執行緒切換和上下文轉換的消耗,會使得垃圾回收的總體成本上升,造成系統吞吐量的下降。

分區演算法

一般來說,在相同條件下,堆空間越大,一次GC時所需要的時間就越長,有關GC產生的停頓也越長。為了更好地控制Gc產生的停頓時間,將一塊大的記憶體區域分割成多個小塊,根據目標的停頓時間,每次合理地回收若千個小區間,而不是整個堆空間,從而減少一次GC所產生的停頓。

分代演算法將按照對象的生命周期長短劃分成兩個部分,分區演算法將整個堆空間劃分成連續的不同小區間。

每一個小區間都獨立使用,獨立回收。這種演算法的好處是可以控制一-次回收多少個小區間。