圖解Java:關於垃圾回收,一文帶你玩轉

在上文《圖解Java:技術體系與運行時數據區》中,我們介紹了運行時數據區。既然我們已經瞭解程序在運行時,不同的數據按照不同的邏輯存放在了不同的空間,那麼空間是有限的,這些數據又該什麼時候回收呢?這篇文章,我們就來聊聊JVM是如何識別垃圾,以及回收垃圾的。

圖解Java:關於垃圾回收,一文帶你玩轉

垃圾收集(GC)是Java比較鮮明的特色,以至於當人們談到GC,就會立刻想到Java。事實上GC最早被設計在Lisp之中。如今,基本上在進行Java相關的面試時,GC是必問的話題。

有人可能有疑惑?當這些自動化工具已經能夠很好地服務開發者,我們為什麼還要去了解它的設計與實現細節?因為Java不是完美的,它也會拋出各種各樣的內存洩漏、溢出等問題,當需要排查時,熟悉GC就成為了必備的技能。

如何判斷對象需要回收?

在程序運行時,一些已經無用的對象會駐留在內存中,此時,我們認為它已經“死”了,造成空間的浪費。那麼如何才能通過一些機制,來掃描並判斷一個是否存活呢?一般使用以下兩種方式。

1.引用計數法

這是用於判斷對象是否存活的最簡單的算法,即當前對象被其他對象引用時,計數器自增1,引用失效時,自減1。當一個對象的引用計數器值為0,那麼認為該對象可以回收。

引用計數法雖然看似合理,但在實際情況下難以穩定工作,需要配合大量的額外處理。比如兩個對象相互引用,但是這兩個對象在程序中已經失效,使用引用計數法來判斷對象是否存活的話,這兩個無用對象永遠無法被回收。

在主流的Java虛擬機中,從來沒有使用這種方式。

比如以下代碼:




<code>B b = new B();A a = new A(b);b.setA(a);/<code>

此時,a和b兩個對象就形成了互相引用的關係,即使它們已經無用了,在引用計數法之下也不會被判斷死亡。

圖解Java:關於垃圾回收,一文帶你玩轉

2.可達性分析算法

這種算法在邏輯上也十分簡單,就是通過一系列根對象(GC Roots)作為起始對象,通過引用關係進行向下搜索。假如一個對象與GC Roots之間沒有任何關聯,那麼認為該對象可以回收。這樣就能解決上述問題,使一些較為邊緣,但是存在相互引用的對象也能被標記為死亡。

那麼,不難發現,我們又引入了一個問題:哪些對象才能被定義為GC Roots?這些內容暫時只要瞭解一下就好,固定可作為GC Roots的對象包括以下幾種:

•在虛擬機棧(棧幀中的本地變量表)中引用的對象,譬如各個線程被調用的方法堆棧中使用到的參數、局部變量、臨時變量等。•在方法區中類靜態屬性引用的對象,譬如Java類的引用類型靜態變量。•在方法區中常量引用的對象,譬如字符串常量池(String Table)裡的引用。•在本地方法棧中JNI(即通常所說的Native方法)引用的對象。•Java虛擬機內部的引用,如基本數據類型對應的Class對象,一些常駐的異常對象(比如NullPointExcepiton、OutOfMemoryError)等,還有系統類加載器。•所有被同步鎖(synchronized關鍵字)持有的對象。·反映Java虛擬機內部情況的JMXBean、JVMTI中註冊的回調、本地代碼緩存等

除了這些固定的可作為GC Roots的對象之外,也會臨時性地加入一些對象。所以GC Roots的數目並沒有想象的那麼少,後面也會提到,如何去優化對這些對象的枚舉。

目前主流的Java垃圾回收器,就是通過可達性分析來進行對象存活判斷的。

圖解Java:關於垃圾回收,一文帶你玩轉

對象的引用狀態

上文提到了一個很重要的概念:引用。判斷對象是否存活完全建立在“引用”這個概念上。

