華文網

從Java虛擬機器的記憶體區域、垃圾收集器及記憶體分配原則談Java的記憶體回收機制

一、引言:

在Java中我們只需要輕輕地new一下,就可以為產生實體一個類,並分配對應的記憶體空間,而後似乎我們也可以不用去管它,Java自帶垃圾回收器,到了對象死亡的時候垃圾回收器就會將死亡物件的記憶體回收。

真的只要根據需要巴拉巴拉地new而不用管記憶體回收了嗎?那為什麼會存在這麼多的記憶體溢出情況呢?下面我們就需要瞭解一下Java記憶體的回收機制,只有瞭解了其虛擬機器的回收原理才能更好的管理記憶體,避免記憶體溢出。

二、Java虛擬機器的記憶體區域

首先,我們得知道在我們的虛擬機器中記憶體到底是怎麼劃分區域的,下面借用《深入理解Java虛擬機器》一書中的一張圖。

我們首先是把上述5個記憶體區域劃分為了左右兩塊,姑且假定左邊的為區域A,右邊的為區域B。這邊我們將記憶體劃分為左右兩塊是有依據的,依據是什麼呢?依據主要是根據執行緒所有性來劃分的,區域A中的方法區和堆是各個執行緒共用的記憶體區域,

而與之對應的區域B中的虛擬機器棧、本地方法棧、程式計數器都是執行緒私有的。

2.1 程式計數器

程式計數器可以看做是當前執行緒所執行的位元組碼的行號指示器。位元組碼解譯器通過改變這個計數器的值倆選取下一條要執行的位元組碼指令。

因為Java虛擬機器的多執行緒是通過執行緒輪流切換並分配處理器執行時間的方式實現的,

在任一時刻,一個處理器內核只會執行一條執行緒中的命令。因此,網路執行緒切換後能夠恢復到特定位置,每個執行緒都需要有一個獨立的程式計數器。

注:在程式計數器中沒有規定任何的記憶體溢出錯誤。

2.2虛擬機器棧

虛擬機器棧描述的是Java方法執行的記憶體模型:每個方法在執行過程中都會創建一個棧幀用於存儲區域變數表、運算元棧、動態連結、方法出口燈資訊。

每一個方法從調用到執行完畢就對應一個棧幀在虛擬機器棧都從入棧到出棧的過程。

區域變數表存放了編譯期可知的各種基底資料型別、物件引用(可能是指向物件起始位址的引用指標,也可能是指向代表物件的控制碼)和returnAddress(指向一條位元組碼指向的位址)

在虛擬機器棧中規定了棧溢出和記憶體溢出兩種異常。

2.3 本地方法棧

本地方法棧的作用與虛擬機器棧是類似的,

只不過本地方法棧是為虛擬機器用到的native方法服務的。同樣在本地方法棧也規定了棧溢出和記憶體溢出兩種異常。

2.4 Java堆

Java堆是Java虛擬機器所管理的記憶體中最大的一塊,Java堆是被所有執行緒共用的,它的功能很單一,就是存放物件實例。此外因為存放的是物件的實例,Java堆是垃圾回收器管理的主要區域,因此也被稱為GC堆。Java堆可以處於物理上不連續的記憶體空間,只要求其邏輯上是連續的即可。一般而言,Java堆是可擴展的(當然也可以實現為固定的),通過-Xmx和-Xms來控制。當沒有記憶體可供分配且堆也無法擴展的時候,就會拋出記憶體溢出異常

2.5 方法區

方法區用於存儲已經被載入的類資訊、常量、靜態變數和即時編譯器編譯後的代碼等。方法區也常被人們稱為永久代,當然主要原因是因為在這塊區域中發生垃圾收集行為比較少。在Jdk1.7已經著手去永久代了,而在Jdk1.8中已經將永久代替換為了元空間(Metaspace)

運行時常量池是方法區的一部分,用於存放編譯器生成的各種字面量和符號引用。

因為是執行緒私有,程式計數器、虛擬機器棧和本地方法棧隨執行緒而生,隨執行緒而死,每一個棧幀隨著方法的進入和退出有序地執行出棧和入棧的操作,每一個棧幀分配的記憶體也是在編譯期可知的,因此,因為這些區域的記憶體分配和回收具有確定性,所以我們不需要考慮回收的問題。(當方法或者執行緒結束的時候,記憶體自然也就回收了)

