前言:在C/C++中對于變量的內存空間一般都是由程序員手動進行管理的,往往會伴隨著大量的 malloc 和 free 操作,常常會有很多問題困擾開發者,這個代碼會不會發生內存泄漏?會不會重復釋放內存?但是在Java開發中我們卻很少有這樣的擔憂,程序員幾乎很少手動管理內存,這是因為在Java虛擬機JVM中這些事情都被JVM的垃圾回收算法管理和代理操作了。
目錄
一.什么是GC
二.JVM中的GC
??如何找到要回收的內存
1.使用引用計數器判斷某個對象是否具有引用指向(Python、PHP)
2.可達性分析(JVM采取的方案)
??如何對找到的內存垃圾進行釋放回收
1.標記-清除
2.復制算法
3.標記-整理
分代回收
一.什么是GC
GC是垃圾回收(Garbage Collection)的縮寫,是計算機科學中一種自動化的內存管理機制。在傳統的內存管理方式中,程序員需要手動分配和釋放內存。而GC則可以自動跟蹤和回收不再被程序使用的內存,從而減輕了程序員的負擔。要注意的是,GC并不是Java獨有的一種機制,現如今GC廣泛應用于許多的高級語言,諸如PHP、Python、Lua、Ruby、Go... ...
GC的主要原理是通過檢測程序中不再被引用的對象,將其標記為垃圾,然后自動回收這些垃圾對象所占用的內存資源。GC會定期地執行垃圾回收操作,找出不再被使用的對象并釋放其內存,從而避免內存泄漏和內存溢出的問題。
垃圾回收機制給程序員帶來了許多便利的同時也會產生性能問題,很簡單的邏輯,既然要自動跟蹤回收部分內存,那就需要分配一定的系統資源給到GC上,如果GC的效率非常差,很可能觸發GC的一瞬間就會把系統的負載拉滿,嚴重時會導致服務器無法響應其他的請求,因此,一個優秀且高效率的GC算法就必不可少。
二.JVM中的GC
對于一個Java程序來說,GC回收的是內存,其實就是不同的對象,往往都是堆區上的數據,我們對于JVM中的內存區域大致做個分析:
- 程序計數器:一般是不需要額外回收的,線程銷毀了,內存自然就回收了
- 棧區:一般夜市不需要額外回收的,線程銷毀了,內存自然也就回收了
- 元數據區:一般也不需要,我們一般進行的都是加載類的操作,很少說是卸載類
- 堆區:GC的主力回收區域
并且GC回收內存的時候,一定回收的是一個完整的對象,比如一個對象有10個成員,那么一定是回收這全部10個成員,不可能只回收一部分。
對于GC回收的內容有了一個了解后,就要關心GC回收的流程,總的來說垃圾回收分為倆個步驟
- 找到要回收的垃圾(內存)
- 釋放對應的內存
下文也按照這個流程分為倆部分來講解
??如何找到要回收的內存
一個對象的創建時間往往是很明確的,但是對于該對象什么時候不再使用,時機往往是模糊不定的。
舉個例子來說,就像一個一年級的小學生,做作業的時候很容易被其他事物分心,可能寫半個小時作業就去玩一下,過一段時間再來寫作業。但是如果我們認為他已經連續2個小時沒有寫作業了,就在他玩的時候將作業和本子和筆收起來,那么等到他回來準備繼續寫作業的時候,就會發現根本無從下手,對應到我們的代碼中,后面的業務和邏輯就完全無法進行了。
因此,我們必須要保證代碼中使用的每一個對象都是有效的,千萬不能出現提前釋放的情況,我們必須要采取很保守的態度,寧可晚一點回收內存,也不能提前回收打斷了原有程序的運行。
那我們需要用什么來作為判斷某個對象是否為垃圾的依據呢?JVM是如何判斷某個對象是否應該被回收呢?對于小學生寫作業的例子中,我們采取了 “上一次使用時間” 進行判斷,很顯然這是不太合理的,在GC中我們往往使用一種很保守的方法來判斷某個對象是否需要釋放——即是否存在引用指向該對象。
就拿下面這段示例來說,我們new了一個類對象Test,這時的 t 就是指向該對象的引用,此時這個對象就是有效的,我們則不能回收他。
Test t = new Test();
但如果我們將 t 置為 null ,原先指向Test對象的 t 更改了他的指向,此時我們就說這個Test對象不存在引用指向該對象,即該對象就是我們要回收的垃圾
t = null;
在我們理解了如何判斷一個對象是否為垃圾后,還有一個問題需要解決,對于我們剛才方案中提到的這個依據,我們又該如何判斷這個依據是否存在呢?剛才的例子很簡單,但是實際情況往往是很復雜的,不可能一概全是用 null 來改變指向,在垃圾回收機制中具體是怎么判定某個對象是否有引用指向呢?
這樣的策略有很多,主要分為以下倆種
- 使用引用計數器(Python/PHP采用的方案)
- 可達性分析(JVM采用的方案)
1.使用引用計數器判斷某個對象是否具有引用指向(Python、PHP)
這種方案為Python和PHP采用的方案,我們知道內存是一塊連續的物理空間,那我們在存儲對象的時候在對象旁邊放置一個引用計數器來統計這個對象目前有多少個引用,每個對象都有自己的引用計數器,當這個計數器為0的時候就說明當前對象沒有引用,那么就可以作為GC回收的垃圾進行內存回收了。
這樣的方案優點在于簡單容易實現,筆者這里還是畫圖說明一下
當我們new了一個對象,并且用a來指向它,此時引用計數器 +1
Test a = new Test();
?然后我們使用一個b來指向a,雖然這一步并沒有新建一個對象,但是這個b還是指向的Test這個對象,因此引用計數器 +1
Test b = a;
然后我們如果再更改b的指向,讓b不再指向Test這個對象,那么對應的引用計數器就要 -1
b = null;
那么如果我們再更改a的指向,此時的引用計數器則 -1 變為了 0 ,則該對象沒有任何的引用,則該對象就是垃圾,需要被回收
a = null;
這樣的方案優點在于簡單易懂,好實現,但是同樣有倆個缺點,那就是會消耗額外的空間以及會參數循環引用的問題。
消耗額外的空間:這很好理解,每個對象都有自己的引用計數器,那么如果對象很多,幾百個上千個對象就需要同樣數量的引用計數器,每個引用計數器的維護也都需要內存,這無疑會造成很大的資源浪費
循環引用的問題則較為復雜,筆者這里還是使用圖文的方式詳細解釋一下。
假設我們分別new了倆個Test對象,分別用a和b來指向他們。
class Test {Test t;
}Test a = new Test();
Test b = new Test();
那么情況就應該同下圖,a和b分別指向倆個地址
然后我們讓每個對象的內部成員對象都指向對方,由于每個Test對象都指向了對方,那么理所應當的倆個計數器都應該 +1?
a.t = b;
b.t = a;
到這里一切都是很正常的,但是,如果我們此時把 a 和 b 都指向 null 的話會發生什么呢?由于原本指向倆個 Test 對象的 a 和 b 都指向 null ,那么理所應當的倆個計數器也都應該 -1
a = null;
b = null;
所以理所應當的就會變為上圖的情況,大家仔細觀察一下,這合理嗎?明明已經沒有任何引用指向倆個 Test 對象了,但是他們的引用計數器卻因為之前的種種操作沒有合理的清零,就導致了倆個對象永遠相互指向對方,倆者的引用計數器都為 1(不為0,不是垃圾,不會被清理),但是外部代碼沒有任何方式訪問到這倆個對象。這就是我們所說的引用循環的問題。
這樣的問題能解決嗎?當然也是可以解決的,前文也說了,有許多語言是使用的這個策略。為了解決這個問題我們則需要引入其他的機制。JVM并沒有使用這種策略。
2.可達性分析(JVM采取的方案)
可達性分析的方案策略是JVM采用的方案,它解決了空間的問題和循環引用的問題,但是付出了時間上的代價,這意味著它需要消耗的時間更多,需要消耗的系統資源也更多。
那么這個方案具體是怎么做的呢?
JVM會把對象之間的引用關系理解為一個樹形結構,通過不斷的遍歷這樣的結構,就能把每個對象打上標記,分為“可達”和“不可達”,就像我們在學習離散數學中那樣,對于圖論的研究,我們會去考慮一個圖的可達性問題,我們知道樹其實也是一種特殊的圖,我們通過研究這顆樹的連通性和可達性就可以判斷出他們每個節點之間的關系,節點與節點之間如果可達就說明他們有引用關系,如果不可達就說明他們沒有引用關系,自然而然的我們就知道了哪些節點(對象)不存在引用關系,從而判斷出哪些對象屬于垃圾,需要回收。
如果其中某個對象沒有任何對象指向它,那么該對象則被判定為垃圾,需要被回收
對于之前提到的循環引用的情況,由于他們與跟節點不可達,因此也會被判定為垃圾,從而進行回收。如圖所示:
這樣就可以解決引用計數器中出現的倆個問題,當然這需要額外消耗系統資源。
一個Java程序中往往有很多的遍歷和類對象,這就意味著有很多上述這樣的樹結構,具體樹有多復雜都取決于實際的代碼結構,在這其中有一個很關鍵的概念——GC roots,也就是這些樹的根節點,在Java代碼中對于棧上的局部遍歷,常量池中引用的對象、方法區中的靜態成員這些都是GC roots,JVM會周期性的對這些樹進行遍歷,不斷的標記可達和不可達,不斷的回收掉不可達的對象。
由于可達性分析需要消耗一定的時間,因此Java垃圾回收沒法做到“實時性”,JVM會提供一組專門復雜GC的線程,不停的進行掃描工作。
??如何對找到的內存垃圾進行釋放回收
解決了找到垃圾的策略,接下來要思考的就是回收垃圾的策略。
對于回收垃圾我們也有三種策略:
- 標記-清除
- 復制算法
- 標記-整理
以下分為三部分講解
1.標記-清除
這種做法簡單粗暴,直接將標記為垃圾的對象對應的內存釋放掉,如下圖所示
但是這樣的策略帶來的最大的問題在于:它會存在“內存碎片”的問題,就會導致后續很難申請到一塊大的連續的內存了。因為我們申請內存都是要申請連續的內存空間的,這樣會使得空間利用率極低。
這就好比放假,假如一個人一個月有15天假期,盡管數量多但是都不是連續的,都是工作一天休息一天,那么這個人就算這么多假期,也還是不能出省出國的旅游,只能在家休息,畢竟隔一天就要上班。
因此,這種方案并不實用。
2.復制算法
這種方案會預先留出一段空間,當發生GC的時候,會將有用的空間全部復制到預留空間里面去,然后再將原來復制前的空間清空回收。
舉例子來說,假如我們現在需要釋放2、4、6三塊內存空間,保留1、3、5、7共四塊內存空間
首先將需要保留的空間復制到預留空間里面去
最后再將復制前的前半部分空間全部回收
這樣的方案解決了空間碎片化的問題,但是需要保留的空間越多,復制的時間也就月多,因此也會有浪費系統資源的問題
3.標記-整理
這種策略類似于順序表中刪除元素的流程,它既能解決內存碎片問題,也能解決空間利用率的問題
還是這個例子,假如我們現在需要釋放2、4、6三塊內存空間,保留1、3、5、7共四塊內存空間
就像順序表刪除元素一樣,后面的元素依次向前覆蓋,最終只保留前半部分內容,對后半部分進行回收
但是這樣搬運覆蓋對時間又有損耗
綜上所述,三種方案各有各的優點,各有各的缺點,那么JVM是如何進行選擇的呢?JVM表示“小孩子才做選擇,我都要”。JVM綜合了以上三種方案?,試用了更復雜的策略——分代回收。
分代回收
?在該方案中JVM會根據對象的年齡來進行分類,對于年齡這個概念需要做出解釋:
年齡:GC中有一組線程,周期性掃描,對于某個對象,經歷了一輪GC后,如果還是存在,沒有成為垃圾的話,年齡就+1
對于GC在堆區的操作我們大概可以分為以下幾個部分,我們將堆區分為新生代和老年代,對于新生代我們又可以細分為Eden(伊甸區)S0(生存區)S1(幸存區)?
對于新創建的對象,基本上都是放在伊甸區,在伊甸區中大部分的對象生命周期都是比較短的,第一輪GC到達的時候,大多數對象都會成為垃圾,只有少數對象能夠活過第一輪GC。
對于伊甸區存活下來的對象,會通過復制算法轉移到生存區,由于存活對象很少,復制開銷也很低,因此生存區空間也不必很大。
每經歷一輪GC,生存區都會淘汰掉一批對象,對于生存區存活下來的對象,會同樣通過復制算法轉移到幸存區,同樣進入幸存區的還可能會有伊甸區進來的對象。
其實對于生存區和幸存區,他們二者之間沒有什么特別的區別,因此,將其二者都稱為生存區或者幸存區都是可以的,重點在于理解思想,二者的名稱并沒有那么重要。
某些對象經歷了很多輪的GC都沒有變為垃圾,那么他們就會從生存區/幸存區經歷復制算法,轉移到老年代,老年代的對象也是需要GC的,但是對于老年代的對象,他們的生命周期往往都比較長,因此可以降低GC的頻率。
上述過程就是分代回收的基本邏輯。
對象在 伊甸區 --> 生存區/幸存區 --> 老年代 的過程中主要體現了復制算法的思想;對象在老年代則通過標記-整理的策略進行回收。
整個過程其實很像玩“吃雞”游戲,一波一波的刷毒圈,一波一波的淘汰人,同樣也很像找工作面試的情況。
?本次的分享就到此為止了,希望我的分享能給您帶來幫助,創作不易也歡迎大家三連支持,你們的點贊就是博主更新最大的動力!
如有不同意見,歡迎評論區積極討論交流,讓我們一起學習進步!
有相關問題也可以私信博主,評論區和私信都會認真查看的,我們下次再見
?