1. 首先需要解決的問題:哪些對象已經死亡
1.1 引用計數算法
引用分析算法是這樣的:
給每一個對象添加一個引用計數器, 每當有一個地方使用,計數器就加一;
任何時刻計數器為0的對象就是 不可能再被使用的。
缺點:難以解決對象之間的循環引用問題
1.2 可達性分析算法(商用程序語言的主流實現):
這個算法的基本思想就是通過一系列的"GC ROOTS"的對象作為起始點(注意是一系列,不是某個'對象'),從這個節點開始向下搜索.
搜索所走過的路徑稱為引用鏈,當一個對象沒有到 "GC ROOTS"的任何引用鏈相連時(圖不可達),則證明此對象是不可用的,屬于可回收對象。
枚舉根節點:
在正式的GC之前總是需要進行可達性分析來查找內存中所有存活的對象,以便GC能夠正確的回收已經死亡的對象。那么對于一個十分復雜的系統,每次GC的時候都要遍歷所有的引用肯定是不現實的。
因為在可達性分析的時候,需要進行Stop The World,程序中的線程需要停止來配合可達性分析。
- 保守式gc
在進行GC的時候,會從一些已知的位置(GC Roots)開始掃描內存,掃描到一個數字就判斷他是不是可能是指向GC堆中的一個指針(這里會涉及上下邊界檢查(GC堆的上下界是已知的)、對齊檢查(通常分配空間的時候會有對齊要求,假如說是4字節對齊,那么不能被4整除的數字就肯定不是指針),之類的。)。然后一直遞歸的掃描下去,最后完成可達性分析。這種模糊的判斷方法因為無法準確判斷一個位置上是否是真的指向GC堆中的指針,所以被命名為保守式GC。這種可達性分析的方式因為不需要準確的判斷出一個指針,所以效率快,但是也正因為這種特點,他存在下面兩個明顯的缺點:
因為是模糊的檢查,所以對于一些已經死掉的對象,很可能會被誤認為仍有地方引用他們,GC也就自然不會回收他們,從而引起了無用的內存占用,就是典型的占著茅坑不拉屎,造成資源浪費。
由于不知道疑似指針是否真的是指針,所以它們的值都不能改寫;移動對象就意味著要修正指針。換言之,對象就不可移動了。有一種辦法可以在使用保守式GC的同時支持對象的移動,那就是增加一個間接層,不直接通過指針來實現引用,而是添加一層“句柄”(handle)在中間,所有引用先指到一個句柄表里,再從句柄表找到實際對象。這樣,要移動對象的話,只要修改句柄表里的內容即可。但是這樣的話引用的訪問速度就降低了。Sun JDK的Classic VM用過這種全handle的設計,但效果實在算不上好。
- 準確性gc
與保守式GC相對的就是準確式GC,何為準確式GC?
就是我們準確的知道,某個位置上面是否是指針,對于java來說,就是知道對于某個位置上的數據是什么類型的,這樣就可以判斷出所有的位置上的數據是不是指向GC堆的引用,包括棧和寄存器里的數據。
java中實現的方式是:從外部記錄下類型信息,存成映射表,在HotSpot中把這種映射表稱之為OopMap,不同的虛擬機名稱可能不一樣。
實現這種功能,需要虛擬機的解釋器和JIT編譯器支持,由他們來生成OopMap。生成這樣的映射表一般有兩種方式:
- 每次都遍歷原始的映射表,循環的一個個偏移量掃描過去;這種用法也叫“解釋式”;
- 為每個映射表生成一塊定制的掃描代碼(想像掃描映射表的循環被展開的樣子),以后每次要用映射表就直接執行生成的掃描代碼;這種用法也叫“編譯式”。
總而言之,GC開始的時候,就通過OopMap這樣的一個映射表知道,在對象內的什么偏移量上是什么類型的數據,而且特定的位置記錄下棧和寄存器中哪些位置是引用。
說明:
實際情況是GC Root通常是一組特別管理的指針,這些指針是tracing GC的trace的起點。它們不是對象圖里的對象,對象也不可能引用到這些“外部”的指針.
那么哪些節點可以作為GC Root呢?
- 虛擬機棧中的引用對象的變量
- 方法區的引用對象的常量
- 方法區中引用對象的靜態變量
- 本地方法棧中引用對象的變量
1.3 對象的消亡:
要真正宣布對象的消亡,需要進行兩次標記過程:
如果對象第一次發現沒有與GC ROOTS 相連, 則進行第一次標記篩選, 篩選條件是判斷對象是否有必要執行 finalize()
方法。如果對象沒有覆蓋過這個方法或者這個方法已經執行過,則不再執行這個方法。
否則,虛擬機將調用這個方法,finalize()方法是對象最后逃脫被清理的機會(在方法中與引用用對象建立連接)
當對象第二次因為沒有與GC ROOTS 相連而被標記后,他將面臨被回收的命運。
說明:
強烈不建議使用這個方法。
1.4 方法區的回收
方法區回收的內容主要包含: 廢棄的常量和無用的類。
類需要滿足下面3個條件才算是"無用的類":
- 該類所有的實例都已經被回收,也就是java堆中不存在該類的任何實例
- 加載該類的ClassLoader已經被回收
- 該類對應的 java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法
2. 對于確定對象已死亡的空間的回收方式: 垃圾收集算法:
2.1 標記-清理算法
算法分為標記和清理兩個過程
主要缺點:
標記和清除過程效率不高 。
標記清除之后會產生大量不連續的內存碎片。
2.2 復制算法(新生代主流收集算法)
它將可用內存容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊用完之后,就將還存活的對象復制到另外一塊上面,然后在把已使用過的內存空間一次理掉。這樣使得每次都是對其中的一塊進行內存回收,不會產生碎片等情況,只要移動堆訂的指針,按順序分配內存即可,實現簡單,運行高效。
主要缺點:
內存縮小為原來的一半。
優化:
IBM 公司的研究表明, 新生代的對象 98% 是'朝生夕死'的,所以并不需要1:1回收內存,而是將內存分成一個大的 Eden空間,和兩個較小的Survivor空間,每次使用 Eden和其中的一塊 Survivor空間。
當回收時, 將Eden和其中的一塊Survivor中還活著的對象一次性復制到另一個Survivor上,最后清理掉Eden和剛才用過的Survivor空間。
當Survivor沒有足夠的空間存放上一次新生代收集下來的存活對象時,這些對象將直接通過分配擔保機制進入老年代。
2.3 標記-整理算法(適用于老年代的回收算法)
標記操作和“標記-清除”算法一致,后續操作不只是直接清理對象,而是在清理無用對象完成后讓所有存活的對象都向一端移動,并更新引用其對象的指針。
特點:
主要缺點:在標記-清除的基礎上還需進行對象的移動,成本相對較高
優點 : 不會產生內存碎片。
2.4 分代收集算法
根據對象的存活周期的不同將內存劃分為幾塊。一般把java堆分為新生代和老年代,這樣就可以根據各個年代的特點采用最適當的收集算法。
在新生代,每次垃圾收集時都發現有大批對象死去,只有少量存活,那就選用復制算法,只需要付出少量存活對象的復制成本就可以完成收集。
而老年代中因為對象存活率高、沒有額外空間對他進行分配擔保,就必須使用“標記-整理”算法進行回收。
3. 垃圾回收的時機:安全點與安全區域:
3.1 安全點:
程序并不能在任意地方都可以停下來進行GC,只有到達安全點時才能暫停。此外,在安全點中,HotSpot也會開始記錄虛擬機的相關信息,如OopMap信息的錄入。安全點的選擇不能太少,否則GC等待時間太長;也不能太多,否則會增大運行負荷。其選擇的原則為“是否具有讓程序長時間執行的特征”,如方法調用,循環等等。具體安全點有下面幾個:
- 循環的末尾 (防止大循環的時候一直不進入safepoint,而其他線程在等待它進入safepoint)
- 方法返回前
- 調用方法的call之后
- 拋出異常的位置
安全點暫停線程運行的手段有兩種:搶先式中斷和主動式中斷:
- 搶占式終端:
不需要線程的執行代碼主動配合,在GC發生時,首先把所有線程全部中斷,如果發現有線程中斷的地方不在安全點上,就恢復線程,讓它跑到安全點上再暫停。
不過現在的虛擬機幾乎沒有采用此算法的
- 主動式終端:
GC需要中斷線程的時候,不直接對線程操作,僅僅簡單地設置一個標志,各個線程執行時去主動輪詢查詢此標志,發現中斷標志為真時就中斷自己掛起。
輪詢標志的地方和安全點是重合的,另外再加上創建對象需要分配內存的地方。
3.2 安全區域
產生原因:
安全點機制保證了程序執行時進入GC的問題。
但是對于非執行態下,如線程Sleep或者Block下,由于此時程序(線程)無法響應JVM的中斷請求,JVM也不太可能一直等待線程重新獲取時間片,此時就需要安全區域了。
運行機制:
在線程執行到Safe Region中的代碼時,首先標識自己已經進入了Safe Region。
當在這段時間里JVM要發起GC時,就不用管標識自己為Safe Region狀態的線程了。
當線程要離開Safe Region時,如果整個GC完成,那線程可繼續執行,否則它必須等待直到收到可以安全離開Safe Region的信號為止。
4.垃圾收集器
收集器 | 串行、并行or并發 | 新生代/老年代 | 算法 | 目標 | 適用場景 |
---|---|---|---|---|---|
Serial | 串行 | 新生代 | 復制算法 | 響應速度優先 | 單CPU環境下的Client模式 |
Serial Old | 串行 | 老年代 | 標記-整理 | 響應速度優先 | 單CPU環境下的Client模式、CMS的后備預案 |
ParNew | 并行 | 新生代 | 復制算法 | 響應速度優先 | 多CPU環境時在Server模式下與CMS配合 |
Parallel Scavenge | 并行 | 新生代 | 復制算法 | 吞吐量優先 | 在后臺運算而不需要太多交互的任務 |
Parallel Old | 并行 | 老年代 | 標記-整理 | 吞吐量優先 | 在后臺運算而不需要太多交互的任務 |
CMS | 并發 | 老年代 | 標記-清除 | 響應速度優先 | 集中在互聯網站或B/S系統服務端上的Java應用 |
G1 | 并發 | both | 標記-整理+復制算法 | 響應速度優先 | 面向服務端應用,將來替換CMS |
Minor GC 和 Full GC
新生代GC (Minor GC)
指發生在新生代的垃圾收集動作,因為Java對象大多都具備朝生夕滅的特性,所以Minor GC非常頻繁,一般回收速度也比較快。
老年代GC (Full GC)
指發生在老年代的GC,出現了Full GC,經常會伴隨至少一次的Minor GC(但非絕對的,在Parallel Scavenge收集器的收集策略里就有直接進行Major GC的策略選擇過程)。Major GC的速度一般會比Minor GC慢10倍以上。
4.1 Serial收集器
Serial(串行)收集器是最基本、發展歷史最悠久的收集器,它是采用復制算法的新生代收集器,曾經(JDK 1.3.1之前)是虛擬機新生代收集的唯一選擇。
它是一個單線程收集器,只會使用一個CPU或一條收集線程去完成垃圾收集工作,更重要的是它在進行垃圾收集時,必須暫停其他所有的工作線程,直至Serial收集器收集結束為止(“Stop The World”)。
這項工作是由虛擬機在后臺自動發起和自動完成的,在用戶不可見的情況下把用戶正常工作的線程全部停掉,這對很多應用來說是難以接收的。
下圖展示了Serial 收集器(老年代采用Serial Old收集器)的運行過程:
在用戶的桌面應用場景中,分配給虛擬機管理的內存一般不會很大,收集幾十兆甚至一兩百兆的新生代(僅僅是新生代使用的內存,桌面應用基本不會再大了),停頓時間完全可以控制在幾十毫秒最多一百毫秒以內,只要不頻繁發生,這點停頓時間可以接收。所以,Serial收集器對于運行在Client模式下的虛擬機來說是一個很好的選擇。
4.2 ParNew 收集器
ParNew收集器就是Serial收集器的多線程版本,它也是一個新生代收集器。
除了使用多線程進行垃圾收集外,其余行為包括Serial收集器可用的所有控制參數、收集算法(復制算法)、Stop The World、對象分配規則、回收策略等與Serial收集器完全相同,兩者共用了相當多的代碼。
ParNew收集器的工作過程如下圖(老年代采用Serial Old收集器):
ParNew收集器除了使用多線程收集外,其他與Serial收集器相比并無太多創新之處,但它卻是許多運行在Server模式下的虛擬機中首選的新生代收集器,其中有一個與性能無關的重要原因是:
除了Serial收集器外,目前只有它能和CMS收集器(Concurrent Mark Sweep)配合工作
4.3 Parallel Scavenge 收集器
Parallel Scavenge收集器也是一個并行的多線程新生代收集器,它也使用復制算法。
Parallel Scavenge收集器的特點是它的關注點與其他收集器不同,CMS等收集器的關注點是盡可能縮短垃圾收集時用戶線程的停頓時間,而Parallel Scavenge收集器的目標是達到一個可控制的吞吐量(Throughput)。
停頓時間越短就越適合需要與用戶交互的程序,良好的響應速度能提升用戶體驗。
而高吞吐量則可以高效率地利用CPU時間,盡快完成程序的運算任務,主要適合在后臺運算而不需要太多交互的任務。
Parallel Scavenge收集器除了會顯而易見地提供可以精確控制吞吐量的參數,還提供了一個參數-XX:+UseAdaptiveSizePolicy,這是一個開關參數,打開參數后,就不需要手工指定新生代的大小(-Xmn)、Eden和Survivor區的比例(-XX:SurvivorRatio)、晉升老年代對象年齡(-XX:PretenureSizeThreshold)等細節參數了,虛擬機會根據當前系統的運行情況收集性能監控信息,動態調整這些參數以提供最合適的停頓時間或者最大的吞吐量,這種方式稱為GC自適應的調節策略(GC Ergonomics)。自適應調節策略也是Parallel Scavenge收集器與ParNew收集器的一個重要區別。
另外值得注意的一點是,Parallel Scavenge收集器無法與CMS收集器配合使用,所以在JDK 1.6推出Parallel Old之前,如果新生代選擇Parallel Scavenge收集器,老年代只有Serial Old收集器能與之配合使用。
4.4 Serial Old收集器
Serial Old 是 Serial收集器的老年代版本,它同樣是一個單線程收集器,使用“標記-整理”(Mark-Compact)算法。
4.5 Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多線程和“標記-整理”算法。
前面已經提到過,這個收集器是在JDK 1.6中才開始提供的,在此之前,如果新生代選擇了Parallel Scavenge收集器,老年代除了Serial Old以外別無選擇,
所以在Parallel Old誕生以后,“吞吐量優先”收集器終于有了比較名副其實的應用組合,在注重吞吐量以及CPU資源敏感的場合,都可以優先考慮Parallel Scavenge加Parallel Old收集器。
Parallel Old收集器的工作流程與Parallel Scavenge相同,這里給出Parallel Scavenge/Parallel Old收集器配合使用的流程圖:
4.6 CMS收集器
CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器,
它非常符合那些集中在互聯網站或者B/S系統的服務端上的Java應用,這些應用都非常重視服務的響應速度。
從名字上(“Mark Sweep”)就可以看出它是基于“標記-清除”算法實現的。
CMS收集器工作的整個流程分為以下4個步驟:
- 初始標記(CMS initial mark):僅僅只是標記一下GC Roots能直接關聯到的對象,速度很快,需要“Stop The World”。
- 并發標記(CMS concurrent mark):進行GC Roots Tracing的過程,在整個過程中耗時最長。
- 重新標記(CMS remark):為了修正并發標記期間因用戶程序繼續運作而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記階段稍長一些,但遠比并發標記的時間短。此階段也需要“Stop The World”。
- 并發清除(CMS concurrent sweep)
由于整個過程中耗時最長的并發標記和并發清除過程收集器線程都可以與用戶線程一起工作,所以,從總體上來說,CMS收集器的內存回收過程是與用戶線程一起并發執行的。
通過下圖可以比較清楚地看到CMS收集器的運作步驟中并發和需要停頓的時間:
優點:
CMS是一款優秀的收集器,它的主要優點在名字上已經體現出來了:并發收集、低停頓,因此CMS收集器也被稱為并發低停頓收集器(Concurrent Low Pause Collector)。
缺點:
- 對CPU資源非常敏感: 其實,面向并發設計的程序都對CPU資源比較敏感。在并發階段,它雖然不會導致用戶線程停頓,但會因為占用了一部分線程(或者說CPU資源)而導致應用程序變慢,總吞吐量會降低。CMS默認啟動的回收線程數是(CPU數量+3)/4,也就是當CPU在4個以上時,并發回收時垃圾收集線程不少于25%的CPU資源,并且隨著CPU數量的增加而下降。但是當CPU不足4個時(比如2個),CMS對用戶程序的影響就可能變得很大,如果本來CPU負載就比較大,還要分出一半的運算能力去執行收集器線程,就可能導致用戶程序的執行速度忽然降低了50%,其實也讓人無法接受。
- 無法處理浮動垃圾(Floating Garbage): 可能出現“Concurrent Mode Failure”失敗而導致另一次Full GC的產生。由于CMS并發清理階段用戶線程還在運行著,伴隨程序運行自然就還會有新的垃圾不斷產生。這一部分垃圾出現在標記過程之后,CMS無法再當次收集中處理掉它們,只好留待下一次GC時再清理掉。這一部分垃圾就被稱為“浮動垃圾”。也是由于在垃圾收集階段用戶線程還需要運行,那也就還需要預留有足夠的內存空間給用戶線程使用,因此CMS收集器不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集,需要預留一部分空間提供并發收集時的程序運作使用。
- 標記-清除算法導致的空間碎片 : CMS是一款基于“標記-清除”算法實現的收集器,這意味著收集結束時會有大量空間碎片產生。空間碎片過多時,將會給大對象分配帶來很大麻煩,往往出現老年代空間剩余,但無法找到足夠大連續空間來分配當前對象。
4.7 G1收集器
G1(Garbage-First)收集器是當今收集器技術發展最前沿的成果之一,它是一款面向服務端應用的垃圾收集器,HotSpot開發團隊賦予它的使命是(在比較長期的)未來可以替換掉JDK 1.5中發布的CMS收集器。
與其他GC收集器相比,G1具備如下特點:
- 并行與并發 : G1 能充分利用多CPU、多核環境下的硬件優勢,使用多個CPU來縮短“Stop The World”停頓時間,部分其他收集器原本需要停頓Java線程執行的GC動作,G1收集器仍然可以通過并發的方式讓Java程序繼續執行。
- 分代收集 : 與其他收集器一樣,分代概念在G1中依然得以保留。雖然G1可以不需要其他收集器配合就能獨立管理整個GC堆,但它能夠采用不同方式去處理新創建的對象和已存活一段時間、熬過多次GC的舊對象來獲取更好的收集效果。
- 空間整合 : G1從整體來看是基于“標記-整理”算法實現的收集器,從局部(兩個Region之間)上來看是基于“復制”算法實現的。這意味著G1運行期間不會產生內存空間碎片,收集后能提供規整的可用內存。此特性有利于程序長時間運行,分配大對象時不會因為無法找到連續內存空間而提前觸發下一次GC。
- 可預測的停頓 : 這是G1相對CMS的一大優勢,降低停頓時間是G1和CMS共同的關注點,但G1除了降低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在GC上的時間不得超過N毫秒,這幾乎已經是實時Java(RTSJ)的垃圾收集器的特征了。
橫跨整個堆內存 :
在G1之前的其他收集器進行收集的范圍都是整個新生代或者老生代,而G1不再是這樣。
G1在使用時,Java堆的內存布局與其他收集器有很大區別,它將整個Java堆劃分為多個大小相等的獨立區域(Region),雖然還保留新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,而都是一部分Region(不需要連續)的集合。
建立可預測的時間模型
G1收集器之所以能建立可預測的停頓時間模型,是因為它可以有計劃地避免在整個Java堆中進行全區域的垃圾收集。
G1跟蹤各個Region里面的垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需時間的經驗值),在后臺維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的Region(這也就是Garbage-First名稱的來由)。這種使用Region劃分內存空間以及有優先級的區域回收方式,保證了G1收集器在有限的時間內可以獲取盡可能高的收集效率。
避免全堆掃描——Remembered Set
G1把Java堆分為多個Region,就是“化整為零”。但是Region不可能是孤立的,一個對象分配在某個Region中,可以與整個Java堆任意的對象發生引用關系。
在做可達性分析確定對象是否存活的時候,需要掃描整個Java堆才能保證準確性,這顯然是對GC效率的極大傷害。
為了避免全堆掃描的發生,虛擬機為G1中每個Region維護了一個與之對應的Remembered Set。
虛擬機發現程序在對Reference類型的數據進行寫操作時,會產生一個Write Barrier暫時中斷寫操作,檢查Reference引用的對象是否處于不同的Region之中(在分代的例子中就是檢查是否老年代中的對象引用了新生代中的對象),如果是,便通過CardTable把相關引用信息記錄到被引用對象所屬的Region的Remembered Set之中。
當進行內存回收時,在GC根節點的枚舉范圍中加入Remembered Set即可保證不對全堆掃描也不會有遺漏。
5. 內存分配與回收策略
Java技術體系中所提倡的自動內存管理最終可以歸結為自動化地解決了兩個問題:給對象分配內存以及回收分配給對象的內存。
對象的內存分配,往大方向講,就是在堆上分配,對象主要分配在新生代的Eden區上,如果啟動了本地線程分配緩沖,將按線程優先在TLAB上分配。
少數情況下也可能會直接分配在老年代中,分配的規則并不是百分之百固定的,其細節取決于當前使用的是哪一種垃圾收集器組合,還有虛擬機中與內存相關的參數的設置。
參數說明:
-Xms
: 最小堆
-Xmx
: 最大堆
-Xmn
: 新生代大小
-XX:+PrintGCDetails
: 打印gc信息
-XX:SurvivorRatio
: Eden 與 Survivor 的占比
- 對象優先在Eden分配
大多數情況下,對象在新生代Eden區 中分配,當Eden區沒有足夠空間進行分配時,虛擬機將發起一次新生代GC(Minor GC).
- 大對象直接進入老年代
“大對象”是代表需要大量連續內存空間的Java對象,最典型的大對象就是那種很長的字符串以及數組.
大對象對虛擬機的內存分配就是一個壞消息(拓展一下:對Java虛擬機而言,比遇到一個大對象更壞的情況時遇到一群“朝生夕滅”的“短命大對象”,編寫程序時應當避免此現象產生).
經常出現大對象容易導致內存還有不少空間時就提前觸發垃圾收集以獲取足夠的連續空間來“安置”它們。
虛擬機提供了一個-XX:PretenureSizeThreshold
參數,令大于這個設置的對象直接在老年代分配。目的是為了避免在Eden區及兩個Survivor區之間發生大量的內存復制
- 長期存活的對象將進入老年代
既然虛擬機采用了分代收集的思想來管理內存,那么內存回收時就必須能識別對象應放在新生代還是老年代。為了做到這點,虛擬機給每個對象定義了一個對象年齡(Age)計數器。
如果對象在Eden出生并經過第一次 Minor GC后仍然存活,并且能被Survivor 容納的話,將被移動到 Survivor空間中,并且對象年齡設為1。
對象在Survivor 區 每“熬過”一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(默認為15歲),就會晉升到老年代中。
大體的新生代與老年代內存大小設置都是一樣,這里多出現了一種參數:-XX:MaxTenuringThreshold
,可通過它來設置對象晉升老年代的年齡閥值。
5.4 空間分配擔保
為了能夠更好地適應不同程序的內存狀況,虛擬機并不是永遠地要求對象年齡必須達到MaxTenuringThreshold規定值才能晉升老年代,如果在 Survivor 空間中相同年齡所有對象大小的總和大于Survivor 空間的一半,年齡大于或等于該年齡的對象就可以直接進入老年代,無須等到參數的規定值。