深入理解java虛擬機 -- 堆區,學習java內存分佈這一篇就夠了

目錄

堆區(Heap):

對象的創建

虛擬機爲新生對象分配內存的兩種分方式:

併發情況下如何保證對象在虛擬機分配內存是安全的

解決這個問題有兩種可選方案:

對象的內存佈局

對象頭(Header)

實例數據(Instance Data)

對齊填充(Padding)

對象頭(Header)-- 對象頭的三部分

1. Mark Word(標記字)

此部分的用處:

2. Klass Word(類指針)

3. 數組長度

對象的訪問定位

句柄訪問

直接指針訪問

句柄訪問和直接指針訪問對比

Java堆的內存劃分

 Heap Memory 又被分爲兩大區域:

整體的詳細架構圖:

  1.Young/New Generation 新生代

新生代GC(Minor GC):

 2.Old/Tenured Generation 老年代

 當一個Object被創建後,內存申請過程如下:

Full GC的介紹:


 

堆區(Heap):


堆區是 理解Java GC機制  最重要的區域,沒有之一。在JVM所管理的內存中,堆區是最大的一塊,堆區也是Java GC機制所管理的主要內存區域,堆區由所有線程共享,在虛擬機啓動時創建。堆區的存在是爲了存儲對象實例,原則上講,所有的對象都在堆區上分配內存(不過現代技術裏,也不是這麼絕對的,也有棧上直接分配的)。

  Java堆既可以被實現成固定大小的,也可以是可擴展的,不過當前主流的Java虛擬機都是按照可擴展來實現的(通過參數-Xmx和-Xms設定)。如果在Java堆中沒有內存完成實例分配,並且堆也無法再擴展時,Java虛擬機將會拋出 OutOfMemoryError 異常。
 

堆中大部分對象都是通過new關鍵字創建對象,下面先看一下對象是如何創建的。

 

對象的創建

Java是一門面向對象的編程語言,Java程序運行過程中無時無刻都有對象被創建出來。在語言層面上,創建對象通常(例外:複製、反序列化)僅僅是一個new關鍵字而已,而在虛擬機中,對象(文中討論的對象限於普通Java對象,不包括數組和Class對象等)的創建又是怎樣一個過程呢?當Java虛擬機遇到一條字節碼new指令時,首先將去檢查這個指令的參數是否能在常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已被加載、解析和初始化過。如果沒有,那必須先執行相應的類加載過程。

 

虛擬機爲新生對象分配內存的兩種分方式:

 

在類加載檢查通過後,接下來虛擬機將爲新生對象分配內存。對象所需內存的大小在類加載完成後便可完全確定,爲對象分配空間的任務實際上便等同於把一塊確定大小的內存塊從Java堆中劃分出來。

  1. 假設Java堆中內存是絕對規整的,所有被使用過的內存都被放在一邊,空閒的內存被放在另一邊,中間放着一個指針作爲分界點的指示器,那所分配內存就僅僅是把那個指針向空閒空間方向挪動一段與對象大小相等的距離,這種分配方式稱爲“指針碰撞”(Bump The Pointer)
  2. 但如果Java堆中的內存並不是規整的,已被使用的內存和空閒的內存相互交錯在一起,那就沒有辦法簡單地進行指針碰撞了,虛擬機就必須維護一個列表,記錄上哪些內存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,並更新列表上的記錄,這種分配方式稱爲 “空閒列表”(Free List)

選擇哪種分配方式由Java堆是否規整決定, 而Java堆是否規整又由所採用的垃圾收集器是否帶有空間壓縮整理(Compact)的能力決定。因此,當使用Serial、ParNew等帶壓縮整理過程的收集器時,系統採用的分配算法是指針碰撞,既簡單又高效;而當使用CMS這種基於清除(Sweep)算法的收集器時,理論上就只能採用較爲複雜的空閒列表來分配內存。

 

指針碰撞(Bump The Pointer):

 

空閒列表(Free List):

 

 

併發情況下如何保證對象在虛擬機分配內存是安全的

 

