Java語言是一門自動記憶體管理的語言,不再需要的物件可以通過垃圾回收自動進行記憶體釋放。
Java執行時記憶體區域劃分
JVM將Java程式執行時記憶體區域劃分成以下幾個部分:
- 程式計數器(Program Counter Register, PC)
- java虛擬機器棧
- 本地方法棧
- java堆
- 方法區,方法區中包括執行時常量池
程式計數器可以看做是當前執行緒所執行位元組碼的行號指示器。JVM依靠程式計數器來選取需要執行的下一條位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來實現。
虛擬機器棧描述了Java方法執行的記憶體模型:每個方法在執行的同時都會建立一個棧幀用於儲存區域性變數表、運算元棧、動態連結、方法出口等資訊。每個方法從呼叫開始至結束的過程,就對應著一個棧幀在虛擬機器棧中入棧到出棧的過程。
本地方法棧與java虛擬機器棧類似,只不過它用於為虛擬機器使用的Native方法服務。
Java堆用於存放物件例項資料,幾乎所有的物件例項都在這裡分配記憶體。Java堆是垃圾收集器管理的主要區域。
方法區用於儲存已經被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。
其中程式計數器、Java虛擬機器棧和本地方法棧都是執行緒私有的,而java堆和方法區是執行緒所共享的。
Java垃圾回收主要作用於Java堆。而Java堆又劃分為年輕代,老年代。年輕代又分為Eden,Survivor(分為From Survivor和To Survivor)。Java中垃圾回收是分帶收集的,不同區域的回收演算法是不同的。
判定物件是否已死
判定一個物件是否已死的方法主要有兩種:引用計數法和可達性分析演算法(也叫根結點列舉法)。
引用計數法
引用計數法思想很簡單:為每個物件維持一個引用計數器,當有一個地方引用它時,計數器加1;當引用失效時,計數器減1;任何時刻計數器為0的物件就是可以被回收的物件。
引用計數法優缺點
引用計數法的優點非常明顯:實現簡單,效率高。但是它無法解決迴圈引用問題:例如物件A和B互相引用,但是其他任何物件都沒有引用A和B或者被A和B引用,此時引用計數法判定A和B不可回收,但是事實上A和B都可以被回收。為了解決迴圈引用問題,可達性分析演算法應運而生。
可達性分析演算法
可達性分析演算法思想是通過一個”GC Roots”集來判定物件是否已死,以”GC Roots”為起始點,從這些節點往下搜尋,搜尋所走過的路徑稱為引用鏈,當一個物件到GC Roots沒有任何引用鏈相連時,這個物件就是不可用的。示意圖如下:
在Java中,可作為 GC Root的物件包括以下幾種:
- 虛擬機器棧(棧幀中的區域性變數表)中引用的物件;
- 方法區中類靜態屬性引用的物件;
- 方法區中常量引用的物件;
- 本地方法棧中Native方法引用的物件。
垃圾收集演算法
標記清除演算法
演算法的執行過程與名字一樣,先標記所有需要回收的物件,在標記完成後統一回收所有被標記的物件。該演算法有兩個問題:
- 標記和清除過程效率不高。主要由於垃圾收集器需要從GC Roots根物件中遍歷所有可達的物件,並給這些物件加上一個標記,表明此物件在清除的時候被跳過,然後在清除階段,垃圾收集器會從Java堆中從頭到尾進行遍歷,如果有物件沒有被打上標記,那麼這個物件就會被清除。顯然遍歷的效率是很低的
- 會產生很多不連續的空間碎片,所以可能會導致程式執行過程中需要分配較大的物件的時候,無法找到足夠的記憶體而不得不提前出發一次垃圾回收。
複製演算法
複製演算法是為了解決標記-清除演算法的效率問題的,其思想如下:將可用記憶體的容量分為大小相等的兩塊,每次只使用其中的一塊,當這一塊記憶體使用完了,就把存活著的物件複製到另外一塊上面,然後再把已使用過的記憶體空間清理掉。
- 優點:每次都是對整個半區進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。
- 缺點:演算法的代價是將記憶體縮小為了原來的一半,未免太高了一點。
現在的商業虛擬機器都採用這種收集演算法來回收新生代,新生代中的物件98%是“朝生夕死”的,所以並不需要按照1∶1的比例來劃分記憶體空間,而是將記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。
當回收時,將Eden和Survivor中還存活著的物件一次性地複製到另外一塊Survivor空間上,最後清理掉Eden和剛才用過的Survivor空間。HotSpot虛擬機器預設Eden和Survivor的大小比例是8∶1,也就是每次新生代中可用記憶體空間為整個新生代容量的90%,只有10%的記憶體會被“浪費”。
當然,90%的物件可回收只是一般場景下的資料,我們沒有辦法保證每次回收都只有不多於10%的物件存活,當Survivor空間不夠用時(例如,存活的物件需要的空間大於剩餘一塊Survivor的空間),需要依賴其他記憶體(這裡指老年代)進行分配擔保(Handle Promotion)。
標記-整理演算法
複製收集演算法在物件存活率較高時就要進行較多的複製操作,效率將會變低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的記憶體中所有物件都100%存活的極端情況,所以在老年代一般不能直接選用這種演算法。
與標記-清除演算法過程一樣,只不過在標記後不是對未標記的記憶體區域進行清理,二是讓所有的存活物件都向一端移動,然後清理掉邊界外的記憶體。該方法主要用於老年代。
分代收集演算法
目前商用虛擬機器都使用“分代收集演算法”,所謂分代就是根據物件的生命週期把記憶體分為幾塊,一般把Java堆中分為新生代和老年代,這樣就可以根據物件的“年齡”選擇合適的垃圾回收演算法。
- 新生代:“朝生夕死”,存活率低,使用複製演算法。
- 老年代:存活率較高,使用“標記-清除”演算法或者“標記-整理”演算法。
垃圾收集器
JVM有7種不同的垃圾收集器,它們是垃圾收集演算法的具體實現。下圖展示了這7種作用於不同分代的收集器,其中用於回收新生代的收集器包括Serial、PraNew、Parallel Scavenge,回收老年代的收集器包括Serial Old、Parallel Old、CMS,還有用於回收整個Java堆的G1收集器。不同收集器之間的連線表示它們可以搭配使用。
各個收集器的特點如下:
- Serial收集器(複製演算法): 新生代單執行緒收集器,標記和清理都是單執行緒,優點是簡單高效;
- Serial Old收集器 (標記-整理演算法): 老年代單執行緒收集器,Serial收集器的老年代版本;
- ParNew收集器 (複製演算法): 新生代收並行集器,實際上是Serial收集器的多執行緒版本,在多核CPU環境下有著比Serial更好的表現;
- Parallel Scavenge收集器 (複製演算法): 新生代並行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 使用者執行緒時間/(使用者執行緒時間+GC執行緒時間),高吞吐量可以高效率的利用CPU時間,儘快完成程式的運算任務,適合後臺應用等對互動相應要求不高的場景;
- Parallel Old收集器 (標記-整理演算法): 老年代並行收集器,吞吐量優先,Parallel Scavenge收集器的老年代版本;
- CMS(Concurrent Mark Sweep)收集器(標記-清除演算法): 老年代並行收集器,以獲取最短回收停頓時間為目標的收集器,具有高併發、低停頓的特點,追求最短GC回收停頓時間。
- G1(Garbage First)收集器 (標記-整理演算法): Java堆並行收集器,G1收集器是JDK1.7提供的一個新收集器,G1收集器基於“標記-整理”演算法實現,也就是說不會產生記憶體碎片。此外,G1收集器不同於之前的收集器的一個重要特點是:G1回收的範圍是整個Java堆(包括新生代,老年代),而前六種收集器回收的範圍僅限於新生代或老年代。
收集器總結
收集器 | 序列、並行or併發 | 新生代or老年代 | 演算法 | 目標 | 適用場景 |
---|---|---|---|---|---|
Serial | 序列 | 新生代 | 複製演算法 | 響應速度優先 | 單CPU環境下的Client模式 |
Serial Old | 序列 | 老年代 | 標記-整理 | 響應速度優先 | 單CPU環境下的Client模式、CMS的後備預案 |
ParNew | 並行 | 新生代 | 複製演算法 | 響應速度優先 | 多CPU環境時在Server模式下與CMS配合 |
Parallel Scavenge | 並行 | 新生代 | 複製演算法 | 吞吐量優先 | 在後臺運算而不需要太多互動的任務 |
Parallel Old | 並行 | 老年代 | 標記-整理 | 吞吐量優先 | 在後臺運算而不需要太多互動的任務 |
CMS | 併發 | 老年代 | 標記-清除 | 響應速度優先 | 集中在網際網路站或B/S系統服務端上的Java應用 |
G1 | 併發 | 年輕代和老年代 | 標記-整理+複製演算法 | 響應速度優先 | 面向服務端應用,將來替換CMS |
記憶體分配與回收策略
Java技術體系中所提倡的自動記憶體管理最終可以歸結為自動化地解決了兩個問題:給物件分配記憶體 以及 回收分配給物件的記憶體。一般而言,物件主要分配在新生代的Eden區上,如果啟動了本地執行緒分配快取(TLAB),將按執行緒優先在TLAB上分配。少數情況下也可能直接分配在老年代中。總的來說,記憶體分配規則並不是一層不變的,其細節取決於當前使用的是哪一種垃圾收集器組合,還有虛擬機器中與記憶體相關的引數的設定。
-
物件優先在Eden分配,當Eden區沒有足夠空間進行分配時,虛擬機器將發起一次MinorGC。現在的商業虛擬機器一般都採用複製演算法來回收新生代,將記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。 當進行垃圾回收時,將Eden和Survivor中還存活的物件一次性地複製到另外一塊Survivor空間上,最後處理掉Eden和剛才的Survivor空間。(HotSpot虛擬機器預設Eden和Survivor的大小比例是8:1)當Survivor空間不夠用時,需要依賴老年代進行分配擔保。
-
大物件直接進入老年代。所謂的大物件是指,需要大量連續記憶體空間的Java物件,最典型的大物件就是那種很長的字串以及陣列。
-
長期存活的物件將進入老年代。當物件在新生代中經歷過一定次數(預設為15)的Minor GC後,就會被晉升到老年代中。
-
動態物件年齡判定。為了更好地適應不同程式的記憶體狀況,虛擬機器並不是永遠地要求物件年齡必須達到了MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有物件大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的物件就可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡。
需要注意的是,Java的垃圾回收機制是Java虛擬機器提供的能力,用於在空閒時間以不定時的方式動態回收無任何引用的物件佔據的記憶體空間。也就是說,垃圾收集器回收的是無任何引用的物件佔據的記憶體空間而不是物件本身。
Minor GC 和 Full GC
- 新生代GC(Minor GC):指發生在新生代的垃圾收集動作,因為Java物件大多都具備朝生夕滅的特性,所以Minor GC非常頻繁,一般回收速度也比較快。
- 老年代GC(Major GC / Full GC):指發生在老年代的GC,出現了Major GC,經常會伴隨至少一次的Minor GC(但非絕對的,在Parallel Scavenge收集器的收集策略裡就有直接進行Major GC的策略選擇過程)。Major GC的速度一般會比Minor GC慢10倍以上。
Minor GC和 Full GC觸發條件
Minor GC觸發條件:當Eden區滿時,觸發Minor GC。
Full GC觸發條件:
- 呼叫System.gc時,系統建議執行Full GC,但是不必然執行。
- 老年代空間不足。
- 方法去空間不足。
- 通過Minor GC後進入老年代的平均大小大於老年代的可用記憶體。
- 由Eden區、From Space區向To Space區複製時,物件大小大於To Space可用記憶體,則把該物件轉存到老年代,且老年代的可用記憶體小於該物件大小。
參考資料
《深入理解Java虛擬機器——JVM高階特性與最佳實踐》-周志明