三、垃圾收集器及記憶體回收

在談垃圾收集器之前,我們首先需要明確的是:哪些記憶體需要回收?什麼時候回收?怎麼回收?

3.1 哪些垃圾需要回收?

我們怎麼判斷哪些是需要回收的垃圾呢?正如前面提到的,GC的重點是在Java堆中,那麼在垃圾收集器在對堆中的物件進行回收前,第一件需要判斷的就是哪些物件已死(即不可能再被任何途徑使用的物件)

怎麼判斷物件是否存活,不得不提一下廣為人知的引用計數法,即給一個物件添加一個引用計數器,每引用一次,計數器值加1,引用失效時,計數器值減1,當值為0時,該對象死亡。然而這個方法固然高效,但卻存在一個很大的問題,它很難解決物件間的相互迴圈引用,即A引用B,B引用A,但其實二者都沒有其他地方被引用,其二者已經不可能被訪問到了,從合理性角度,這兩個物件已經死了。

下面請出我們要介紹了主角,也是在Java虛擬機器中所採用的方法---可達性分析演算法

這個演算法的基本思想就是通過一系列被稱為“GC Roots”物件作為起始點,從這些節點向下所示,走過的路徑被稱為引用鏈,游離在外的對象即為不可用的對象。

那麼顯然這個方法的關鍵在於那些物件是可以作為GC Root:

1)虛擬機器棧中引用的物件

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

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

4)本地方法棧中native方法引用的物件。

3.2 什麼時候需要回收?

從一般來講什麼時候需要回收,即當物件已死的時候需要回收,但從嚴格意義上來講,真正宣告一個物件死亡需要經歷上述兩次不可達的標記才會導致這個物件被收集。

當物件第一次被標記為不可達時,會對它進行一次篩選,判斷其是否有必要執行finalize方法,當物件沒有覆蓋finalize方法或者finalize方法已經被虛擬機器調用過了,則不會執行finalize方法。

那我們就可以在finalize方法中對物件進行最後的自救了,即在finalize方法中為物件和GC ROOT的引用鏈中的任一物件建立關聯即可。

除了這個,因為考慮到記憶體的有限性,不僅僅是物件死亡後才需要回收,為了有效利用記憶體,我們還需要有一些具有類似性質的物件,在記憶體足夠時可以保留在記憶體中,當記憶體不夠時即可被回收。這個特效其實就是很多系統緩存中用到的。

為了實現上述特效,Java中對引用進行了擴展,將引用分為了強引用、軟引用、弱引用和虛引用4種,其引用強度依次減弱。

強引用:即我們一般的new出來的物件引用即為強引用軟引用:用來描述一些還有用但不必需的物件,對於軟引用關聯的物件,當系統記憶體不足時會將這些物件列入到回收範圍內進行回收。通過SoftReference類實現軟引用弱引用:用來描述非必需的物件,被弱引用關聯的物件只能生產到下一次垃圾收集發生之前,但是因為垃圾收集器的執行緒優先順序低,所以他也不一定會被回收。通過WeakReference類實現弱引用虛引用:最弱的引用,一個物件是否有虛引用對其生存時間毫無影響,也無法通過虛引用來獲取到一個物件實例,它的唯一作用就是能夠在這個物件被回收時收到一個系統通知。通過PhantomReference類實現虛引用。

3.3 怎麼回收?

利用垃圾收集器進行回收。不同的垃圾收集器採用的收集演算法或許不同,而這也會使得其收集時的細節不同。

3.3.1下面主要描述幾種常用的收集演算法:

(1)標記-清除演算法:

1)標記:標記出所有需要回收的物件

2)清除:在標記完成後統一回收被標記的物件。

上述圖片其實將這個演算法的主要缺點暴露無遺,可以發現回收後會產生大量不連續的記憶體碎片,空間碎片太多會導致以後在程式分配較大物件時無法找到連續記憶體而提前出發另一次垃圾回收,此外標記和清除過程效率也不高。

(2)複製演算法

複製演算法將可用記憶體分為了大小相等的兩塊,每次我們只使用其中的一塊,當這一塊記憶體用完了,我們將這一塊還存活的物件複製到另一塊中,然後將已使用過的那一塊記憶體空間一次性清空。