按照正常的邏輯,一個對象只存在“被引用”和“未被引用”兩種狀態,這聽上去很合理,初期的Java也是這麼設計的。但是我們需要考慮是否存在這樣一種情況:在內存空間充足時,一些對象可以保留;當內存空間不足時,這些對象應該回收。比如緩存就是一些具有這種特點的對象。

那麼,當我們引入“內存空間是否充足”這個條件時,於是將引用分為了四種類型:

•強引用(Strongly Re-ference):最傳統的“引用”的定義,是指在程序代碼之中普遍存在的引用賦值,即類似“Objectobj=new Object()”這種引用關係。無論任何情況下,只要強引用關係還存在,垃圾收集器就永遠不會回收掉被引用的對象。•軟引用(Soft Reference):用來描述一些還有用,但非必須的對象。只被軟引用關聯著的對象,在系統將要發生內存溢出異常前,會把這些對象列進回收範圍之中進行第二次回收,如果這次回收還沒有足夠的內存,才會拋出內存溢出異常。•弱引用(Weak Reference):用來描述那些非必須對象,但是它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生為止。當垃圾收集器開始工作,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象。•虛引用(PhantomReference):也稱為“幽靈引用”或者“幻影引用”,它是最弱的一種引用關係。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。為一個對象設置虛引用關聯的唯一目的只是為了能在這個對象被收集器回收時收到一個系統通知。

(具體這四種引用的實驗,在本系列以後的文章裡再詳細講解)

垃圾回收算法簡述

在瞭解以上概念後,可以進入我們更加關心的話題:垃圾回收。我將這部分稱為“簡述”,是因為這部分只從宏觀上講解,具體的實現細節並未提及。

分代回收是什麼?

分代回收是現在最主流的一種垃圾回收方式。簡而言之,分代收集就是給每個對象標上不同年齡,年齡是隨著垃圾回收次數上升的,並且將它們劃分到不同的區域存儲。

新生代(Young Generation)、老年代(Old Generation)這樣的概念就產生了,這樣的好處是,在不同區域可以使用不同的回收算法,進行不同的回收頻率,這樣能夠提高整體回收效率。

分代回收是一個抽象概念,不同虛擬機可以有自己的實現方式,但都要遵循這三個思想:

•弱分代假說(Weak Generational Hypothesis):絕大多數對象都是朝生夕滅的。大部分對象存在於新生代,新生代中GC比較頻繁。•強分代假說(Strong Generational Hypothesis):熬過越多次垃圾收集過程的對象就越難以消亡。新生代中的對象如果在多次GC中沒有被回收,那麼將會進入老年代。•跨代引用假說(Intergenerational Reference Hypothesis):跨代引用相對於同代引用來說僅佔極少數。

圖解Java:關於垃圾回收,一文帶你玩轉

前兩條很好理解,這裡要重點說一下跨代引用。當少數新生代中的對象被老年代中的對象引用時,傾向於同時生存或同時消亡。即一個新生代對象被老年代對象引用時,由於該老年代對象依然存活,那麼該新生代對象也不會被回收。

如何確定新生代中的某個對象被老年代中的對象引用?JVM在新生代中維護一個名為“記憶集”,Remembered Set)的數據結構,這個結構將會記錄老年代中存在跨代引用的對象信息,當新生代中出現GC時,只會掃描記憶集,將其中的對象信息加入到GC Roots中(這也是上文所說的,一些對象臨時性地加入GC Roots),這樣在可達性算法之下,新生代中相應的對象就不會被回收。

三種回收算法

1.標記-清除算法

簡單來說,就是先標記需要回收的對象,然後清除,就是這麼簡單。它也是一種最早出現、最基礎的回收算法。但是存在兩個主要缺點:

•不穩定,假如存在大量需要清除的對象,那麼標記-清除的執行時間會隨著對象數的增加而增加。•內存空間碎片化,會產生大量不連續的內存碎片,這在後續要為大對象分配空間時,而獲取不到足夠的連續內存。

圖解Java:關於垃圾回收,一文帶你玩轉

