理解Java記憶體區域與垃圾收集器

升級之路發表於2019-04-10

本文目錄結構

  • java記憶體區域
    • 執行時記憶體區域
    • 物件訪問
  • 垃圾收集器
    • 判斷物件死亡
    • 方法區回收
    • GC回收演算法
    • 空間分配擔保
  • 參考

java記憶體區域

執行時記憶體區域

java虛擬機器在執行java程式的過程中會把它所管理的記憶體劃分為若干個不同的資料區域。

java記憶體區域
我們注意到執行時區域主要會包括5部分割槽域,它們有個各自的用途,以及建立和銷燬時間,有的依賴虛擬機器程式,有的依賴使用者執行緒。

  • 程式計數器 程式計數器是一塊較小的記憶體空間,它的作用是當前執行緒所執行到的位元組碼的位置指示器。位元組碼直譯器工作時就是通過改變計數器的值來選取下一條需要執行的位元組碼指令,從而達到分支、迴圈、跳轉、異常處理等基本功能。 java虛擬機器中的多執行緒實際是通過執行緒輪流切換實現的。所以實際上在同一時刻,處理器的一個核心只會執行一條指令。因此為了執行緒切換後還能恢復到正確的執行位置,需要每個執行緒都要有一個獨立的程式計數器。而且他們之間互補影響,獨立工作。所以程式計數器是一塊執行緒私有的記憶體。
  • 本地方法棧 與程式計數器一樣,本地方法棧也是執行緒私有的。本地方法棧為虛擬機器提供使用Native方法服務。由於虛擬機器規範並沒有對本地方法棧中的使用語言和資料結構等做強制規定,所以虛擬機器可以自由實現。
  • java虛擬機器棧 同本地方法棧,虛擬機器棧是執行緒私有,它的生命週期與當前執行緒相同。它為虛擬機器執行java方法提供服務。它描述的記憶體模型:每個方法被執行的時候會同時建立一個棧楨,用於儲存區域性變數、操作棧、動態連結、方法出口等資訊。每個方法的呼叫到返回結果的過程,就是對應一個棧楨的入棧與出棧。 經常有人會說java記憶體可以粗糙的區分為堆和棧,這裡的棧就是虛擬機器棧,而虛擬機器棧中最重要的就是區域性變數表。 區域性變數表存放了編譯期可知的基本資料型別、物件的引用(reference型別,它可能只想物件起始地址的引用指標,也可能指向代表改物件的控制程式碼)。區域性變數表所需要的記憶體在編譯期完成分配,當進入一個方法時,此方法所需要的記憶體空間大小是確定的,所以在方法執行期間,不會改變區域性變數表的大小。
  • java堆 對於虛擬機器來說,堆是其所管理的最大的一塊記憶體。java堆是指被執行緒共享的一塊記憶體區域,它在虛擬機器啟動時即建立,堆的唯一目的時存放物件例項。同時由於堆空間有限,物件的建立和銷燬是時常發生的,所以java堆是垃圾收集器的主要管理區域,所以java堆有時也會稱為GC堆。現在的GC回收基本都採用分代回收演算法,所以堆可以細分為新生代和老年代,新生代又可以分為eden區,from Survivor空間和to Survivor空間等。對於堆中的各個區域分配和回收細節,在GC部分講解。 在虛擬機器規範中,沒有強制要求堆是實體記憶體連續的,只是邏輯上連續即可。所以當前的主流虛擬機器的堆空間都是可以動態擴容的,可以通過-Xmx和-Xms控制。
  • 方法區 方法區同java堆都是執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。java虛擬機器實現規範對該區域並沒有強制要求實現GC回收,所以相對而言,該區域的垃圾收集器很少出現,所以有人開發者會成稱該區域為永久代。這個區域的記憶體回收主要是針對常量池的回收和對型別的解除安裝。 執行時常量池 一個class檔案除了有類的版本、欄位、方法、介面等描述以外,還有一項是常量池,用於存放編譯期間生成的各種字面量和符號引用,這部分內容將在類載入後存放到方法區的執行時常量池中。執行時常量池是具有動態性的,java虛擬機器對class檔案的每一部分的格式有嚴格的規定,每個位元組用於儲存哪種資料都有規範要求,這樣才會被虛擬機器認可。但是對於常量池是比較寬鬆的,因為java並不要求常量一定要編譯期產生,也可以在執行期間放入常量,比如String的intern()方法。