這樣我們相當於每次只對其中一塊進行記憶體回收,並且不會產生碎片,只需要一移動堆頂的指標,按順序分配記憶體即可。但缺點很明顯:我們可以使用的記憶體縮小為原來的一半。

現在的商業虛擬機器都採用這種演算法來回收新生代,不過與之不同的是,它是將新生代的記憶體分為較大的Eden空間和兩塊較小的Survivor控制項,每次使用Eden和其中一塊Survivor空間。當回收時,將Eden和Survivor中還存活的物件一次性複製到另外一塊Survivor上,最後清理到前面兩個空間中的物件。

當然了,我們無法保證每次一塊Survivor中可以供所有存活的物件存活,所有依賴於老年代的記憶體進行分配擔保。

(3)標記-整理演算法

在標記清除演算法的基礎上,提出了標記整理演算法,這個演算法在標記清除演算法的基礎上,在標記完可回收物件後,將所有存活的物件向一端移動,然後直接清理掉端邊界以外的記憶體。

(4)分代收集演算法

當前虛擬機器的垃圾收集都採用分代收集的思想,其實這個演算法的核心在於根據物件存活的週期不同將記憶體劃分為幾塊,一般分為新生代和老年代,這樣就可以根據各個年代的特點選擇合適的演算法收集。

1)在新生代中,物件朝生夕死,只有少量存活,可以選擇複製演算法。當新生代中的記憶體空間不夠時,可以依賴老年代的記憶體空間。(即老年代為新生代進行分配擔保)

2)在老年代中物件存活率高。沒有額外空間進行分配擔保,就必須使用“標記-清除”或者是“標記-整理”演算法。

3.3.2 Java的回收策略:

為了更好瞭解Java的回收策略,我們得首先對Java的記憶體分配規則。

1)Java的記憶體分配規則

Java物件的記憶體分配,即在堆上進行分配,對象主要被分配在新生代的Eden區(如果啟動了本地執行緒分配緩存,將按執行緒優先在TLAB上分配),少數情況下直接分配在老年代中。下面是幾條規則:

(1)對象優先在Eden分配:

大多數情況下,物件在新生代Eden區分配,當Eden去沒有足夠空間分配時,虛擬機器將發起一次Minor GC。

(2)大物件直接進行老年代:

所謂的大物件即是需要大量練習記憶體空間的Java物件,最典型的大物件就是那種很長的字串以及陣列。

(3)長期存活的物件將進入老年代:

虛擬機器為每個物件定義了一個Age計數器,當物件在Eden出生,並經過一次Minor GC仍然存活並能夠被Survivor容納,將被移動到Survivor空間中,當其在Survivor區中每熬過一次Minor GC,年齡加1,當年齡到一定歲數後即會升級到老年代中,這是第一種升級的方法。

第二種升級發方法是如果在Survivor空間中相同年齡的所有物件大小總和大於Survivor控制項的一半,年齡大於等於這個閾值的物件就可以進入老年代。

2)垃圾回收

上面提到了Minor GC,什麼是Minor GC,Minor GC是指發生在新生代的垃圾回收動作。而與之對應的Major/Full GC是指發生了老年代的GC。

前面提到了Minor GC的觸發條件,即Eden沒有足夠空間分配記憶體的時候,那什麼時候會觸發Major GC。

一般而言,當老年代的連續空間大於新生代物件總大小或者歷次升級到老年代的平均大小就會進行Minor GC,否則才會進行Major GC。

這其中就涉及到一個先前提到的概念,分配擔保。因為老年代需要為新生代分配記憶體做擔保,當老年代無法為新生代的物件分配空間進行擔保時,就可能會觸發Major GC,從而騰出一定空間給新生代的物件升級。

只要求其邏輯上是連續的即可。一般而言,Java堆是可擴展的(當然也可以實現為固定的),通過-Xmx和-Xms來控制。當沒有記憶體可供分配且堆也無法擴展的時候,就會拋出記憶體溢出異常

2.5 方法區

方法區用於存儲已經被載入的類資訊、常量、靜態變數和即時編譯器編譯後的代碼等。方法區也常被人們稱為永久代,當然主要原因是因為在這塊區域中發生垃圾收集行為比較少。在Jdk1.7已經著手去永久代了,而在Jdk1.8中已經將永久代替換為了元空間(Metaspace)

