天天看點

Java虛拟機詳解04----GC算法和種類【重要】

本文主要内容:

gc的概念

gc算法

    引用計數法(無法解決循環引用的問題,不被java采納)

      根搜尋算法

      現代虛拟機中的垃圾搜集算法:

      标記-清除

      複制算法(新生代)

      标記-壓縮(老年代)

      分代收集

stop-the-world

一、gc的概念:

gc:garbage collection 垃圾收集

1960年 lisp使用了gc

java中,gc的對象是java堆和方法區(即永久區)

我們接下來對上面的三句話進行一一的解釋:

(1)gc:garbage collection 垃圾收集。這裡所謂的垃圾指的是在系統運作過程當中所産生的一些無用的對象,這些對象占據着一定的記憶體空間,如果長期不被釋放,可能導緻oom。

在c/c++裡是由程式猿自己去申請、管理和釋放記憶體空間,是以沒有gc的概念。而在java中,背景專門有一個專門用于垃圾回收的線程來進行監控、掃描,自動将一些無用的記憶體進行釋放,這就是垃圾收集的一個基本思想,目的在于防止由程式猿引入的人為的記憶體洩露。

(2)事實上,gc的曆史比java久遠,1960年誕生于mit的lisp是第一門真正使用記憶體動态配置設定和垃圾收集技術的語言。當lisp還在胚胎時期時,人們就在思考gc需要完成的3件事情:

哪些記憶體需要回收? 什麼時候回收? 如何回收?

(3)記憶體區域中的程式計數器、虛拟機棧、本地方法棧這3個區域随着線程而生,線程而滅;棧中的棧幀随着方法的進入和退出而有條不紊地執行着出棧和入棧的操作,每個棧幀中配置設定多少記憶體基本是在類結構确定下來時就已知的。在這幾個區域不需要過多考慮回收的問題,因為方法結束或者線程結束時,記憶體自然就跟着回收了。

而java堆和方法區則不同,一個接口中的多個實作類需要的記憶體可能不同,一個方法中的多個分支需要的記憶體也可能不一樣,我們隻有在程式處于運作期間時才能知道會建立哪些對象,這部分記憶體的配置設定和回收都是動态的,gc關注的也是這部分記憶體,後面的文章中如果涉及到“記憶體”配置設定與回收也僅指着一部分記憶體。

二、引用計數算法:(老牌垃圾回收算法。無法處理循環引用,沒有被java采納)

1、引用計數算法的概念:

給對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器為0的對象就是不可能再被使用的。

Java虛拟機詳解04----GC算法和種類【重要】

2、使用者舉例:

引用計數算法的實作簡單,判定效率也高,大部分情況下是一個不錯的算法。很多地方應用到它。例如:

微軟公司的com技術:computer object model 使用actionscript3的flashplayer python

但是,主流的java虛拟機并沒有選用引用計數算法來管理記憶體,其中最主要的原因是:它很難解決對象之間互相循環引用的問題。

3、引用計數算法的問題:

引用和去引用伴随加法和減法,影響性能

緻命的缺陷:對于循環引用的對象無法進行回收

Java虛拟機詳解04----GC算法和種類【重要】

上面的3個圖中,對于最右邊的那張圖而言:循環引用的計數器都不為0,但是他們對于根對象都已經不可達了,但是無法釋放。

循環引用的代碼舉例:

Java虛拟機詳解04----GC算法和種類【重要】
Java虛拟機詳解04----GC算法和種類【重要】

上方代碼看起來有點刻意為之,但其實在實際程式設計過程當中,是經常出現的,比如兩個一對一關系的資料庫對象,各自保持着對方的引用。最後一個無限循環隻是為了保持jvm不退出,沒什麼實際意義。

代碼解釋:

代碼中标注了1、2、3三個數字,當位置1的語句執行完以後,兩個對象的引用計數全部為1。當位置2的語句執行完以後,兩個對象的引用計數就全部變成了2。當位置3的語句執行完以後,也就是将二者全部歸為空值以後,二者的引用計數仍然為1。根據引用計數算法的回收規則,引用計數沒有歸0的時候是不會被回收的。