物件訪問

在java虛擬機器棧中我們提到區域性變數表存放了物件的引用,我們都知道物件是分配的java堆中的,那麼具體是怎麼引用的呢? 比如Object obj = new Object();,假設這句程式碼出現在方法體中,那麼“Object obj ”這部分語義將會反映到java棧的本地變數表中(為reference型別),而“new Object()”這部分語義將會反映在java堆上,形成一塊儲存了Object型別所有例項資料值的結構化記憶體。 由於reference型別在java虛擬機器規範中只規定了一個指向物件的引用,所以在實際虛擬機器中訪問會有所不同,主流訪問有兩種:

  • 控制程式碼訪問
  • 直接指標訪問
控制程式碼訪問

java堆會劃分出一小塊記憶體空間作為控制程式碼池,reference中儲存的就是物件的控制程式碼地址,二控制程式碼中包含了物件例項資料和型別資料的各自地址資訊。

image.png

直接地址訪問

reference中直接儲存的就是物件的地址,java堆需要考慮物件的佈局中如何存放訪問型別資料的相關資訊。

image.png
這兩種物件的訪問方式各有優勢,使用控制程式碼訪問方式的最大好處就是reference中儲存的是穩定的控制程式碼地址,在物件被移動時只會改變控制程式碼中的例項資料指標,而reference本身不需要被修改。使用直接指標訪問方式的最大好處就是速度更快,它節省了一次指標定位的的時間開銷。

垃圾收集器

判斷物件死亡

GC在對堆記憶體進行回收前,第一件事是需要確定哪些物件是需要被回收的,所以就需要判斷物件是否存活。一般的有兩種方法來判斷:

  1. 引用計數法 給一個物件新增一個引用計數器,每當有地方對其引用時,計數器加1,當引用實效時,計數器減1,任何時刻計數器為0時就表示該物件不再被使用。 引用計數法實現簡單,通常是比較高效的,但是引用計數法有個弊端是當兩個不再被使用的物件互相引用時,導致兩者都不會被釋放。
  2. 根搜尋演算法 根搜尋演算法是指通過一系列名為“GC roots"的物件為起點,從這些節點開始向下搜尋,搜尋過的路徑稱為引用鏈,當一個物件到GC roots沒有任何引用鏈相連,就表示此物件不再被使用。 在java語言中,作為GC roots的物件包括以下幾種: a. java虛擬機器棧(棧楨中本地變數表)中引用的物件 b. 方法區中類靜態屬性引用的物件 c. 方法區中常量引用的物件 d. 本地方法棧中JNI引用的物件

方法區回收

前面已經提到方法區是很少出現垃圾收集器的,因為方法區回收的價效比比較低,通常堆記憶體的回收一次可以回收70%-95%的空間,但方法區的垃圾收集器效率很低。 一般的,方法區回收主要由兩部分: 1.廢棄常量 廢棄的常量與堆回收比較類似,只需要指導該常量是否在其他地方被使用即可。 2.無用的類 這種情況的判斷比較苛刻,一般要求滿足以下三個條件才算是無用的: a. 該類的所有例項都被回收 b. 載入該類的ClassLoader也被回收 c. 該類對應的java.lang.class物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類

GC回收演算法

1.標記-清除演算法 最基礎的收集演算法是“標記-清除”(Mark-Sweep)演算法,如同它的名字一樣,演算法分為“標記”和“清除”兩個階段。 a. 首先標記出所有需要回收的物件 b. 在標記完成後統一回收所有被標記的物件。 缺點: 效率問題:標記和清除兩個過程的效率都不高 空間問題:標記清除之後產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

標記-清除演算法

2.複製演算法 目的是為了解決效率問題。 將可用記憶體按容量大小劃分為大小相等的兩塊,每次只使用其中的一塊。當一塊記憶體使用完了,就將還存活著的物件複製到另一塊上面,然後再把已使用過的記憶體空間一次清理掉。這樣使得每次都是對整個半區進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況。 缺點: 將記憶體縮小為了原來的一半。

複製演算法
現代的商業虛擬機器都採用這種收集演算法來回收新生代,IBM公司的專門研究表明,新生代中物件98%物件是“朝生夕死”的,所以不需要按照1:1的比例來劃分記憶體空間,而是將記憶體分為較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。

3.標記-整理演算法 複製收集演算法在物件存活率較高時,就要進行較多的複製操作,效率就會變低。 根據老年代的特點,提出了“標記-整理”演算法。 標記過程仍然與”標記-清除“演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉邊界以外的記憶體。

標記-整理演算法

4.分代收集演算法 一般是把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集演算法。在新生代中,每次垃圾收集時都發現有大批物件死去,只有少量存活,那就選用複製演算法。在老年代中,因為物件存活率高、沒有額外空間對它進行分配擔保,就必須採用“標記-清除”或“標記-整理”演算法來進行回收。JVM把年輕代分為了三部分:1個Eden區和2個Survivor區(分別叫from和to),預設比例為8:1。 工作過程:一般情況下,新建立的物件都會被分配到Eden區(一些大物件特殊處理),這些物件經過第一次GC後,如果仍然存活,將會被移到Survivor區。物件在Survivor區中每熬過一次GC,年齡就會增加1歲,當它的年齡增加到一定程度時,就會被移動到年老代中。 因為年輕代中的物件基本都是朝生夕死的(80%以上),所以在年輕代的垃圾回收演算法使用的是複製演算法,複製演算法不會產生記憶體碎片。在GC開始的時候,物件只會存在於Eden區和名為“From”的Survivor區,Survivor區“To”是空的。緊接著進行GC,Eden區中所有存活的物件都會被複制到“To”,而在“From”區中,仍存活的物件會根據他們的年齡值來決定去向。年齡達到一定值(年齡閾值,可以通過-XX:MaxTenuringThreshold來設定)的物件會被移動到年老代中,沒有達到閾值的物件會被複制到“To”區域。經過這次GC後,Eden區和From區已經被清空。這個時候,“From”和“To”會交換他們的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎樣,都會保證名為To的Survivor區域是空的。GC會一直重複這樣的過程,直到“To”區被填滿,“To”區被填滿之後,會將所有物件移動到年老代中。

空間分配擔保

先了解下Minor GC與Major GC/Full GC

  • Minor GC 即新生代GC,指發生在新生代的垃圾收集動作,Minor GC的回收的物件大多具備朝生夕滅的特性,所以Minor GC是非常頻繁,並且回收速度比較快。
  • Major GC/Full GC 即老年代GC,指發生在老年代的垃圾收集動作,出現Major GC,經常會伴隨至少一次的Minor GC。Major GC的速度一般比Minor GC慢10倍以上。

在發生Minor GC時,虛擬機器會檢測之前每次晉升到老年代的平均大小是否大於老年代的剩餘空間大小,如果大於,則改為直接進行一次Full GC,如果小於,則檢視HandlePromotionFailure設定是否允許擔保失敗,如果允許,那麼只會進行Minor GC,如果不允許,那麼進行一次Full GC。 在分代回收演算法中提到過,新生代使用複製收集演算法,但為了記憶體利用率,只使用其中一個Survivor空間來作為輪換備份,因此當出現大量物件在Minor GC後仍然存活的情況(最極端的情況就是記憶體回收後新生代中所有物件都存活),就需要老年代進行分配擔保,把Survivor無法容納的物件直接進入老年代。與生活中的貸款擔保類似,老年代要進行這樣的擔保,前提是老年代本身還有容納這些物件的剩餘空間,一共有多少物件會活下來在實際完成記憶體回收之前是無法明確知道的,所以只好取之前每一次回收晉升到老年代物件容量的平均大小值作為經驗值,與老年代的剩餘空間進行比較,決定是否進行Full GC來讓老年代騰出更多空間。

參考

@Dpuntu, 本文版權屬於再惠研發團隊,歡迎轉載,轉載請保留出處。

相關文章