運行時常量池是方法區的一部分,用於存放編譯器生成的各種字面量和符號引用。

因為是執行緒私有,程式計數器、虛擬機器棧和本地方法棧隨執行緒而生,隨執行緒而死,每一個棧幀隨著方法的進入和退出有序地執行出棧和入棧的操作,每一個棧幀分配的記憶體也是在編譯期可知的,因此,因為這些區域的記憶體分配和回收具有確定性,所以我們不需要考慮回收的問題。(當方法或者執行緒結束的時候,記憶體自然也就回收了)

三、垃圾收集器及記憶體回收

在談垃圾收集器之前,我們首先需要明確的是:哪些記憶體需要回收?什麼時候回收?怎麼回收?

3.1 哪些垃圾需要回收?

我們怎麼判斷哪些是需要回收的垃圾呢?正如前面提到的,GC的重點是在Java堆中,那麼在垃圾收集器在對堆中的物件進行回收前,第一件需要判斷的就是哪些物件已死(即不可能再被任何途徑使用的物件)

怎麼判斷物件是否存活,不得不提一下廣為人知的引用計數法,即給一個物件添加一個引用計數器,每引用一次,計數器值加1,引用失效時,計數器值減1,當值為0時,該對象死亡。然而這個方法固然高效,但卻存在一個很大的問題,它很難解決物件間的相互迴圈引用,即A引用B,B引用A,但其實二者都沒有其他地方被引用,其二者已經不可能被訪問到了,從合理性角度,這兩個物件已經死了。

下面請出我們要介紹了主角,也是在Java虛擬機器中所採用的方法---可達性分析演算法

這個演算法的基本思想就是通過一系列被稱為“GC Roots”物件作為起始點,從這些節點向下所示,走過的路徑被稱為引用鏈,游離在外的對象即為不可用的對象。

那麼顯然這個方法的關鍵在於那些物件是可以作為GC Root:

1)虛擬機器棧中引用的物件

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

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

4)本地方法棧中native方法引用的物件。

3.2 什麼時候需要回收?

從一般來講什麼時候需要回收,即當物件已死的時候需要回收,但從嚴格意義上來講,真正宣告一個物件死亡需要經歷上述兩次不可達的標記才會導致這個物件被收集。

當物件第一次被標記為不可達時,會對它進行一次篩選,判斷其是否有必要執行finalize方法,當物件沒有覆蓋finalize方法或者finalize方法已經被虛擬機器調用過了,則不會執行finalize方法。

那我們就可以在finalize方法中對物件進行最後的自救了,即在finalize方法中為物件和GC ROOT的引用鏈中的任一物件建立關聯即可。

除了這個,因為考慮到記憶體的有限性,不僅僅是物件死亡後才需要回收,為了有效利用記憶體,我們還需要有一些具有類似性質的物件,在記憶體足夠時可以保留在記憶體中,當記憶體不夠時即可被回收。這個特效其實就是很多系統緩存中用到的。

為了實現上述特效,Java中對引用進行了擴展,將引用分為了強引用、軟引用、弱引用和虛引用4種,其引用強度依次減弱。

強引用:即我們一般的new出來的物件引用即為強引用軟引用:用來描述一些還有用但不必需的物件,對於軟引用關聯的物件,當系統記憶體不足時會將這些物件列入到回收範圍內進行回收。通過SoftReference類實現軟引用弱引用:用來描述非必需的物件,被弱引用關聯的物件只能生產到下一次垃圾收集發生之前,但是因為垃圾收集器的執行緒優先順序低,所以他也不一定會被回收。通過WeakReference類實現弱引用虛引用:最弱的引用,一個物件是否有虛引用對其生存時間毫無影響,也無法通過虛引用來獲取到一個物件實例,它的唯一作用就是能夠在這個物件被回收時收到一個系統通知。通過PhantomReference類實現虛引用。

3.3 怎麼回收?

利用垃圾收集器進行回收。不同的垃圾收集器採用的收集演算法或許不同,而這也會使得其收集時的細節不同。

3.3.1下面主要描述幾種常用的收集演算法:

(1)標記-清除演算法:

1)標記:標記出所有需要回收的物件

2)清除:在標記完成後統一回收被標記的物件。

