Java虛擬機詳解04----GC算法和種類

【聲明】 html

歡迎轉載,但請保留文章原始出處→_→ java

生命壹號:http://www.cnblogs.com/smyhvae/算法

文章來源:http://www.cnblogs.com/smyhvae/p/4744233.html
數據庫

 

本文主要內容:編程

  • GC的概念
  • GC算法

    引用計數法(沒法解決循環引用的問題,不被java採納)數組

      根搜索算法ide

      現代虛擬機中的垃圾蒐集算法:佈局

      標記-清除性能

      複製算法(新生代)字體

      標記-壓縮(老年代)

      分代收集

  • Stop-The-World

 

1、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關注的也是這部份內存,後面的文章中若是涉及到「內存」分配與回收也僅指着一部份內存。

 

2、引用計數算法:(老牌垃圾回收算法。沒法處理循環引用,沒有被Java採納)

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

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

e6217360-0985-46e8-88fd-022f1fc0fba5

二、使用者舉例:

引用計數算法的實現簡單,斷定效率也高,大部分狀況下是一個不錯的算法。不少地方應用到它。例如:

微軟公司的COM技術:Computer Object Model

使用ActionScript3的FlashPlayer

Python

可是,主流的java虛擬機並無選用引用計數算法來管理內存,其中最主要的緣由是:它很難解決對象之間相互循環引用的問題

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

  • 引用和去引用伴隨加法和減法,影響性能
  • 致命的缺陷:對於循環引用的對象沒法進行回收

1a489e67-e047-408f-a97e-4a141e6ab3b0

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

循環引用的代碼舉例:

複製代碼
 1 public class Object {
 2 
 3     Object field = null;
 4     
 5     public static void main(String[] args) {
 6         Thread thread = new Thread(new Runnable() {
 7             public void run() {
 8                 Object objectA = new Object();
 9                 Object objectB = new Object();//位置1
10                 objectA.field = objectB; 11                 objectB.field = objectA;//位置2 12                 //to do something
13                 objectA = null;
14                 objectB = null;//位置3
15             }
16         });
17         thread.start();
18         while (true);
19     }
20     
21 } 
複製代碼

上方代碼看起來有點刻意爲之,但其實在實際編程過程中,是常常出現的,好比兩個一對一關係的數據庫對象,各自保持着對方的引用。最後一個無限循環只是爲了保持JVM不退出,沒什麼實際意義。

代碼解釋:

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

對於咱們如今使用的GC來講,當thread線程運行結束後,會將objectA和objectB所有做爲待回收的對象。而果咱們的GC採用上面所說的引用計數算法,則這兩個對象永遠不會被回收,即使咱們在使用後顯示的將對象歸爲空值也毫無做用。

 

3、根搜索算法:

一、根搜索算法的概念:

  因爲引用計數算法的缺陷,因此JVM通常會採用一種新的算法,叫作根搜索算法。它的處理方式就是,立若干種根對象,當任何一個根對象到某一個對象均不可達時,則認爲這個對象是能夠被回收的

7ab0f17b-13f7-4886-a24d-3813c2173891

如上圖所示,ObjectD和ObjectE是互相關聯的,可是因爲GC roots到這兩個對象不可達,因此最終D和E仍是會被當作GC的對象,上圖如果採用引用計數法,則A-E五個對象都不會被回收。

 

二、可達性分析:

 咱們剛剛提到,設立若干種根對象,當任何一個根對象到某一個對象均不可達時,則認爲這個對象是能夠被回收的。咱們在後面介紹標記-清理算法/標記整理算法時,也會一直強調從根節點開始,對全部可達對象作一次標記,那什麼叫作可達呢?這裏解釋以下:

可達性分析:

  從根(GC Roots)的對象做爲起始點,開始向下搜索,搜索所走過的路徑稱爲引用鏈」,當一個對象到GC Roots沒有任何引用鏈相連(用圖論的概念來說,就是從GC Roots到這個對象不可達)時,則證實此對象是不可用的。

 

三、根(GC Roots):

說到GC roots(GC根),在JAVA語言中,能夠當作GC roots的對象有如下幾種:

一、棧(棧幀中的本地變量表)中引用的對象。

二、方法區中的靜態成員。

三、方法區中的常量引用的對象(全局變量)

四、本地方法棧中JNI(通常說的Native方法)引用的對象。

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

在根搜索算法的基礎上,現代虛擬機的實現當中,垃圾蒐集的算法主要有三種,分別是標記-清除算法、複製算法、標記-整理算法。這三種算法都擴充了根搜索算法,不過它們理解起來仍是很是好理解的。

 

4、標記-清除算法:

一、標記清除算法的概念:

標記-清除算法是現代垃圾回收算法的思想基礎。標記-清除算法將垃圾回收分爲兩個階段:標記階段和清除階段。一種可行的實現是,在標記階段,首先經過根節點,標記全部從根節點開始的可達對象。所以,未被標記的對象就是未被引用的垃圾對象;而後,在清除階段,清除全部未被標記的對象。

7de44970-2e02-46a1-a5d0-0663b21906c6

二、標記-清除算法詳解:

它的作法是當堆中的有效內存空間(available memory)被耗盡的時候,就會中止整個程序(也被成爲stop the world),而後進行兩項工做,第一項則是標記,第二項則是清除。

  • 標記:標記的過程其實就是,遍歷全部的GC Roots,而後將全部GC Roots可達的對象標記爲存活的對象
  • 清除:清除的過程將遍歷堆中全部的對象,將沒有標記的對象所有清除掉

也就是說,就是當程序運行期間,若可使用的內存被耗盡的時候,GC線程就會被觸發並將程序暫停,隨後將依舊存活的對象標記一遍,最終再將堆中全部沒被標記的對象所有清除掉,接下來便讓程序恢復運行

來看下面這張圖:

47146934-c3a3-4976-991f-77e84ae008cc

上圖表明的是程序運行期間全部對象的狀態,它們的標誌位所有是0(也就是未標記,如下默認0就是未標記,1爲已標記),假設這會兒有效內存空間耗盡了,JVM將會中止應用程序的運行並開啓GC線程,而後開始進行標記工做,按照根搜索算法,標記完之後,對象的狀態以下圖:

5cbf57ce-c83a-40d2-b58a-b37d3eee3803

上圖中能夠看到,按照根搜索算法,全部從root對象可達的對象就被標記爲了存活的對象,此時已經完成了第一階段標記。接下來,就要執行第二階段清除了,那麼清除完之後,剩下的對象以及對象的狀態以下圖所示:

8654ed59-fc00-446d-8995-a02ab57cf213

上圖能夠看到,沒有被標記的對象將會回收清除掉,而被標記的對象將會留下,而且會將標記位從新歸0。接下來就不用說了,喚醒中止的程序線程,讓程序繼續運行便可。

疑問:爲何非要中止程序的運行呢?

答:

這個其實也不難理解,假設咱們的程序與GC線程是一塊兒運行的,各位試想這樣一種場景。

假設咱們剛標記完圖中最右邊的那個對象,暫且記爲A,結果此時在程序當中又new了一個新對象B,且A對象能夠到達B對象。可是因爲此時A對象已經標記結束,B對象此時的標記位依然是0,由於它錯過了標記階段。所以當接下來輪到清除階段的時候,新對象B將會被苦逼的清除掉。如此一來,不難想象結果,GC線程將會致使程序沒法正常工做。

上面的結果固然使人沒法接受,咱們剛new了一個對象,結果通過一次GC,突然變成null了,這還怎麼玩?

三、標記-清除算法的缺點:

(1)首先,它的缺點就是效率比較低(遞歸與全堆對象遍歷),致使stop the world的時間比較長,尤爲對於交互式的應用程序來講簡直是沒法接受。試想一下,若是你玩一個網站,這個網站一個小時就掛五分鐘,你還玩嗎?

(2)第二點主要的缺點,則是這種方式清理出來的空閒內存是不連續的,這點不難理解,咱們的死亡對象都是隨即的出如今內存的各個角落的,如今把它們清除以後,內存的佈局天然會亂七八糟。而爲了應付這一點,JVM就不得不維持一個內存的空閒列表,這又是一種開銷。並且在分配數組對象的時候,尋找連續的內存空間會不太好找。

 

5、複製算法:(新生代的GC)

複製算法的概念:

將原有的內存空間分爲兩塊,每次只使用其中一塊,在垃圾回收時,將正在使用的內存中的存活對象複製到未使用的內存塊中,以後,清除正在使用的內存塊中的全部對象,交換兩個內存的角色,完成垃圾回收

  • 與標記-清除算法相比,複製算法是一種相對高效的回收方法
  • 不適用於存活對象較多的場合,如老年代(複製算法適合作新生代的GC

ff1e1846-e49c-4663-aee1-7c63628f567c

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

複製算法使得每次都只對整個半區進行內存回收,內存分配時也就不用考慮內存碎片等複雜狀況,只要移動堆頂指針,按順序分配內存便可,實現簡單,運行高效。只是這種算法的代價是將內存縮小爲原來的一半,這個太要命了。

因此從以上描述不難看出,複製算法要想使用,最起碼對象的存活率要很是低才行,並且最重要的是,咱們必需要克服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空間不夠用時,須要依賴於老年代進行分配擔保,因此大對象直接進入老年代。整個過程以下圖所示:

7e1f6ed2-e0c4-45e4-b7db-b59c28e1ee9c

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

根據上面的複製算法,如今咱們來看下面的這個gc日誌的數字,就應該能看得懂了吧:

6d59301f-f0c9-4fed-ba36-e66bc6574e8f

上方GC日誌中,新生代的可用空間是13824K(eden區的12288K+from space的1536K)。而根據內存的地址計算得知,新生代的總空間爲15M,而這個15M的空間是 = 13824K +to space 的 1536K。

 

6、標記-整理算法:(老年代的GC)

引入:

    若是在對象存活率較高時就要進行較多的複製操做,效率將會變低。更關鍵的是,若是不想浪費50%的空間,就須要有額外的空間進行分配擔保,以應對被使用的內存中全部對象都100%存活的極端狀況,因此在老年代通常不能直接選中這種算法。

概念:

標記-壓縮算法適合用於存活對象較多的場合,如老年代。它在標記-清除算法的基礎上作了一些優化。和標記-清除算法同樣,標記-壓縮算法也首先須要從根節點開始,對全部可達對象作一次標記;但以後,它並不簡單的清理未標記的對象,而是將全部的存活對象壓縮到內存的一端;以後,清理邊界外全部的空間

cc79889a-0856-4018-92c3-c51108c9caea

  • 標記:它的第一個階段與標記/清除算法是如出一轍的,均是遍歷GC Roots,而後將存活的對象標記。
  • 整理:移動全部存活的對象,且按照內存地址次序依次排列,而後將末端內存地址之後的內存所有回收。所以,第二階段才稱爲整理階段。

上圖中能夠看到,標記的存活對象將會被整理,按照內存地址依次排列,而未被標記的內存會被清理掉。如此一來,當咱們須要給新對象分配內存時,JVM只須要持有一個內存的起始地址便可,這比維護一個空閒列表顯然少了許多開銷。

標記/整理算法不只能夠彌補標記/清除算法當中,內存區域分散的缺點,也消除了複製算法當中,內存減半的高額代價

  • 可是,標記/整理算法惟一的缺點就是效率也不高。

不只要標記全部存活對象,還要整理全部存活對象的引用地址。從效率上來講,標記/整理算法要低於複製算法。

標記-清除算法、複製算法、標記整理算法的總結:

三個算法都基於根搜索算法去判斷一個對象是否應該被回收,而支撐根搜索算法能夠正常工做的理論依據,就是語法中變量做用域的相關內容。所以,要想防止內存泄露,最根本的辦法就是掌握好變量做用域,而不該該使用C/C++式內存管理方式。

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

它們的區別以下:(>表示前者要優於後者,=表示二者效果同樣)

(1)效率複製算法>標記/整理算法>標記/清除算法(此處的效率只是簡單的對比時間複雜度,實際狀況不必定如此)。

(2)內存整齊度:複製算法=標記/整理算法>標記/清除算法。

(3)內存利用率:標記/整理算法=標記/清除算法>複製算法。

注1:能夠看到標記/清除算法是比較落後的算法了,可是後兩種算法倒是在此基礎上創建的。

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

 

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

當前商業虛擬機的GC都是採用的「分代收集算法」,這並非什麼新的思想,只是根據對象的存活週期的不一樣將內存劃分爲幾塊兒。通常是把Java堆分爲新生代和老年代:短命對象歸爲新生代,長命對象歸爲老年代

  • 少許對象存活,適合複製算法:在新生代中,每次GC時都發現有大批對象死去,只有少許存活,那就選用複製算法,只須要付出少許存活對象的複製成本就能夠完成GC。
  • 大量對象存活,適合用標記-清理/標記-整理:在老年代中,由於對象存活率高、沒有額外空間對他進行分配擔保,就必須使用「標記-清理」/「標記-整理」算法進行GC。

注:老年代的對象中,有一小部分是由於在新生代回收時,老年代作擔保,進來的對象;絕大部分對象是由於不少次GC都沒有被回收掉而進入老年代

8、可觸及性:

全部的算法,須要可以識別一個垃圾對象,所以須要給出一個可觸及性的定義。

可觸及的:

  從根節點能夠觸及到這個對象。

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

可復活的:

  一旦全部引用被釋放,就是可復活狀態

  由於在finalize()中可能復活該對象

不可觸及的:

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

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

      要被回收。

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

複製代碼
 1 package test03;
 2 
 3 /**
 4  * Created by smyhvae on 2015/8/19.
 5  */
 6 public class CanReliveObj {
 7     public static CanReliveObj obj;
 8 
 9     //當執行GC時,會執行finalize方法,而且只會執行一次
10     @Override
11     protected void finalize() throws Throwable {
12         super.finalize();
13         System.out.println("CanReliveObj finalize called");
14         obj = this;   //當執行GC時,會執行finalize方法,而後這一行代碼的做用是將null的object復活一下,而後變成了可觸及性
15     }
16 
17     @Override
18     public String toString() {
19         return "I am CanReliveObj";
20     }
21 
22     public static void main(String[] args) throws
23             InterruptedException {
24         obj = new CanReliveObj();
25         obj = null;   //可復活
26         System.out.println("第一次gc");
27         System.gc();
28         Thread.sleep(1000);
29         if (obj == null) {
30             System.out.println("obj 是 null");
31         } else {
32             System.out.println("obj 可用");
33         }
34         obj = null;    //不可復活
35         System.out.println("第二次gc");
36         System.gc();
37         Thread.sleep(1000);
38         if (obj == null) {
39             System.out.println("obj 是 null");
40         } else {
41             System.out.println("obj 可用");
42         }
43     }
44 }
複製代碼

 

咱們須要注意第14行的註釋。一開始,咱們在第25行將obj設置爲null,而後執行一次GC,本覺得obj會被回收掉,其實並無,由於GC的時候會調用11行的finalize方法,而後obj在第14行被複活了。緊接着又在第34行設置obj設置爲null,而後執行一次GC,此時obj就被回收掉了,由於finalize方法只會執行一次。

31011217-d3a2-4e5b-9503-4f0b9bad9161

finalize方法的使用總結:

  • 經驗:避免使用finalize(),操做不慎可能致使錯誤。
  • 優先級低,什麼時候被調用,不肯定

什麼時候發生GC不肯定,天然也就不知道finalize方法何時執行

  • 若是要使用finalize去釋放資源,咱們可使用try-catch-finally來替代它

 

9、Stop-The-World:

一、Stop-The-World概念:

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

全局停頓,全部Java代碼中止,native代碼能夠執行,但不能和JVM交互

多半狀況下是因爲GC引發

    少數狀況下由其餘狀況下引發,如:Dump線程、死鎖檢查、堆Dump。

 

二、GC時爲何會有全局停頓?

    (1)避免沒法完全清理乾淨

打個比方:類比在聚會,忽然GC要過來打掃房間,聚會時很亂,又有新的垃圾產生,房間永遠打掃不乾淨,只有讓你們中止活動了,才能將房間打掃乾淨。

    何況,若是沒有全局停頓,會給GC線程形成很大的負擔,GC算法的難度也會增長,GC很難去判斷哪些是垃圾。

  (2)GC的工做必須在一個能確保一致性的快照中進行。

這裏的一致性的意思是:在整個分析期間整個執行系統看起來就像被凍結在某個時間點上,不能夠出現分析過程當中對象引用關係還在不斷變化的狀況,該點不知足的話分析結果的準確性沒法獲得保證。

這點是致使GC進行時必須停頓全部Java執行線程的其中一個重要緣由。

三、Stop-The-World的危害:

長時間服務中止,沒有響應(將用戶正常工做的線程所有暫停掉)

遇到HA系統,可能引發主備切換,嚴重危害生產環境。

  備註:HA:High Available, 高可用性集羣。

d07bb3ea-1235-41d5-9fb1-56b4087d1acf

好比上面的這主機和備機:如今是主機在工做,此時若是主機正在GC形成長時間停頓,那麼備機就會監測到主機沒有工做,因而備機開始工做了;可是主機不工做只是暫時的,當GC結束以後,主機又開始工做了,那麼這樣的話,主機和備機就同時工做了。主機和備機同時工做實際上是很是危險的,頗有可能會致使應用程序不一致、不能提供正常的服務等,進而影響生產環境。

代碼舉例:

(1)打印日誌的代碼:(每隔100ms打印一條)

複製代碼
public static class PrintThread extends Thread{
    public static final long starttime=System.currentTimeMillis();
    @Override
    public void run(){
        try{
            while(true){
                long t=System.currentTimeMillis()-starttime;
                System.out.println("time:"+t);
                Thread.sleep(100);
            }
        }catch(Exception e){

        }
    }
}
複製代碼

 

上方代碼中,是負責打印日誌的代碼,每隔100ms打印一條,並計算打印的時間。

(2)工做線程的代碼:(工做線程,專門用來消耗內存)

複製代碼
public static class MyThread extends Thread{
    HashMap<Long,byte[]> map=new HashMap<Long,byte[]>();
    @Override
    public void run(){
        try{
            while(true){
                if(map.size()*512/1024/1024>=450){   //若是map消耗的內存消耗大於450時,那就清理內存
                    System.out.println("=====準備清理=====:"+map.size());
                    map.clear();
                }

                for(int i=0;i<1024;i++){
                    map.put(System.nanoTime(), new byte[512]);
                }
                Thread.sleep(1);
            }
        }catch(Exception e){
            e.printStackTrace();
        }
    }
}
複製代碼

 

而後,咱們設置gc的參數爲:

-Xmx512M -Xms512M -XX:+UseSerialGC -Xloggc:gc.log -XX:+PrintGCDetails -Xmn1m -XX:PretenureSizeThreshold=50 -XX:MaxTenuringThreshold=1

 

打印日誌以下:

8a8de388-7989-47f2-a7e1-496487e4be57

上圖中,紅色字體表明的正在GC。按道理來講,應該是每隔100ms會打印輸出一條日誌,可是當執行GC的時候,會出現全局停頓的狀況,致使沒有按時輸出。

 

下篇文章中,咱們將對各類垃圾收集器進行介紹。

相關文章
相關標籤/搜索