除如何劃分可用空間之外,還有另外一個需要考慮的問題:對象創建在虛擬機中是非常頻繁的行爲,即使僅僅修改一個指針所指向的位置,在併發情況下也並不是線程安全的,可能出現正在給對象A分配內存,指針還沒來得及修改,對象B又同時使用了原來的指針來分配內存的情況。

 

解決這個問題有兩種可選方案:

  1. 一種是對分配內存空間的動作進行同步處理——實際上虛擬機是採用CAS配上失敗重試的方式保證更新操作的原子性
  2. 另外一種是把內存分配的動作按照線程劃分在不同的空間之中進行,即每個線程在Java堆中預先分配一小塊內存,稱爲本地線程分配緩衝(Thread Local AllocationBuffer,TLAB),哪個線程要分配內存,就在哪個線程的本地緩衝區中分配,只有本地緩衝區用完了,分配新的緩存區時才需要同步鎖定。

概況一下內存保證分配安全的方式就是:

  1. CAS+失敗重試  (關於CAS的可以看一下另一篇博客  高併發編程 -- Java中CAS詳解
  2. TLAB 

虛擬機是否使用TLAB,可以通過-XX:+/-UseTLAB參數來設定。

 

CAS+失敗重試
首先我們說下CAS+失敗重試的方式,我們知道CAS是樂觀鎖的一種實現方式,通過比對原值和舊的預期值來確定是否要將原值更改爲新值,如果通過CAS來實現線程安全,那麼需要三個因子,首先是在主內存中的一個原值,然後第二個是這個原值在各個線程中的副本,再接下來就是新值。

如果採用的是指針碰撞的方式進行對象的內存分配,那麼這個原值就是當前指針的位置,假設說現在是初始化狀態,那麼指針的位置就是0,也就是原值就是0,這個0會在主內存中存放着,假設現在有兩個線程A和B,每個線程都在執行着創建對象的操作,這兩個線程中保存着原值的副本,此時的原值是0,副本也是0,那麼新值呢?新值就是需要的內存量,假設說對象A需要5的內存,對象B需要7的內存。

現在兩個線程開始創建對象,我們採用CAS+失敗重試的方法來確保線程安全。

首先A線程開始創建對象,此時觸發樂觀鎖的機制,A讀取到了目前的內存情況,也就是指針的初始位置0,因爲是樂觀鎖,所以在沒有提交的時候並不會觸發衝突檢查,這個時候時間切換到線程B,線程B也開始創建對象,同樣讀取到了目前的內存情況,同樣的指針位置是0,因爲沒有提交,同樣不會觸發衝突檢查,但是線程的工作是CPU輪流安排時間片進行的,同一時間只會有一條線程執行任務,這個時候又切換到線程A,A開始提交,提交的時候觸發了衝突檢查,對原值以及舊的預期值進行比對,原值是0,預期原值也就是線程裏面的原值副本,此時線程A的舊的預期值也是0,比對通過,將新值賦給原值,新值是5,那麼指針就會移動5個單位,此時指針的位置就是在5,同時將線程A的預期值更改爲5,現在時間再次切換到線程B,線程B開始提交,觸發樂觀鎖機制,進行衝突檢查,此時的原值是5,線程B的舊的預期值是0,比對不通過,此時虛擬機什麼都不做,只是把線程B的舊的預期值更新爲5,然後觸發失敗重試,線程B會再次嘗試提交,再次出發衝突檢查,那麼這個時候檢查就通過了,通過之後將新值賦給原值,需要注意的地方是,這個賦值並不是把5改變成7,而是將指針移動7個單位,移動到12,然後再把線程B的舊的預期值更新爲12,以此類推。

那麼如果是空閒列表呢?又是如何通過CAS+失敗重試的方式來保證線程安全呢。

其實道理是一樣的,同樣的AB兩個線程都需要創建對象分配空間,A需要5,B需要7。

首先是A線程,此時的原值是可用的內存空間0-100,A和B的舊的預期值也都是0-100,這個時候A先去創建對象,檢查通過後,虛擬機分配了7-12的內存給了A線程的對象,同時將原值以及A線程的舊的預期值更改爲0-7,12-100,這個時候B再去提交創建對象的時候,舊的預期值是0-100,與0-7,12-100比對不通過,將預期值更新爲0-7,7-12,然後觸發失敗重試,再次提交的時候比對通過,提交成功,更新原值和線程B的預期值,以此類推。


TLAB
那麼如果是TLAB的方式呢?

TLAB,全文Thread Local Allocation Buffer,本地線程分配緩衝,每個線程都會在Eden空間申請到一個TLAB,大小佔Eden空間的1%,當然申請這個空間的過程是線程同步的,這個同步的實現也是依賴於CAS+失敗重試的方式,具體我們就不再詳細介紹,只是原值和新值不同而已,可以把TLAB想象成一個對象,佔用內存大小就是Eden空間的1%即可。

當這個線程需要創建對象的時候,直接在TLAB裏面創建就行了,這樣就避免因併發而導致的線程安全問題。

當然,有這樣的一種情況,現在這個線程需要創建一個對象,但是當前的TLAB的空間不足了,怎麼辦,它會再向Eden空間去申請一個TLAB,申請的過程是線程同步的,它會把這個對象放到新的TLAB中,也就是說一個線程並不是只有一個TLAB。

那如果這個對象特別大,哪怕是一個新的TLAB也放不下呢?直到這個時候,線程纔會去把對象直接創建在Eden空間,再次採用CAS+失敗重試的方法去保證線程同步。

也就是說,採用這種方式,線程會向Eden空間申請線程私有的TLAB來創建對象,確保線程安全,除非說現在的TLAB不夠用了,再去申請新的TLAB的時候纔會同步鎖定,或者說是對象特別大,一個全新的TLAB空間都裝不下了,必須去Eden空間創建,纔會同步鎖定。

但一般來說,創建的對象都是特別小的,也都是會迅速銷燬的,所以這種方式從效率上來講還是比較高的。

 

內存分配完成之後,虛擬機必須將分配到的內存空間(但不包括對象頭)都初始化爲零值,如果使用了TLAB的話,這一項工作也可以提前至TLAB分配時順便進行。這步操作保證了對象的實例字段在Java代碼中可以不賦初始值就直接使用,使程序能訪問到這些字段的數據類型所對應的零值。

接下來,Java虛擬機還要對對象進行必要的設置,例如這個對象是哪個類的實例、如何才能找到類的元數據信息、對象的哈希碼(實際上對象的哈希碼會延後到真正調用Object::hashCode()方法時才計算)、對象的GC分代年齡等信息。這些信息存放在對象的對象頭(Object Header)之中。根據虛擬機當前運行狀態的不同,如是否啓用偏向鎖等,對象頭會有不同的設置方式。

在上面工作都完成之後,從虛擬機的視角來看,一個新的對象已經產生了。但是從Java程序的視角看來,對象創建纔剛剛開始——構造函數,即Class文件中的<init>()方法還沒有執行,所有的字段都爲默認的零值,對象需要的其他資源和狀態信息也還沒有按照預定的意圖構造好。

 

對象的內存佈局

在HotSpot虛擬機裏,對象在堆內存中的存儲佈局可以劃分爲三個部分:

  • 對象頭(Header)
  • 實例數據(Instance Data)
  • 對齊填充(Padding)。

對象的幾個部分的作用:

1.對象頭中的 Mark Word(標記字) 主要用來表示對象的線程鎖狀態,另外還可以用來配合GC存放該對象的hashCode

2.Klass Word是一個指向方法區中Class信息的指針,意味着該對象可隨時知道自己是哪個Class的實例;

3.數組長度也是佔用64位(8字節)的空間,這是可選的,只有當本對象是一個數組對象時纔會有這個部分;

4.對象體是用於保存對象屬性和值的主體部分,佔用內存空間取決於對象的屬性數量和類型;

5.對齊字是爲了減少堆內存的碎片空間。
 

 

對象頭(Header)

HotSpot虛擬機對象的對象頭部分包括兩類信息。第一類是用於存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等,這部分數據的長度在32位和64位的虛擬機(未開啓壓縮指針)中分別爲32個比特和64個比特,官方稱它爲“Mark Word”。對象需要存儲的運行時數據很多,其實已經超出了32、64位Bitmap結構所能記錄的最大限度,但對象頭裏的信息是與對象自身定義的數據無關的額外存儲成本,考慮到虛擬機的空間效率,Mark Word被設計成一個有着動態定義的數據結構,以便在極小的空間內存儲儘量多的數據,根據對象的狀態複用自己的存儲空間。例如在32位的HotSpot虛擬機中,如對象未被同步鎖鎖定的狀態下,Mark Word的32個比特存儲空間中的25個比特用於存儲對象哈希碼,4個比特用於存儲對象分代年齡,2個比特用於存儲鎖標誌位,1個比特固定爲0,在其他狀態(輕量級鎖定、重量級鎖定、GC標記、可偏向)下對象的存儲內容

 

對象頭的另外一部分是類型指針,即對象指向它的類型元數據的指針,Java虛擬機通過這個指針來確定該對象是哪個類的實例。並不是所有的虛擬機實現都必須在對象數據上保留類型指針,換句話說,查找對象的元數據信息並不一定要經過對象本身,這點我們會在下一節具體討論。此外,如果對象是一個Java數組,那在對象頭中還必須有一塊用於記錄數組長度的數據,因爲虛擬機可以通過普通Java對象的元數據信息確定Java對象的大小,但是如果數組的長度是不確定的,將無法通過元數據中的信息推斷出數組的大小。

HotSpot虛擬機代表Mark Word中的代碼(markOop.cpp)註釋片段,它描述了32位虛擬機Mark Word的存儲佈局:

 

實例數據(Instance Data)

實例數據部分是對象真正存儲的有效信息,即我們在程序代碼裏面所定義的各種類型的字段內容,無論是從父類繼承下來的,還是在子類中定義的字段都必須記錄起來。

這部分的存儲順序會受到虛擬機分配策略參數(-XX:FieldsAllocationStyle參數)和字段在Java源碼中定義順序的影響。HotSpot虛擬機默認的分配順序爲longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary ObjectPointers,OOPs),從以上默認的分配策略中可以看到,相同寬度的字段總是被分配到一起存放,在滿足這個前提條件的情況下,在父類中定義的變量會出現在子類之前。如果HotSpot虛擬機的+XX:CompactFields參數值爲true(默認就爲true),那子類之中較窄的變量也允許插入父類變量的空隙之中,以節省出一點點空間。

