這篇文章主要講Java記憶體的分配與回收機制,主要包括Java運行時的資料區域、物件的建立、垃圾收集演算法#與回收策略。
參考的php中文網課程《JAVA 初級入門影片教學》,筆者只是基於教程中的內容對其總結概括並圖文化。這部分內容幾乎都是理解性的,為了便於理解和記憶所以盡量以圖文的或表格的形式來展現。
一.執行階段資料區
下圖是Java虛擬機器執行時的記憶體示意圖:
從圖中我們可以看到Java記憶體總共分成6個部分:
- ##程式計數器:
每個執行緒都有一個獨立的程式計數器,計數器可以看作是目前執行緒所執行的字節碼的行號指示器。字節碼解釋器工作時,就是透過改變這個計數器的值來選取下一條所需執行的字節碼指令、分支、循環、跳轉、異常處理,線程恢復等基礎功能都需要依賴這個計數器來完成。
- Java虛擬機器堆疊:
虛擬機器堆疊是執行緒私有的,生命週期與執行緒相同。虛擬機器堆疊為Java方法執行描述記憶體模型,每個方法在執行的同時會建立一個堆疊幀用於儲存局部變數表格、運算堆疊、動態連結、方法出口等資訊。 每一個方法從呼叫直到執行完成的過程,就對應一個堆疊幀在虛擬機器堆疊中入棧到出棧的過程。
- 本地方法堆疊:與虛擬機器堆疊所發揮的作用相似。差異是虛擬機器堆疊為執行Java方法服務,
本機方法堆疊為Native方法服務。
- 堆:
所有執行緒共享的區域。在虛擬機器啟動時創建,所有的物件實例幾乎都在堆上分配。 Java堆還可細分為:新生代和老年代,再細緻一點有Eden空間、From Survivor空間、To Survivor空間。不過無論如何劃分,儲存的都是物件實例,進一步劃分的目的是為了更好的回收內存,或是更快的分配內存。
- 方法區:
方法區是各個執行緒共享的記憶體區域,主要用於儲存已被虛擬機器載入的類別資訊、常數、靜態變數、即時編譯後的程式碼等資料。 這塊區域與Java堆一樣不需要連續的記憶體和可以選擇固定大小或可擴充外,還可以選擇不實作垃圾收集。這區域的記憶體回收目標主要是針對常數池的回收和對類型的卸載,垃圾收集行為在這個區域較少出現。
- 執行時間常數池:執行時間常數池是方法區的一部份。
Class檔案中除了有類別的版本、欄位、方法、介面等描述資訊外,還有一項資訊是常數池,用於存放編譯期產生的各種字面符和符號引用,這部分內容在類別載入後進入方法區的運行時常數池中存放。
- 直接記憶體:
直接記憶體也稱為堆外內存,它不是虛擬機運行時資料區的一部份。 JDK1.4後引入NIO類,是一種基於通道(Channel)與緩衝區(Buffer)的I/O方式,它可以使用Native函數庫直接在堆外分配內存,然後透過存儲在Java堆中的DirectByteBuffer物件作為引用對這塊記憶體進行操作。這樣能夠顯著提升效能,避免Java堆和Native堆中來回複製資料。
所以透過表格的形式概括如下:
資料區域 |
概括 |
執行緒共用 |
##程式計數器 | 目前執行緒所執行的字節碼的行號指示器 | #否 |
#虛擬機器堆疊 | 為Java方法執行建立棧幀儲存局部變數、操作數棧、動態連結、方法出口等資訊 |
|
#本地方法堆疊 | 與虛擬機棧類似,為Native方法服務 | 否 |
堆 | #存放物件實例 | 是 |
#方法區 | 儲存虛擬機器已載入的類別資訊、常數、靜態變數、即時編譯後的程式碼等資料 | #是 |
運行時常數池 | 方法區的一部分,存放編譯期產生的字面量和符號引用 | #是 |
直接記憶體 | 被分配在堆外的內存,效能高,不受Java堆的大小限制 | 是 |
#二.物件的建立與記憶體佈局
1.物件的建立
Java物件的建立
#上圖是物件建立的完整流程圖,接下來做詳細說明。
當虛擬機器收到new指令後,檢查這個指令的參數是否能在常數池中定位到一個類別的符號引用,並且檢查這個符號引用所代表的類別是否已被載入、解析和初始化過。如果沒有,必須先執行類別載入過程。
在類別載入完成後可以確定物件分配所需的空間。如果Java堆中記憶體是絕對規則的,用過的內存放一邊,空閒的內存放另一邊,中間放著一個指針作為分界點的指示器,那分配內存就只是把指針向空閒空間方向挪動一段與物件大小相等的距離,這種分配方式稱為"指針碰撞"。如果Java堆中記憶體不是規則的,空閒記憶體與使用過的記憶體是交錯的,虛擬機器必須維護一個列表,記錄哪些記憶體區塊是可用的,在分配的時候從列表中找出足夠的空間分配給物件實例,並更新清單上的記錄,這種分配方式稱為"空閒清單"。採用哪種分配方式通常由虛擬機器的垃圾收集器是否有壓縮整理功能決定。
分割可用空間時,還需考慮為物件實例分配空間時是否是執行緒安全的。要確保線程安全,有兩種方案。一種是對分配記憶體空間的動作進行同步處理,實際上虛擬機器採用CAS配上失敗重試的方式保證更新操作的原子性。另一種是把記憶體分配的動作按照線程劃分在不同空間中進行,每個線程在Java堆中預先分配一小塊內存,稱為本地線程分配緩衝(Thread Local Allocation Buffer , TLAB)。哪個執行緒要分配內存,就在哪個執行緒的TLAB上分配,只有TLAB用完並分配新的TLAB時,才需要同步鎖定。
記憶體分配完成後,虛擬機器對分配到的記憶體空間都初始化為零值(不包括物件頭),保證物件的實例欄位在Java程式碼中可以不賦初始值就可以直接使用。
虛擬機器將物件的資訊放入物件的物件頭。
執行建構子
#2.物件的記憶體佈局
物件的記憶體佈局總共分為三個部分:
物件頭中主要包括兩部分資訊:
實例資料部分是物件真正儲存的有效訊息,也是程式碼中定義的各種類型的欄位內容。從父類別繼承下來的,在子類別中定義的都需要記錄下來。
對齊填充僅僅起到佔位符的作用。 HotSpot VM的自動記憶體管理系統要求物件起始位址是8位元組的整數倍,所以物件大小必須是8位元組的整數倍。當物件實例資料部分沒有對齊時,需要透過對齊填滿來補全。
三.記憶體的回收
1.物件存活判定
Java虛擬機器透過可達性分析來判定物件是否存活。這個演算法的基本思想是透過一系列稱為"GC Roots"的物件作為起始點,從這些節點向下搜尋,搜尋走過的路徑稱為引用鏈,當一個物件到GC Roots當沒有與任何引用鏈相連時,則該物件是不可用的。
如圖,object5,object6,object7雖然互有關聯,但是GC Roots是不可達的,所以它們被判定是可回收的物件。
另外值得一提的是引用計數演算法,引用計數法是透過給物件一個引用計數器,每當有一個地方引用它時,計數器值就加一;引用失效時,計數器值就減一;任何時刻計數器為0的物件就是不可能再被使用的。引用計數器效率高、實作簡單。但很難解決物件間相互循環引用的問題,主流Java虛擬機器幾乎都不再使用引用計數法來管理記憶體。
可達性分析示意圖
即使在可達性分析演算法中不可達的對象,也不一定會立即被回收。一個物件被回收,至少要經歷兩次標記過程。
如果物件在進行可達性分析後沒有與GC Roots相連的引用鏈,那麼它將被第一次標記並進行一次篩選。篩選的條件是此物件是否有必要執行finalize()方法。當物件沒有覆寫finalize()方法,或finalize()方法已被虛擬機器呼叫過,虛擬機器將這兩種情況視為"沒有必要執行"。
如果這個物件判定為有必要執行finalize()方法,那麼這個物件會放置在F-Queue佇列中,稍後由虛擬機器自動建立、低優先權的Finalizer執行緒去執行finalize()方法。 GC對F-Queue中的物件進行第二次小規模標記,如果物件重新與引用鏈上的任何一個物件建立關聯,那麼第二次標記時它將被移除"即將回收"的集合。否則物件就真的要被回收了。
Finalize方法
#2.方法區回收判定
方法區的回收主要包含兩部分內容:廢棄常數和無用的類別。
廢棄常數的回收與回收Java堆中的物件類似。
判斷無用的類別的條件必須滿足三個條件:
3.垃圾收集演算法
-
#標記-清除演算法(Mark-Sweep):
演算法分為"標記"和"清除"兩個階段:首先標記需要回收的對象,在標記完成後統一回收被標記的對象。它主要不足有兩個:一是效率問題,標記和清除兩個過程效率都不高。二是空間問題,標記清除後會產生大量不連續記憶體碎片,碎片太多可能導致要分配較大物件時,無法找到足夠的記憶體空間不得不提前觸發一次垃圾收集動作。
標記-清除
-
複製演算法:
複製演算法將可用記憶體依容量分為大小相等的兩塊,每次只使用其中一塊。當一塊記憶體用完了,將存活的物件複製到另一塊上面,然後把已使用的記憶體空間一次清理掉。這使得每次都是對整個半區進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等情況,只要移動堆頂指針,依序分配記憶體即可,實作簡單,運行高效。只是這個演算法將記憶體縮小為原來的一半,代價較高。
複製演算法
-
#標記-整理演算法(Mark-Compact) :
標記過程與"標記-清除"演算法一樣,但後續不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體
標記-整理演算法
#4.分代收集演算法
#商業虛擬機器的垃圾收集都採用分代收集演算法,根據物件存活週期將記憶體劃分為幾塊。 Java堆分為新生代和老年代,這樣可以根據年代特徵採用適當的收集演算法。新生代中每次垃圾收集都有大批物件死去,那就選用複製演算法。老年代物件存活率高,沒有額外空間進行分配擔保,適合使用"標記-清理"或"標記-整理"演算法來回收。
4.記憶體分配與回收策略
物件優先在Eden分割區:
大多數情況下,物件在新生代Eden區中分配。當Eden區沒有足夠空間分配時,虛擬機器發起一次Minor GC。 GC後物件嘗試放入Survivor空間,如果Survivor空間無法放入物件時,只能透過空間分配擔保機制提前轉移到老年代。
大物件直接進入老年代:
大物件指需要大量連續記憶體空間的Java物件。虛擬機器提供-XX:PretenureSizeThreshold參數,如果大於這個設定值物件則直接分配在老年代。這樣可以避免新生代中的Eden區及兩個Survivor區發生大量記憶體複製。
長期存活的物件進入老年代:
虛擬機會為每個物件定義一個物件年齡計數器。如果對像在Eden出生並且經過一次Minor GC後任然存活,且能夠被Survivor容納,將被移動到Survivor空間中,並且對象年齡設為1.每次Minor GC後對象任然存活在Survivor區中,年齡就加一,當年齡到達-XX:MaxTenuringThreshold參數設定的值時,將會移到老年代。
動態年齡判斷:
虛擬機器不是永遠要求物件的年齡必須達到-XX:MaxTenuringThreshold設定的值才會將物件移到老年代去。如果Survivor中相同年齡所有物件大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的物件可以直接進入老年代。
空間分配擔保:
在Minor GC前,虛擬機會檢查老年代最大可用連續空間是否大於新生代所有物件總空間,如果條件成立,那麼Minor GC是成立的。如果不成立,虛擬機器查看HandlePromotionFailure設定值是否允許擔保失敗。如果允許,那麼會繼續檢查老年代最大可用連續空間是否大於歷次移動到老年代物件的平均大小,如果大於,將嘗試一次Minor GC。如果小於,或HandlePromotionFailure設定值不允許冒險,那將進行一次Full GC。
新生代GC(Minor GC):發生在新生代的垃圾收集動作,因為Java物件大多朝生夕死,所以Minor GC非常頻繁,回收速度也較快。
老年代GC(Major GC/Full GC):發生在老年代的垃圾收集動作。出現Major GC,常伴隨至少一次Minor GC。 Major GC的速度一般比Minor GC慢10倍以上。
以上是Java記憶體分配與回收機制詳解(圖)的詳細內容。更多資訊請關注PHP中文網其他相關文章!