對于我們現在使用的gc來說,當thread線程運作結束後,會将objecta和objectb全部作為待回收的對象。而如果我們的gc采用上面所說的引用計數算法,則這兩個對象永遠不會被回收,即便我們在使用後顯示的将對象歸為空值也毫無作用。

三、根搜尋算法:

1、根搜尋算法的概念:

  由于引用計數算法的缺陷,是以jvm一般會采用一種新的算法,叫做根搜尋算法。它的處理方式就是,設立若幹種根對象,當任何一個根對象到某一個對象均不可達時,則認為這個對象是可以被回收的。

Java虛拟機詳解04----GC算法和種類【重要】

如上圖所示,objectd和objecte是互相關聯的,但是由于gc roots到這兩個對象不可達,是以最終d和e還是會被當做gc的對象,上圖若是采用引用計數法,則a-e五個對象都不會被回收。

2、可達性分析:

 我們剛剛提到,設立若幹種根對象,當任何一個根對象到某一個對象均不可達時,則認為這個對象是可以被回收的。我們在後面介紹标記-清理算法/标記整理算法時,也會一直強調從根節點開始,對所有可達對象做一次标記,那什麼叫做可達呢?這裡解釋如下:

可達性分析:

  從根(gc roots)的對象作為起始點,開始向下搜尋,搜尋所走過的路徑稱為“引用鍊”,當一個對象到gc roots沒有任何引用鍊相連(用圖論的概念來講,就是從gc roots到這個對象不可達)時,則證明此對象是不可用的。

3、根(gc roots):

說到gc roots(gc根),在java語言中,可以當做gc roots的對象有以下幾種:

1、棧(棧幀中的本地變量表)中引用的對象。 2、方法區中的靜态成員。 3、方法區中的常量引用的對象(全局變量) 4、本地方法棧中jni(一般說的native方法)引用的對象。

注:第一和第四種都是指的方法的本地變量表,第二種表達的意思比較清晰,第三種主要指的是聲明為final的常量值。

在根搜尋算法的基礎上,現代虛拟機的實作當中,垃圾搜集的算法主要有三種,分别是标記-清除算法、複制算法、标記-整理算法。這三種算法都擴充了根搜尋算法,不過它們了解起來還是非常好了解的。

四、标記-清除算法:

1、标記清除算法的概念:

标記-清除算法是現代垃圾回收算法的思想基礎。标記-清除算法将垃圾回收分為兩個階段:标記階段和清除階段。一種可行的實作是,在标記階段,首先通過根節點,标記所有從根節點開始的可達對象。是以,未被标記的對象就是未被引用的垃圾對象;然後,在清除階段,清除所有未被标記的對象。

Java虛拟機詳解04----GC算法和種類【重要】

2、标記-清除算法詳解:

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

标記:标記的過程其實就是,周遊所有的gc roots,然後将所有gc roots可達的對象标記為存活的對象。

清除:清除的過程将周遊堆中所有的對象,将沒有标記的對象全部清除掉。

也就是說,就是當程式運作期間,若可以使用的記憶體被耗盡的時候,gc線程就會被觸發并将程式暫停,随後将依舊存活的對象标記一遍,最終再将堆中所有沒被标記的對象全部清除掉,接下來便讓程式恢複運作。

來看下面這張圖:

Java虛拟機詳解04----GC算法和種類【重要】

上圖代表的是程式運作期間所有對象的狀态,它們的标志位全部是0(也就是未标記,以下預設0就是未标記,1為已标記),假設這會兒有效記憶體空間耗盡了,jvm将會停止應用程式的運作并開啟gc線程,然後開始進行标記工作,按照根搜尋算法,标記完以後,對象的狀态如下圖:

Java虛拟機詳解04----GC算法和種類【重要】

上圖中可以看到,按照根搜尋算法,所有從root對象可達的對象就被标記為了存活的對象,此時已經完成了第一階段标記。接下來,就要執行第二階段清除了,那麼清除完以後,剩下的對象以及對象的狀态如下圖所示:

Java虛拟機詳解04----GC算法和種類【重要】

上圖可以看到,沒有被标記的對象将會回收清除掉,而被标記的對象将會留下,并且會将标記位重新歸0。接下來就不用說了,喚醒停止的程式線程,讓程式繼續運作即可。

疑問:為什麼非要停止程式的運作呢?

答:

這個其實也不難了解,假設我們的程式與gc線程是一起運作的,各位試想這樣一種場景。

假設我們剛标記完圖中最右邊的那個對象,暫且記為a,結果此時在程式當中又new了一個新對象b,且a對象可以到達b對象。但是由于此時a對象已經标記結束,b對象此時的标記位依然是0,因為它錯過了标記階段。是以當接下來輪到清除階段的時候,新對象b将會被苦逼的清除掉。如此一來,不難想象結果,gc線程将會導緻程式無法正常工作。

上面的結果當然令人無法接受,我們剛new了一個對象,結果經過一次gc,忽然變成null了,這還怎麼玩?

3、标記-清除算法的缺點:

(1)首先,它的缺點就是效率比較低(遞歸與全堆對象周遊),導緻stop the world的時間比較長,尤其對于互動式的應用程式來說簡直是無法接受。試想一下,如果你玩一個網站,這個網站一個小時就挂五分鐘,你還玩嗎?

(2)第二點主要的缺點,則是這種方式清理出來的空閑記憶體是不連續的,這點不難了解,我們的死亡對象都是随即的出現在記憶體的各個角落的,現在把它們清除之後,記憶體的布局自然會亂七八糟。而為了應付這一點,jvm就不得不維持一個記憶體的空閑清單,這又是一種開銷。而且在配置設定數組對象的時候,尋找連續的記憶體空間會不太好找。

五、複制算法:(新生代的gc)

複制算法的概念:

将原有的記憶體空間分為兩塊,每次隻使用其中一塊,在垃圾回收時,将正在使用的記憶體中的存活對象複制到未使用的記憶體塊中,之後,清除正在使用的記憶體塊中的所有對象,交換兩個記憶體的角色,完成垃圾回收。

與标記-清除算法相比,複制算法是一種相對高效的回收方法

不适用于存活對象較多的場合,如老年代(複制算法适合做新生代的gc)

Java虛拟機詳解04----GC算法和種類【重要】

複制算法的最大的問題是:空間的浪費

複制算法使得每次都隻對整個半區進行記憶體回收,記憶體配置設定時也就不用考慮記憶體碎片等複雜情況,隻要移動堆頂指針,按順序配置設定記憶體即可,實作簡單,運作高效。隻是這種算法的代價是将記憶體縮小為原來的一半,這個太要命了。

是以從以上描述不難看出,複制算法要想使用,最起碼對象的存活率要非常低才行,而且最重要的是,我們必須要克服50%記憶體的浪費。

現在的商業虛拟機都采用這種收集算法來回收新生代,新生代中的對象98%都是“朝生夕死”的,是以并不需要按照1:1的比例來劃分記憶體空間,而是将記憶體分為一塊比較大的eden空間和兩塊較小的survivor空間,每次使用eden和其中一塊survivor。當回收時,将eden和survivor中還存活着的對象一次性地複制到另外一塊survivor空間上,最後清理掉eden和剛才用過的survivor空間。hotspot虛拟機預設eden和survivor的大小比例是8:1,也就是說,每次新生代中可用記憶體空間為整個新生代容量的90%(80%+10%),隻有10%的空間會被浪費。

當然,98%的對象可回收隻是一般場景下的資料,我們沒有辦法保證每次回收都隻有不多于10%的對象存活,當survivor空間不夠用時,需要依賴于老年代進行配置設定擔保,是以大對象直接進入老年代。整個過程如下圖所示:

Java虛拟機詳解04----GC算法和種類【重要】

上圖中,綠色箭頭的位置代表的是大對象,大對象直接進入老年代。

根據上面的複制算法,現在我們來看下面的這個gc日志的數字,就應該能看得懂了吧:

Java虛拟機詳解04----GC算法和種類【重要】

上方gc日志中,新生代的可用空間是13824k(eden區的12288k+from space的1536k)。而根據記憶體的位址計算得知,新生代的總空間為15m,而這個15m的空間是 = 13824k +to space 的 1536k。

六、标記-整理算法:(老年代的gc)

引入:

    如果在對象存活率較高時就要進行較多的複制操作,效率将會變低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行配置設定擔保,以應對被使用的記憶體中所有對象都100%存活的極端情況,是以在老年代一般不能直接選中這種算法。

概念:

标記-壓縮算法适合用于存活對象較多的場合,如老年代。它在标記-清除算法的基礎上做了一些優化。和标記-清除算法一樣,标記-壓縮算法也首先需要從根節點開始,對所有可達對象做一次标記;但之後,它并不簡單的清理未标記的對象,而是将所有的存活對象壓縮到記憶體的一端;之後,清理邊界外所有的空間。

Java虛拟機詳解04----GC算法和種類【重要】

标記:它的第一個階段與标記/清除算法是一模一樣的,均是周遊gc roots,然後将存活的對象标記。

整理:移動所有存活的對象,且按照記憶體位址次序依次排列,然後将末端記憶體位址以後的記憶體全部回收。是以,第二階段才稱為整理階段。

上圖中可以看到,标記的存活對象将會被整理,按照記憶體位址依次排列,而未被标記的記憶體會被清理掉。如此一來,當我們需要給新對象配置設定記憶體時,jvm隻需要持有一個記憶體的起始位址即可,這比維護一個空閑清單顯然少了許多開銷。

标記/整理算法不僅可以彌補标記/清除算法當中,記憶體區域分散的缺點,也消除了複制算法當中,記憶體減半的高額代價。

但是,标記/整理算法唯一的缺點就是效率也不高。

不僅要标記所有存活對象,還要整理所有存活對象的引用位址。從效率上來說,标記/整理算法要低于複制算法。

标記-清除算法、複制算法、标記整理算法的總結:

三個算法都基于根搜尋算法去判斷一個對象是否應該被回收,而支撐根搜尋算法可以正常工作的理論依據,就是文法中變量作用域的相關内容。是以,要想防止記憶體洩露,最根本的辦法就是掌握好變量作用域,而不應該使用c/c++式記憶體管理方式。

在gc線程開啟時,或者說gc過程開始時,它們都要暫停應用程式(stop the world)。

它們的差別如下:(>表示前者要優于後者,=表示兩者效果一樣)

(1)效率:複制算法>标記/整理算法>标記/清除算法(此處的效率隻是簡單的對比時間複雜度,實際情況不一定如此)。

(2)記憶體整齊度:複制算法=标記/整理算法>标記/清除算法。

(3)記憶體使用率:标記/整理算法=标記/清除算法>複制算法。

注1:可以看到标記/清除算法是比較落後的算法了,但是後兩種算法卻是在此基礎上建立的。

注2:時間與空間不可兼得。

七、分代收集算法:(新生代的gc+老年代的gc)

目前商業虛拟機的gc都是采用的“分代收集算法”,這并不是什麼新的思想,隻是根據對象的存活周期的不同将記憶體劃分為幾塊兒。一般是把java堆分為新生代和老年代:短命對象歸為新生代,長命對象歸為老年代。

少量對象存活,适合複制算法:在新生代中,每次gc時都發現有大批對象死去,隻有少量存活,那就選用複制算法,隻需要付出少量存活對象的複制成本就可以完成gc。

大量對象存活,适合用标記-清理/标記-整理:在老年代中,因為對象存活率高、沒有額外空間對他進行配置設定擔保,就必須使用“标記-清理”/“标記-整理”算法進行gc。

注:老年代的對象中,有一小部分是因為在新生代回收時,老年代做擔保,進來的對象;絕大部分對象是因為很多次gc都沒有被回收掉而進入老年代。

八、可觸及性:

所有的算法,需要能夠識别一個垃圾對象,是以需要給出一個可觸及性的定義。

可觸及的:

  從根節點可以觸及到這個對象。

      其實就是從根節點掃描,隻要這個對象在引用鍊中,那就是可觸及的。

可複活的:

  一旦所有引用被釋放,就是可複活狀态

  因為在finalize()中可能複活該對象

不可觸及的:

  在finalize()後,可能會進入不可觸及狀态

  不可觸及的對象不可能複活

      要被回收。

finalize方法複活對象的代碼舉例:

Java虛拟機詳解04----GC算法和種類【重要】
Java虛拟機詳解04----GC算法和種類【重要】

我們需要注意第14行的注釋。一開始,我們在第25行将obj設定為null,然後執行一次gc,本以為obj會被回收掉,其實并沒有,因為gc的時候會調用11行的finalize方法,然後obj在第14行被複活了。緊接着又在第34行設定obj設定為null,然後執行一次gc,此時obj就被回收掉了,因為finalize方法隻會執行一次。

Java虛拟機詳解04----GC算法和種類【重要】

finalize方法的使用總結:

經驗:避免使用finalize(),操作不慎可能導緻錯誤。

優先級低,何時被調用,不确定

何時發生gc不确定,自然也就不知道finalize方法什麼時候執行

如果要使用finalize去釋放資源,我們可以使用try-catch-finally來替代它

九、stop-the-world:

1、stop-the-world概念:

  java中一種全局暫停的現象。

全局停頓,所有java代碼停止,native代碼可以執行,但不能和jvm互動

多半情況下是由于gc引起。

    少數情況下由其他情況下引起,如:dump線程、死鎖檢查、堆dump。

2、gc時為什麼會有全局停頓?

    (1)避免無法徹底清理幹淨

打個比方:類比在聚會,突然gc要過來打掃房間,聚會時很亂,又有新的垃圾産生,房間永遠打掃不幹淨,隻有讓大家停止活動了,才能将房間打掃幹淨。

    況且,如果沒有全局停頓,會給gc線程造成很大的負擔,gc算法的難度也會增加,gc很難去判斷哪些是垃圾。

  (2)gc的工作必須在一個能確定一緻性的快照中進行。

這裡的一緻性的意思是:在整個分析期間整個執行系統看起來就像被當機在某個時間點上,不可以出現分析過程中對象引用關系還在不斷變化的情況,該點不滿足的話分析結果的準确性無法得到保證。

這點是導緻gc進行時必須停頓所有java執行線程的其中一個重要原因。

3、stop-the-world的危害:

長時間服務停止,沒有響應(将使用者正常工作的線程全部暫停掉) 遇到ha系統,可能引起主備切換,嚴重危害生産環境。   備注:ha:high available, 高可用性叢集。
Java虛拟機詳解04----GC算法和種類【重要】

比如上面的這主機和備機:現在是主機在工作,此時如果主機正在gc造成長時間停頓,那麼備機就會監測到主機沒有工作,于是備機開始工作了;但是主機不工作隻是暫時的,當gc結束之後,主機又開始工作了,那麼這樣的話,主機和備機就同時工作了。主機和備機同時工作其實是非常危險的,很有可能會導緻應用程式不一緻、不能提供正常的服務等,進而影響生産環境。

代碼舉例:

(1)列印日志的代碼:(每隔100ms列印一條)

Java虛拟機詳解04----GC算法和種類【重要】
Java虛拟機詳解04----GC算法和種類【重要】

上方代碼中,是負責列印日志的代碼,每隔100ms列印一條,并計算列印的時間。

(2)工作線程的代碼:(工作線程,專門用來消耗記憶體)

Java虛拟機詳解04----GC算法和種類【重要】
Java虛拟機詳解04----GC算法和種類【重要】

然後,我們設定gc的參數為:

列印日志如下:

Java虛拟機詳解04----GC算法和種類【重要】

上圖中,紅色字型代表的正在gc。按道理來說,應該是每隔100ms會列印輸出一條日志,但是當執行gc的時候,會出現全局停頓的情況,導緻沒有按時輸出。