對齊填充(Padding)

對象的第三部分是對齊填充,這並不是必然存在的,也沒有特別的含義,它僅僅起着佔位符的作用。由於HotSpot虛擬機的自動內存管理系統要求對象起始地址必須是8字節的整數倍,換句話說就是任何對象的大小都必須是8字節的整數倍。對象頭部分已經被精心設計成正好是8字節的倍數(1倍或者2倍),因此,如果對象實例數據部分沒有對齊的話,就需要通過對齊填充來補全。

 

對象頭(Header)-- 對象頭的三部分

1. Mark Word(標記字)

Java對象處於5種不同狀態時,Mark Word中64個位的表現形式,上面每一行代表對象處於某種狀態時的樣子。

lock: 2位的鎖狀態標記位,由於希望用儘可能少的二進制位表示儘可能多的信息,所以設置了lock標記。該標記的值不同,整個Mark Word表示的含義不同。biased_lock和lock一起,表達的鎖狀態含義如下:

biased_lock:對象是否啓用偏向鎖標記,只佔1個二進制位。爲1時表示對象啓用偏向鎖,爲0時表示對象沒有偏向鎖。lock和biased_lock共同表示對象處於什麼鎖狀態。

age:4位的Java對象年齡。在GC中,如果對象在Survivor區複製一次,年齡增加1。當對象達到設定的閾值時,將會晉升到老年代。默認情況下,並行GC的年齡閾值爲15,併發GC的年齡閾值爲6。由於age只有4位,所以最大值爲15,這就是-XX:MaxTenuringThreshold選項最大值爲15的原因。

identity_hashcode:31位的對象標識hashCode,採用延遲加載技術。調用方法System.identityHashCode()計算,並會將結果寫到該對象頭中。當對象加鎖後(偏向、輕量級、重量級),MarkWord的字節沒有足夠的空間保存hashCode,因此該值會移動到管程Monitor中。

thread:持有偏向鎖的線程ID。

epoch:偏向鎖的時間戳。

ptr_to_lock_record:輕量級鎖狀態下,指向棧中鎖記錄的指針。

ptr_to_heavyweight_monitor:重量級鎖狀態下,指向對象監視器Monitor的指針。
 

通常說的通過synchronized實現的同步鎖,真實名稱叫做重量級鎖。但是重量級鎖會造成線程排隊(串行執行),且會使CPU在用戶態和核心態之間頻繁切換,所以代價高、效率低。爲了提高效率,不會一開始就使用重量級鎖,JVM在內部會根據需要,按如下步驟進行鎖的升級:

鎖一共有4種狀態

級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨着競爭情況逐漸升級。鎖可以升級但不能降級,意味着偏向鎖升級成輕量級鎖後不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是爲了提高獲得鎖和釋放鎖的效率

 

此部分的用處:

synchronized的實現原理與應用:

在多線程併發編程中synchronized一直是元老級角色,很多人都會稱呼它爲重量級鎖。但是,隨着Java SE 1.6對synchronized進行了各種優化之後,有些情況下它就並不那麼重了。本文詳細介紹Java SE 1.6中爲了減少獲得鎖和釋放鎖帶來的性能消耗而引入的偏向鎖和輕量級鎖,以及鎖的存儲結構和升級過程。先來看下利用synchronized實現同步的基礎:Java中的每一個對象都可以作爲鎖。具體表現爲以下3種形式。

❑ 對於普通同步方法,鎖是當前實例對象。

❑ 對於靜態同步方法,鎖是當前類的Class對象。

❑ 對於同步方法塊,鎖是Synchonized括號裏配置的對象。

當一個線程試圖訪問同步代碼塊時,它首先必須得到鎖,退出或拋出異常時必須釋放鎖。那麼鎖到底存在哪裏呢?鎖裏面會存儲什麼信息呢?

從JVM規範中可以看到Synchonized在JVM裏的實現原理,JVM基於進入和退出Monitor對象來實現方法同步和代碼塊同步,但兩者的實現細節不一樣。代碼塊同步是使用monitorenter和monitorexit指令實現的,而方法同步是使用另外一種方式實現的,細節在JVM規範裏並沒有詳細說明。但是,方法的同步同樣可以使用這兩個指令來實現。

monitorenter指令是在編譯後插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束處和異常處,JVM要保證每個monitorenter必須有對應的monitorexit與之配對。任何對象都有一個monitor與之關聯,當且一個monitor被持有後,它將處於鎖定狀態。線程執行到monitorenter指令時,將會嘗試獲取對象所對應的monitor的所有權,即嘗試獲得對象的鎖。

synchronized用的鎖是存在 Java對象頭裏的 。如果對象是數組類型,則虛擬機用3個字寬(Word)存儲對象頭,如果對象是非數組類型,則用2字寬存儲對象頭。在32位虛擬機中,1字寬等於4字節,即32bit

 

 

2. Klass Word(類指針)


這一部分用於存儲對象的類型指針,該指針指向它的類元數據,JVM通過這個指針確定對象是哪個類的實例。該指針的位長度爲JVM的一個字大小,即32位的JVM爲32位,64位的JVM爲64位。
如果應用的對象過多,使用64位的指針將浪費大量內存,統計而言,64位的JVM將會比32位的JVM多耗費50%的內存。爲了節約內存可以使用選項+UseCompressedOops開啓指針壓縮,其中,oop即ordinary object pointer普通對象指針。開啓該選項後,下列指針將壓縮至32位:

每個Class的屬性指針(即靜態變量)
每個對象的屬性指針(即對象變量)
普通對象數組的每個元素指針
當然,也不是所有的指針都會壓縮,一些特殊類型的指針JVM不會優化,比如指向PermGen的Class對象指針(JDK8中指向元空間的Class對象指針)、本地變量、堆棧元素、入參、返回值和NULL指針等。
 

3. 數組長度


 如果對象是一個數組,那麼對象頭還需要有額外的空間用於存儲數組的長度,這部分數據的長度也隨着JVM架構的不同而不同:32位的JVM上,長度爲32位;64位JVM則爲64位。64位JVM如果開啓+UseCompressedOops選項,該區域長度也將由64位壓縮至32位。
 

 

對象的訪問定位

對象訪問方式也是由虛擬機實現而定的,主流的訪問方式主要有使用句柄和直接指針兩種:

  1. 句柄訪問
  2. 直接指針訪問

 

句柄訪問

如果使用句柄訪問的話,Java堆中將可能會劃分出一塊內存來作爲句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自具體的地址信息

直接指針訪問

如果使用直接指針訪問的話,Java堆中對象的內存佈局就必須考慮如何放置訪問類型數據的相關信息,reference中存儲的直接就是對象地址,如果只是訪問對象本身的話,就不需要多一次間接訪問的開銷

 

句柄訪問和直接指針訪問對比

使用句柄來訪問:最大好處就是reference中存儲的是穩定句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行爲)時只會改變句柄中的實例數據指針,而reference本身不需要被修改。

使用直接指針來訪問:最大的好處就是速度更快,它節省了一次指針定位的時間開銷,由於對象訪問在Java中非常頻繁,因此這類開銷積少成多也是一項極爲可觀的執行成本,就本書討論的主要虛擬機HotSpot而言,它主要使用第二種方式進行對象訪問(有例外情況,如果使用了Shenandoah收集器的話也會有一次額外的轉發,具體可參見第3章),但從整個軟件開發的範圍來看,在各種語言、框架中使用句柄來訪問的情況也十分常見。

總結:

句柄來訪問,方便 reference。

直接指針來訪問,降低指針定位開銷。

 

 

Java堆的內存劃分

Java堆是被所有線程共享的一塊內存區域,所有對象和數組都在堆上進行內存分配。爲了進行高效的垃圾回收,虛擬機把堆內存劃分成新生代、老年代和永久代(1.8中無永久代,使用metaspace實現)三塊區域。

 Heap Memory 又被分爲兩大區域:

        - Young/New Generation 新生代

        新生對象放置在新生代中,新生代由Eden 與Survivor Space 組成。

        - Old/Tenured Generation 老年代

        老年代用於存放程序中經過幾次垃圾回收後還存活的對象

下面是一個

整體的詳細架構圖:

 

JDK7 之前的堆內存劃分:

Java虛擬機將堆內存劃分爲新生代、老年代和永久代,永久代是HotSpot虛擬機特有的概念(JDK1.8之後爲metaspace替代永久代),它採用永久代的方式來實現方法區,其他的虛擬機實現沒有這一概念,而且HotSpot也有取消永久代的趨勢,在JDK 1.7中HotSpot已經開始了“去永久化”,把原本放在永久代的字符串常量池移出。永久代主要存放常量、類信息、靜態變量等數據,與垃圾回收關係不大,新生代和老年代是垃圾回收的主要區域。

永久代(Permanent Generationn)

  永久代存儲類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據,對這一區域而言,Java虛擬機規範指出可以不進行垃圾收集,一般而言不會進行垃圾回收。

 

永久代和方法區

1、方法區

  方法區(Method Area)是jvm規範裏面的運行時數據區的一個組成部分,jvm規範中的運行時數據區還包含了:pc寄存器、虛擬機棧、堆、方法區、運行時常量池、本地方法棧。主要用來存儲class、運行時常量池、字段、方法、代碼、JIT代碼等。運行時數據區跟內存不是一個概念,方法區是運行時數據區的一部分。方法區是jvm規範中的一部分,並不是實際的實現,切忌將規範跟實現混爲一談。

2、永久代

  永久帶又叫Perm區,只存在於hotspot jvm中,並且只存在於jdk7和之前的版本中,jdk8中已經徹底移除了永久帶,jdk8中引入了一個新的內存區域叫metaspace。並不是所有的jvm中都有永久帶,ibm的j9,oracle的JRocket都沒有永久帶,永久帶是實現層面的東西,永久帶裏面存的東西基本上就是方法區規定的那些東西。

3、區別

  方法區是規範層面的東西,規定了這一個區域要存放哪些東西,永久帶或者是metaspace是對方法區的不同實現,是實現層面的東西。

 

 

  1.Young/New Generation 新生代


        程序中新建的對象都將分配到新生代中,新生代又由Eden(伊甸園)與兩塊Survivor(倖存者) Space 構成。Eden 與Survivor Space 的空間大小比例默認爲8:1,即當Young/New Generation 區域的空間大小總數爲10M 時,Eden 的空間大小爲8M,兩塊Survivor Space 則各分配1M,這個比例可以通過-XX:SurvivorRatio 參數來修改。Young/New Generation的大小則可以通過-Xmn參數來指定。

        Eden:剛剛新建的對象將會被放置到Eden 中,這個名稱寓意着對象們可以在其中快樂自由的生活。

        Survivor Space:倖存者區域是新生代與老年代的緩衝區域,兩塊倖存者區域分別爲s0 與s1,當觸發Minor GC 後將仍然存活的對象移動到S0中去(From Eden To s0)。這樣Eden 就被清空可以分配給新的對象。
        當再一次觸發Minor GC後,S0和Eden 中存活的對象被移動到S1中(From s0To s1),S0即被清空。在同一時刻, 只有Eden和一個Survivor Space同時被操作。所以s0與s1兩塊Survivor 區同時會至少有一個爲空閒的,這點從下面的圖中可以看出。

        當每次對象從Eden 複製到Survivor Space 或者從Survivor Space 之間複製,計數器會自動增加其值。 默認情況下如果複製發生超過16次,JVM 就會停止複製並把他們移到老年代中去。如果一個對象不能在Eden中被創建,它會直接被創建在老年代中。

 

新生代GC(Minor GC):

指發生在新生代的垃圾收集動作,因爲 Java 對象大多都具備朝生夕滅的特性,通常很多的對象都活不過一次GC,所以Minor GC 非常頻繁,一般回收速度也比較快。
        Minor GC 清理過程(圖中紅色區域爲垃圾):

 

1.清理之前

 

  2.清理之後

圖中的"From" 與"To" 只是邏輯關係而不是Survivor Space 的名稱,也就是說誰裝着對象誰就是"From"。 一個對象在倖存者區被移動/複製的次數決定了它是否會被移動到堆中。

 

 2.Old/Tenured Generation 老年代


        老年代用於存放程序中經過幾次垃圾回收後還存活的對象,例如緩存的對象等,老年代所佔用的內存大小即爲-Xmx 與-Xmn 兩個參數之差。
        堆是JVM 中所有線程共享的,因此在其上進行對象內存的分配均需要進行加鎖,這也導致了new 對象的開銷是比較大的,鑑於這樣的原因,Hotspot JVM 爲了提升對象內存分配的效率,對於所創建的線程都會分配一塊獨立的空間,這塊空間又稱爲TLAB(Thread Local Allocation Buffer),其大小由JVM 根據運行的情況計算而得,在TLAB 上分配對象時不需要加鎖,因此JVM 在給線程的對象分配內存時會盡量的在TLAB 上分配,在這種情況下JVM 中分配對象內存的性能和C 基本是一樣高效的,但如果對象過大的話則仍然是直接使用堆空間分配,TLAB 僅作用於新生代的Eden,因此在編寫Java 程序時,通常多個小的對象比大的對象分配起來更加高效,但這種方法同時也帶來了兩個問題,一是空間的浪費,二是對象內存的回收上仍然沒法做到像Stack 那麼高效,同時也會增加回收時的資源的消耗,可通過在啓動參數上增加 -XX:+PrintTLAB來查看TLAB 這塊的使用情況。

 

        老年代GC(Major GC/Full GC):指發生在老年代的GC,出現了Major GC,通常會伴隨至少一次Minor GC(但也並非絕對,在ParallelScavenge 收集器的收集策略裏則可選擇直接進行Major GC)。MajorGC 的速度一般會比Minor GC 慢10倍以上。

        虛擬機給每個對象定義了一個對象年齡(age)計數器。如果對象在 Eden 出生並經過第一次 Minor GC 後仍然存活,並且能被 Survivor 容納的話,將被移動到 Survivor 空間中,並將對象年齡設爲 1。對象在 Survivor 區中每熬過一次 Minor GC,年齡就增加 1 歲,當它的年齡增加到一定程度(默認爲 15 歲)時,就會被晉升到老年代中。對象晉升老年代的年齡閾值,可以通過參數 -XX:MaxTenuringThreshold 來設置。

 

 當一個Object被創建後,內存申請過程如下:


        1.JVM 會試圖爲相關Java 對象在Eden 中初始化一塊內存區域。
        2.當Eden 空間足夠時,內存申請結束。否則進入第三步。
        3.JVM 試圖釋放在Eden 中所有不活躍的對象(這屬於1或更高級的垃圾回收), 釋放後若Eden 空間仍然不足以放入新對象,則試圖將部分Eden 中活躍對象放入Survivor 區。
        4.Survivor 區被用來作爲新生代與老年代的緩衝區域,當老年代空間足夠時,Survivor 區的對象會被移到老年代,否則會被保留在Survivor 區。
        5.當老年代空間不夠時,JVM 會在老年代進行0級的完全垃圾收集(Major GC/Full GC)。
        6.Major GC/Full G後,若Survivor 及老年代仍然無法存放從Eden 複製過來的部分對象,導致JVM 無法在Eden 區爲新對象創建內存區域,JVM 此時就會拋出內存不足的異常。

 

上圖中,如果創建對象申請空間到最後的執行了Full GC,

Full GC的介紹:


Full GC 是發生在老年代的垃圾收集動作,所採用的是標記-清除算法。 
現實的生活中,老年代的人通常會比新生代的人 “早死”。堆內存中的老年代(Old)不同於這個,老年代裏面的對象幾乎個個都是在 Survivor 區域中熬過來的,它們是不會那麼容易就 “死掉” 了的。因此,Full GC 發生的次數不會有 Minor GC 那麼頻繁,並且做一次 Full GC 要比進行一次 Minor GC 的時間更長,一般是Minor GC的 10倍以上。 
另外,標記-清除算法收集垃圾的時候會產生許多的內存碎片 ( 即不連續的內存空間 ),此後需要爲較大的對象分配內存空間時,若無法找到足夠的連續的內存空間,就會提前觸發一次 GC 的收集動作。 

 

大型的應用系統常常會被兩個問題困擾:
        一個是啓動緩慢,因爲初始Heap 非常小,必須由很多major 收集器來調整內存大小。
        另一個更加嚴重的問題就是默認的Heap 最大值對於應用程序來說“太可憐了”。根據以下經驗法則(即拇指規則,指根據經驗大多數情況下成立,但不保證絕對):
        (1)給於虛擬機更大的內存設置,往往默認的64mb 對於現代系統來說太小了。
        (2)將-Xms 與-Xmx 設置爲相同值,這樣做的好處是GC 不用再頻繁的根據內存使用情況去動態修改Heap 內存大小了,而且只有當內存使用達到-Xmx 設置的最大值時纔會觸發垃圾收集,這給GC 及系統減輕了負擔。
        (3)當CPU 數量增加後相應也要增加物理內存的數量,因爲JVM 中有並行垃圾收集器。 

 下面是幾種斷代法可用GC彙總:

 

 

 GC 的默認使用情況:

  新生代 老年代/永久代
Client 串行收集器 串行收集器
Server 並行壓縮收集器 CMS

 

 

 

參考自:

《JVM高級特性與最佳實踐(第3版)》

https://www.jianshu.com/p/d48c9b0fc1b4

https://baijiahao.baidu.com/s?id=1639566514819627231&wfr=spider&for=pc

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章