2.標記-複製算法

為了解決上述提到的“標記-清除法”的不穩定效率低的問題,標記-複製法將總的內存空間分為兩份,但是隻使用其中的一半,當這一半的空間使用完了,就進行一次GC,將存活的對象複製到另一半空間上,對當前空間進行回收。在複製的過程中,也能夠解決空間碎片化的問題。可以看出,這種方式是比較適合新生代的,因為新生代每進行一輪GC,能夠存活的對象不多,因此複製的開銷也比較小。

但是顯而易見,這種方式的弊端在於只能使用一半的空間,實在是過於浪費。

圖解Java:關於垃圾回收,一文帶你玩轉

基於這種特點,“Appel式回收”被提出,主要是這麼做的:把新生代分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次分配內存只使用Eden和其中一塊Survivor。發生垃圾蒐集時,將Eden和Survivor中仍然存活的對象一次性複製到另外一塊Survivor空間上,然後直接清理掉Eden和已用過的那塊Survivor空間。

但是如何保證一次GC之後,一塊Survivor一定能夠存放得下未被回收的對象呢?因此Appel式回收還有一個充當罕見情況的“逃生門”的安全設計,當Survivor空間不足以容納一次Minor GC之後存活的對象時,就需要依賴其他內存區域(實際上大多就是老年代)進行分配擔保(Handle Promotion)。

3.標記-整理算法

上述提到了“標記-複製法”比較適合新生代的GC,而且存在空間浪費的情況,“Appel式回收”優化這一問題。

面對老年代對象存活時間比較長的特點,“標記-整理法”被提出。簡單來說,就是先將存活對象標記,然後將它們往內存空間的一側對齊(解決碎片化問題)然後回收掉邊界以外的對象。

圖解Java:關於垃圾回收,一文帶你玩轉

這種移動對象的方式存在弊端,因為老年代中每次GC被標記為存活的對象一定是佔大多數的,移動所有對象負擔比較大,雖然在人類看來,一次移動的時間很短,但是也是需要暫停應用程序來進行的,這樣的停頓被稱為“stop the world”。但是如果不使用移動的方式來避免內存碎片的話,將會更加複雜。關鍵在於具體場景下的權衡利弊。

也可以通過“標記-清除法”和“標記-整理法”相配合,先容忍內存碎片的存在,當無法忍受時,再進行“整理”。

圖解Java:關於垃圾回收,一文帶你玩轉

方法區的回收

上一篇文章《圖解Java:技術體系與運行時數據區》介紹過方法區的概念,這裡需要再次聲明的是,在JDK8前後,“方法區”是始終存在的。因為“方法區”是JVM規範,是一個抽象的概念。改變的只是“方法區”的實現方式,在JDK8之前HotSpot團隊使用永久代的方式來實現方法區,JDK8之後使用元空間。關於這點,不要混淆了。

之前也提到過,GC主要出現在堆中,在方法區中進行GC效果是比較差的,因為方法區中存儲的是一些常量以及類型信息,出現需要回收的情況比較少(這也是方法區常被誤稱為永久代的原因)。但是這並不代表方法區不需要進行GC,在大量使用反射、動態代理、CGLib等字節碼框架,動態生成JSP以及OSGi這類頻繁自定義類加載器的場景中,通常都需要Java虛擬機具備類型卸載的能力,以保證不會對方法區造成過大的內存壓力。

常量的回收方式和在堆中回收對象比較類似,而卸載一個類,條件比較苛刻:

•該類所有的實例都已經被回收,也就是Java堆中不存在該類及其任何派生子類的實例。•加載該類的類加載器已經被回收(這個條件除非是經過精心設計的可替換類加載器的場景,如OSGi、JSP的重加載等,否則通常是很難達成的)•該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

-END-

這是我準備在2020年持續更新的一個系列

致力於用一種新方式講解編程

難度會逐漸上升

原文鏈接:https://mp.weixin.qq.com/s/ucUzosavwnMkbrBxA-UMXQ


分享到:


相關文章: