1. 程式人生 > >【朝花夕拾】記憶體篇之(三)Java GC

【朝花夕拾】記憶體篇之(三)Java GC

        在上一篇日誌中有講到,JVM記憶體由程式計數器、虛擬機器棧、本地方法棧、GC堆,方法區五個部分組成。其中GC堆是一塊多執行緒的共享區域,它存在的作用就是存放物件例項。本節中所要講述的各種場景,就發生在這塊區域,垃圾回收也主要發生在GC堆記憶體中。本章內容為高質量面試中幾乎是必問的知識點,尤其是其中GC Root、分代演算法、引用型別等方面的知識點,可以很好地體現程式設計師的內功。本文主要是在相關文章的基礎上進行蒐集和整理而成,也包含了自己的一些理解和總結,其中涉及到的程式碼,貼出執行結果的,都是自己親自執行過的。另外,本文內容為筆者一字一句敲上去的,如果能對讀者有幫助並被轉載了,請註明一下,如果對本文中內容有異意的,也請不吝賜教。

       本章主要內容如下:

                   

一、什麼是垃圾回收

       垃圾回收,即GC:Garbage Collection。在Java中,當原先分配給某物件的記憶體不再被任何物件指向時,該記憶體便被廢棄成為垃圾。這部分無用的記憶體空間需要在適當的時候被回收,以供新的物件例項使用。垃圾回收就是這種回收無用記憶體空間,並使其對未來例項可用的過程。

二、為什麼要進行垃圾回收

       由於裝置的記憶體空間是有限的,而程式執行時需要先載入到記憶體中,如果記憶體中垃圾過多,可用的空間過小,系統將會卡頓,甚至使得程式無法正常執行。為了能夠充分利用記憶體空間,就需要對記憶體進行垃圾回收。 垃圾回收能夠自動釋放記憶體空間,減輕程式設計師的程式設計負擔,JVM的一個系統級執行緒會自動釋放該記憶體塊,這就是我們平時所熟知的,JVM為程式設計師自動完成了記憶體的回收工作。垃圾回收將程式不再需要的物件的“無用資訊”丟棄,以便將這些空間分配給新物件使用。除了清理廢棄的物件,垃圾回收還會清除記憶體碎片,完成記憶體整理。

三、Java GC所用演算法

       先上思維導圖,下圖總結了Java GC過程中所使用的相關演算法。此處分為兩類:(1)判斷物件是否存活的演算法;(2)GC不同階段使用的演算法

        

    1、判斷物件是否存活的演算法

        GC堆記憶體中存放著幾乎所有的物件例項,垃圾回收器在對該記憶體進行回收前,首先需要確定這些物件哪些是“活著”,哪些已經“死去”,其判斷方法主要由如下兩種:

      (1)引用計數法

          該演算法由於無法處理物件之間相互迴圈引用的問題,在Java中並未採用該演算法,在此不做深入探究,有興趣的可自行學習。

      (2)根搜尋演算法(GC ROOT Tracing)

         Java中採用了該演算法來判斷物件是否是存活的。

         演算法思想:通過一系列名為“GC Roots” 的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈相連(用圖論來說就是從GC Roots到這個物件不可達)時,則證明物件是不可用的,即該物件是“死去”的,同理,如果有引用鏈相連,則證明物件可以,是“活著”的。如下圖所示:

               

          那麼,哪些可以作為GC Roots的物件呢?Java 語言中包含了如下幾種:

          1)虛擬機器棧(棧幀中的本地變量表)中的引用的物件。

          2)方法區中的類靜態屬性引用的物件。

          3)方法區中的常量引用的物件。

          4)本地方法棧中JNI(即一般說的Native方法)的引用的物件。

    2、Java GC所用的演算法

       在GC堆的不同區域,GC的不同階段中,會選擇不同的垃圾收集器來完成GC,當然,這些不同的垃圾回收器也採用了不同演算法。

     (1)Tracing演算法

                                

                                   tracing演算法示意圖

        稱標記-清除(mark-and-sweep)演算法,顧名思義,就是標記存活的物件,清除死去的物件。如上示意圖所示,該演算法基於根搜尋方法,從根集合進行掃描,對存活的物件進行標記,標記完畢後,再掃描整個空間中未被標記的物件,進行回收。標記-清除演算法不需要進行物件的移動,並且僅對不存活的物件進行處理,在存活物件比較多的情況下極為高效,但由於該演算法直接回收不存活的物件,因此會造成記憶體碎片。其示意圖如下:

    (2)Compacting演算法

                                 

                                                                       compacting演算法示意圖

        該演算法也被稱為標記-整理(mark-and-compact)演算法,顧名思義,就是標記存活的物件,整理回收後的空間。如上示意圖所示,該演算法和標記-清除演算法一樣先對存活的物件進行標記,然後清除掉沒有標記的物件,即不存活的物件。但與標記-清除演算法不同的是,該演算法多了一個整理的過程,在回收不存活的物件佔用的空間後,會將所有的存活物件往左端空閒空間移動,並更新對應的指標,因此解決了記憶體碎片的問題,當然,該演算法多了一個整理的過程,進行了物件的移動,因此成本更高。在基於Compacting演算法的收集器的實現中,一般增加了控制代碼和控制代碼表。

    (3)Copying演算法

                               

                                                                             copying演算法示意圖

       該演算法的提出是為了克服控制代碼的開銷和解決堆碎片的垃圾回收。如上示意圖所示,它開始時把堆分成物件面(from space)和空閒面(to space),程式在物件面為例項物件分配空間,當物件滿了,基於copying演算法的垃圾收集器就從根基中掃描存活物件,並將每個存活物件複製到空閒面,使得存活的物件所佔用的記憶體之間沒有碎片。這樣空閒面變成了物件面,原來的物件面變成了空閒面,程式會在新的物件面中分配記憶體。一種典型的基於copying演算法的垃圾回收是stop-and-copy演算法,它將堆分成物件面和空閒區域面,在物件面與空閒區域面的切換過程中,程式暫停執行。該演算法的優點是:不理會非存活的物件,copy數量僅僅取決於存活物件的數量,且在copy的同時,整理了heap空間,消除了記憶體碎片,空閒區的空間使用始終是連續的,記憶體使用效率得到提高。缺點是:劃分了物件面和空閒面,記憶體的使用率為1/2。收集器必須複製所有的存活物件,這增加了程式等待時間。

    (4)Generation演算法

                

                                                        generation演算法示意圖

          不同的物件的生命週期是不一樣的,分代的垃圾回收策略正式基於這一點。因此,不同生命週期的物件可以採取不同的回收演算法,以便提高回收效率。該演算法包含三個區域:年輕代(Young Generation)、年老代(Old Generation)、持久代(Permanent Generation)

      1)年輕代(Young Generation)

  • 所有新生成的物件首先都是放在年輕代中。年輕代的目標就是儘可能快速地回收哪些生命週期短的物件。
  • 新生代記憶體按照8:1:1的比例分為一個Eden區和兩個survivor(survivor0,survivor1)區。Eden區,字面意思翻譯過來,就是伊甸區,人類生命開始的地方。當一個例項被建立了,首先會被儲存在該區域內,大部分物件在Eden區中生成。Survivor區,倖存者區,字面理解就是用於儲存倖存下來物件。回收時先將Eden區存活物件複製到一個Survivor0區,然後清空Eden區,當這個Survivor0區也存放滿了後,則將Eden和Survivor0區中存活物件複製到另外一個survivor1區,然後清空Eden和這個Survivor0區,此時的Survivor0區就也是空的了。然後將Survivor0區和Survivor1區交換,即保持Servivor1為空,如此往復。
  • 當Survivor1區不足以存放Eden區和Survivor0的存活物件時,就將存活物件直接放到年老代。如果年老代也滿了,就會觸發一次Major GC(即Full GC),即新生代和年老代都進行回收。
  • 新生代發生的GC也叫做Minor GC,MinorGC發生頻率比較高,不一定等Eden區滿了才會觸發。

      2)年老代(Old Generation)

  • 在新生代中經歷了多次GC後仍然存活的物件,就會被放入到年老代中。因此,可以認為年老代中存放的都是一些生命週期較長的物件。
  • 年老代比新生代記憶體大很多(大概比例2:1?),當年老代中存滿時觸發Major GC,即Full GC,Full GC發生頻率比較低,年老代物件存活時間較長,存活率比較高。
  • 此處採用Compacting演算法,由於該區域比較大,而且通常物件生命週期比較長,compaction需要一定的時間,所以這部分的GC時間比較長。

      3)持久代(Permanent Generation)

        持久代用於存放靜態檔案,如Java類、方法等,該區域比較穩定,對GC沒有顯著影響。這一部分也被稱為執行時常量,有的版本說JDK1.7後該部分從方法區中移到GC堆中,有的版本卻說,JDK1.7後該部分被移除,有待考證。

        持久代中的垃圾回收,請參考下文中的“方法區的垃圾回收”。

                        

                                  Generaton演算法結構思維導圖

       現代商用虛擬機器基本都採用分代收集演算法來進行垃圾回收。這種演算法沒什麼特別的,就是將上述多種演算法結合,根據物件的生命週期的不同將記憶體劃分為幾塊,然後根據各快的特點採用最適合的收集演算法。新生代物件存活率低,使用複製演算法,在內部不同的區內進行復制,複製成本比較低;年老代物件存活率高,沒有額外空間進行分配,採用標記-清理演算法或者標記-整理演算法。