上述圖片其實將這個演算法的主要缺點暴露無遺,可以發現回收後會產生大量不連續的記憶體碎片,空間碎片太多會導致以後在程式分配較大物件時無法找到連續記憶體而提前出發另一次垃圾回收,此外標記和清除過程效率也不高。

(2)複製演算法

複製演算法將可用記憶體分為了大小相等的兩塊,每次我們只使用其中的一塊,當這一塊記憶體用完了,我們將這一塊還存活的物件複製到另一塊中,然後將已使用過的那一塊記憶體空間一次性清空。

這樣我們相當於每次只對其中一塊進行記憶體回收,並且不會產生碎片,只需要一移動堆頂的指標,按順序分配記憶體即可。但缺點很明顯:我們可以使用的記憶體縮小為原來的一半。

現在的商業虛擬機器都採用這種演算法來回收新生代,不過與之不同的是,它是將新生代的記憶體分為較大的Eden空間和兩塊較小的Survivor控制項,每次使用Eden和其中一塊Survivor空間。當回收時,將Eden和Survivor中還存活的物件一次性複製到另外一塊Survivor上,最後清理到前面兩個空間中的物件。

當然了,我們無法保證每次一塊Survivor中可以供所有存活的物件存活,所有依賴於老年代的記憶體進行分配擔保。

(3)標記-整理演算法

在標記清除演算法的基礎上,提出了標記整理演算法,這個演算法在標記清除演算法的基礎上,在標記完可回收物件後,將所有存活的物件向一端移動,然後直接清理掉端邊界以外的記憶體。

(4)分代收集演算法

當前虛擬機器的垃圾收集都採用分代收集的思想,其實這個演算法的核心在於根據物件存活的週期不同將記憶體劃分為幾塊,一般分為新生代和老年代,這樣就可以根據各個年代的特點選擇合適的演算法收集。

1)在新生代中,物件朝生夕死,只有少量存活,可以選擇複製演算法。當新生代中的記憶體空間不夠時,可以依賴老年代的記憶體空間。(即老年代為新生代進行分配擔保)

2)在老年代中物件存活率高。沒有額外空間進行分配擔保,就必須使用“標記-清除”或者是“標記-整理”演算法。

3.3.2 Java的回收策略:

為了更好瞭解Java的回收策略,我們得首先對Java的記憶體分配規則。

1)Java的記憶體分配規則

Java物件的記憶體分配,即在堆上進行分配,對象主要被分配在新生代的Eden區(如果啟動了本地執行緒分配緩存,將按執行緒優先在TLAB上分配),少數情況下直接分配在老年代中。下面是幾條規則:

(1)對象優先在Eden分配:

大多數情況下,物件在新生代Eden區分配,當Eden去沒有足夠空間分配時,虛擬機器將發起一次Minor GC。

(2)大物件直接進行老年代:

所謂的大物件即是需要大量練習記憶體空間的Java物件,最典型的大物件就是那種很長的字串以及陣列。

(3)長期存活的物件將進入老年代:

虛擬機器為每個物件定義了一個Age計數器,當物件在Eden出生,並經過一次Minor GC仍然存活並能夠被Survivor容納,將被移動到Survivor空間中,當其在Survivor區中每熬過一次Minor GC,年齡加1,當年齡到一定歲數後即會升級到老年代中,這是第一種升級的方法。

第二種升級發方法是如果在Survivor空間中相同年齡的所有物件大小總和大於Survivor控制項的一半,年齡大於等於這個閾值的物件就可以進入老年代。

2)垃圾回收

上面提到了Minor GC,什麼是Minor GC,Minor GC是指發生在新生代的垃圾回收動作。而與之對應的Major/Full GC是指發生了老年代的GC。

前面提到了Minor GC的觸發條件,即Eden沒有足夠空間分配記憶體的時候,那什麼時候會觸發Major GC。

一般而言,當老年代的連續空間大於新生代物件總大小或者歷次升級到老年代的平均大小就會進行Minor GC,否則才會進行Major GC。

這其中就涉及到一個先前提到的概念,分配擔保。因為老年代需要為新生代分配記憶體做擔保,當老年代無法為新生代的物件分配空間進行擔保時,就可能會觸發Major GC,從而騰出一定空間給新生代的物件升級。