四、垃圾收集器

                     

       垃圾收集器就是上一節中垃圾收集演算法理論的具體實現。不同的虛擬機器所提供的垃圾收集器可能會有很大差別,我們平時開發用的是HotSpot虛擬機器,上圖中就是該款虛擬機器中所包含的所有收集器。如上一節所說,現代商業虛擬機器中基本都採用了分代收集演算法,上圖中就展示了在記憶體的不同Generation中,各垃圾收集器的使用情況,在物件的不同生命週期中分別採用不同的收集器。沒有最好的垃圾收集器,也沒有萬能的收集器,只能選擇對具體應用最合適的收集器,這也是HotSpot為什麼要實現這麼多收集器的原因。

       上圖展示的7款垃圾回收器,在某個生命週期階段或單獨使用,或配合(有連線的)使用,其實現原理也是按照上一節中描述的各種收集演算法實現的。至於對每一款垃圾收集器的詳細說明,本文展開講解,有興趣的可以自行研究。

五、方法區的垃圾回收

        本文開頭就說過,GC主要發生在GC堆記憶體中,但並不是只發生在該部分,方法區也需要進行垃圾回收。方法區和堆一樣,都是現成共享的記憶體區域,被用於儲存已被虛擬機器載入的類資訊、即時編譯後的程式碼、靜態變數和常量等資料。根據Java虛擬機器規範,方法區無法滿足記憶體分配需求時,也會丟擲OutOfMemoryError異常,雖然規範規定可以不實現垃圾收集,因為和GC堆記憶體的垃圾回收相比,方法區的回收效率實在太低,但是該部分割槽域也是可以被回收的。

       方法區的垃圾回收主要由兩種:廢棄常量回收和無用類回收。

       1、當一個常量物件不在任何地方被引用的時候,則被標記為廢棄常量,這個常量可以被回收。以字面量常量回收為例,如果一個字串"abc"已經進入常量池,但是當前系統沒有任何一個String物件引用了叫做“abc”的字面量,那麼,如果發生GC並且有必要時,"abc"就會被系統移出常量池,常量池中的其他類(介面)、方法、欄位的符號引用也與此類似。

       2、方法區中的類需要同時滿足如下三個條件才能被標記為無用的類:(1) Java堆中不存在該類的任何例項物件;(2) 載入該類的類載入器已經被回收;(3) 該類對應的java.lang.Class物件不在任何地方被引用,且無法在任何地方通過反射訪問到該類的方法。當滿足這三個條件的類才可以被回收,但並不是一定會被回收,需要引數進行控制,HotSpot虛擬機器中提供了 -Xnoclassgc引數進行控制是否回收。

       在上一篇文章中講到,執行時常量在jdk1.7之前是存在於方法區的,該部分也被稱為永久代(Permanence Generation)。在前面章節Generaton演算法中,也提到了該永久代。這裡所說的方法區垃圾回收就是對Permanence Generation 的垃圾回收(這一句是筆者個人的理解,沒有權威的資料宣告這一點)。

六、引用型別

    Java中提供了四種引用方式,強引用、軟引用、弱引用、虛引用,這樣做有兩個目的:(1)可以讓程式設計師通過程式碼的方式決定某些物件的生命週期;(2)有利於JVM進行垃圾回收。

    1、強引用

     強引用是指建立一個物件,並把這個物件賦給一個引用變數。比如:

1 People people = new People();2 String str = "abc";

       當強引用有引用變數指向時,永遠不會被JVM作為垃圾回收,系統記憶體緊張時,JVM寧願丟擲OutOfMemory異常,也不會回收強引用物件。比如:

複製程式碼
 1 public class ReferenceDemo {
 2 
 3     public static void main(String[] args) {
 4         ReferenceDemo demo = new ReferenceDemo();
 5         demo.test();
 6     }
 7 
 8     public void test() {
 9         People people = new People();
10         People[] peopleArr = new People[1000];
11     }
12 }
13 
14 class People {
15     public String name;
16     public int age;
17 
18     public People() {
19         this.name = "zhangsan";
20         this.age = 20;
21     }
22 
23     public People(String name, int age) {
24         this.name = name;
25         this.age = age;
26     }
27 
28     @Override
29     public String toString() {
30         return "[name:" + name + ",age:" + age + "]";
31     }
32 }
複製程式碼

       當執行到People[] peopleArr = new People[1000];這句時, 如果記憶體不足,JVM會丟擲OOM錯誤也不會回收object指向的物件。不過,要注意的是,當test執行完後,people和peopleArr都會不復存在,所以它們指向的物件都會被JVM回收。

       如果想中斷強引用和某個物件之間的關聯,可以顯示地將引用賦值為null,這樣,JVM在合適的時候就會回收該物件。比如Vector類的clear()方法中,就是通過將引用賦值為null來實現清理工作的。

    2、軟引用(SoftReference)

    (1)使用SoftReference實現軟引用

        如果一個物件具有軟引用,記憶體空間足夠,垃圾回收器就不會回收它。只有當記憶體空間不足了,才會回收這些物件的記憶體。只要垃圾回收器沒有回收它,該物件就可以被程式使用。軟引用可用來實現記憶體敏感的快取記憶體,比如網頁快取、圖片快取等。使用軟引用能夠防止記憶體洩漏,增強程式的健壯性。

        SoftReference的特點是它的一個例項儲存了對一個Java物件的軟引用,該軟引用的存在不妨礙垃圾收集執行緒對該Java物件的回收。一旦SoftReference儲存了對一個Java物件的軟引用後,在垃圾執行緒對這個Java物件回收前,SoftReference類所提供的get()方法返回Java物件的強引用。另外,一旦垃圾執行緒回收該Java物件之後,get()方法將返回null。

     

1 People people = new People();
2 SoftReference softRef = new SoftReference<>(people);

此時,對於這個People()物件,有兩個引用路徑,一個是來自mPeople的強引用,一個是來自SoftReference的軟引用。所以這裡的這個People()物件是強可及物件。隨即,可以通過如下方式結束mPeople對這個People物件的強引用:

1 people = null;

此後,這個People()物件成為了軟引用物件。如果垃圾收集執行緒進行記憶體垃圾收集,並不會因為有一個SoftReference對該物件的引用而始終保留該物件。

        JVM的垃圾收集執行緒對軟可及物件和其他一般Java物件進行了區別對待:軟可及物件的清理是由垃圾收集執行緒根據其特定演算法按照記憶體需求決定的。垃圾收集執行緒會在虛擬機器中丟擲OutOfMemoryError之前回收軟可及物件,而且虛擬機器會盡可能優先回收長時間閒置不用的軟可及物件,對那些剛剛構建的或剛剛使用過的“新”軟可及物件會被JVM儘可能保留。在回收這些物件之前,我們可以通過:

1 People softRefPeople =  (People) softRef.get();

重新獲得對該例項的強引用。如果軟可及物件也被回收了,則mSoftRef.get()也只能得到null了。

如下程式碼演示了SoftReference的使用:

複製程式碼
 1 public class ReferenceDemo {
 2 
 3     public static void main(String[] args) {
 4         People people = new People();//來自people的強引用
 5         SoftReference softRef = new SoftReference<>(people);//來自SoftReference的軟引用
 6         people = null;//結束people對People例項的強引用。
 7         People softRefPeople = (People) softRef.get();//通過get()重新獲得對People的強引用。
 8         System.out.println(softRefPeople.toString());
 9     }
10 }
複製程式碼

執行結果如下:

1 [name:zhangsan,age:20]

    (2)使用ReferenceQueue清除失去了軟引用物件的SoftReference

       SoftReference物件除了具有儲存軟引用的特殊性之外,它也是一個Java物件,也具有Java物件的一般特性。所以,當軟可及物件被回收之後,這個SoftReference物件的get()方法返回null,已經不再具有存在的價值了,需要一個適當的清除機制,避免大量SoftReference物件帶來的記憶體洩漏。在java.lang.ref包裡還提供了ReferenceQueue。如果在建立SorftReference物件的時候,使用了一個ReferenceQueue物件作為引數提供給SoftReference的構造方法,如:

People people = new People();
ReferenceQueue queue = new ReferenceQueue<>();
SoftReference softRef2 = new SoftReference(people,queue);

        當這個SoftReference所軟引用的people被垃圾收集器回收的同時,softRef2所強引用的SoftReference物件被列入ReferenceQueue。也就是說,ReferenceQueue中儲存的物件是Reference物件,而且是已經失去了它所軟引用物件的Reference物件。另外,從ReferenceQueue這個名字可以看出,它是一個佇列,當我們呼叫它的poll()方法的時候,如果這個佇列中不是空佇列,那麼將返回佇列中第一個Reference物件。在任何時候,我們都可以呼叫ReferenceQueue的poll()方法來檢查是否有它所關心的非強可及物件被回收。如果佇列為空,將返回一個null,否則該方法返回佇列中第一個Reference物件。利用這個方法,我們可以檢查哪個SoftReference所軟引用的物件已經被回收。於是我們可以把這些失去所軟引用物件的SoftReference物件清除掉。

1 SoftReference softRef3 = null;
2 while ((softRef3 = (SoftReference)queue.poll())!=null) {
3     //清除ref
4 }

    3、弱引用(WeakReference)

      弱引用也是用來描述非必需物件的,當JVM進行垃圾回收時,無論記憶體是否充足,都會回收被弱引用關聯的物件。在Java中,用java.lang.ref.WeakReference類來表示。使用方法如下:

複製程式碼
1 public class ReferenceDemo {
2 
3     public static void main(String[] args) {
4         WeakReference<People> weakRef = new WeakReference<People>(new People());
5         System.out.println(weakRef.get());//獲取到弱引用保護的物件。
6         System.gc();//通知JVM進行垃圾回收
7         System.out.println(weakRef.get());
8     }
9 }
複製程式碼

      執行結果如下:

1 [name:zhangsan,age:20]
2 null

第二個結果為null,說明只要JVM進行垃圾回收,被弱引用關聯的物件必定會被回收掉。不過要注意的是,這裡所說的被弱引用關聯的物件,是指只有弱引用與之關聯。如果存在強引用同時與之關聯,則進行垃圾回收時也不會回收該物件(軟引用也是如此)。比如,將程式碼做一點小改動:

複製程式碼
 1 public class ReferenceDemo {
 2 
 3     public static void main(String[] args) {
 4         People people = new People();
 5         WeakReference<People> weakRef = new WeakReference<People>(people);
 6         System.out.println(weakRef.get());
 7         System.gc();
 8         System.out.println(weakRef.get());
 9     }
10 }
複製程式碼

      執行結果如下:第二行結果有了很大的變化,不再是null,說明沒有被回收。

1 [name:zhangsan,age:20]
2 [name:zhangsan,age:20]

       弱引用也可以和引用佇列ReferenceQueue聯合使用,如果弱引用所引用的物件被JVM回收,這個弱引用就會被加入到與之關聯的引用佇列中,其使用方法同軟引用中的使用。

       在使用軟引用和弱引用的時候,我們可以顯示地通過System.gc()來通知JVM進行垃圾回收,但是要注意的是,雖然發出了通知,JVM不一定會立刻執行,也就是說,這句程式碼是無法確保此時JVM一定會進行垃圾回收的,可能會在發出通知後,在某個合適的時間進行回收。

       另外,在Android開發中,常常與Handler聯合使用,來避免記憶體洩漏的發生。

    4、虛引用(PhantomReference)

       在Java中,用java.lang.PhantamReference類表示。如果一個物件與虛引用關聯,則跟沒有引用與之關聯一樣,在任何時候都可能被垃圾回收器回收。需要注意的是,虛引用必須和引用佇列關聯使用,當垃圾回收器準備回收一個物件時,如果發現它還有虛引用,就會把這個虛引用加入到與之關聯的引用佇列中(這裡和前面的軟引用和弱引用有所不同,這兩者加入佇列是在物件被回收之時)。程式可以通過判斷引用佇列中是否已經加入了虛引用,來了解被引用的物件是否將要被垃圾回收。如果程式發現某個虛引用已經被加入到引用佇列,那麼就可以在所引用的物件的記憶體被回收之前採取必要的行動。

複製程式碼
1 public class ReferenceDemo {
2 
3     public static void main(String[] args) {
4         ReferenceQueue<People> queue = new ReferenceQueue<>();
5         PhantomReference<People> phanRef = new PhantomReference<People>(new People(), queue);
6         System.out.println(phanRef.get());
7     }
8 }
複製程式碼

      執行結果如下:

1 null

該結果驗證了前面所說的,“如果一個物件與虛引用關聯,則跟沒有引用與之關聯一樣,在任何時候都可能被垃圾回收器回收”。

    5、小結