本文主要從GC(垃圾回收)的角度試着對jvm中的內存分配策略與相應的垃圾收集器作一個介紹。html
注:仍是老規矩,本着能畫圖就不BB原則,儘可能將各知識點經過思惟導圖或者其餘模型圖的方式進行說明。文字僅記錄額外的思考與心得,以及其餘特殊狀況java
本部分的回答主要圍繞 哪些內存須要回收?何時回收?以及如何回收?這三個問題來進行介紹。算法
由上圖可知,只有堆區和靜態區,運行時才能知道建立的對象信息,因此垃圾收集器所須要關注的內存也就集中於這兩個部分了。編程
不可能再被任何途徑使用(對象已死)segmentfault
主流對象存過斷定算法分爲以下兩種:安全
引用計數算法數據結構
可達性分析算法併發
在 java 中引用分爲強軟弱虛四種形式,jvm
最多見的就是強引用,好比相似Object obj = new Object()
這種。高併發
軟引用經過 「SoftReference」 來實現
弱引用經過 「WeakReference」 來實現
弱引用經過 「PhantomReference」 來實現
在方法區中,垃圾收集遠不像堆區那麼頻繁和高效。咱們聚焦於兩部份內容,廢棄常量和無用的類。
針對是否對類進行回收,HotSpot 虛擬機提供了 -Xnoclassgc
參數進行控制
針對類加載和卸載信息,可使用 -verbose:class
以及 -XX:+TraceClassLoading
、-XX:TraceClassUnLoading
注:-verbose:class
以及 -XX:+TraceClassLoading
能夠用在Product版的虛擬機中。-XX:+TraceClassUnLoading
參數須要 FastDebug 版的虛擬機支持。
其實如何回收也是具體的垃圾收集器該乾的的事。可是各個平臺的虛擬機操做內存的方法又各不相同。因此這部分先站在一個略宏觀的角度討論下關於垃圾回收的幾種常見算法。
傳統的複製算法因爲將內存劃分爲了兩半,致使同一時間內存的可用率只有50%,這顯然是難以接受的。
因此也早就有了機智的前輩對此方法進行了改進,接下來就來介紹下 HotSpot 虛擬機中是如何改進的~
複製收集算法在對象存活率較高的時候,就要進行較多的複製操做,效率將會變低。更關鍵的是,若是不想浪費50%的控件,就須要有額外的空間進行分配擔保,以應對被使用的內存中全部對象都100%存活的極端狀況。
因此針對老年代的特色,通常更傾向使用相似「標記-整理」而非「複製收集」這樣的算法。
前面從理論上介紹了對象存活的斷定方法和垃圾收集算法的思想,可是具體實現的過程當中,也纔會發現一些在理論思考時不會注意的點。
經過一組稱爲 OopMap 的數據結構來達到目的:
在類加載完成的時候,HotSpot 將對象內數據類型及其偏移量記錄下來
JIT 編譯過程當中也在特定的位置記錄下棧和寄存器中哪些位置使引用
經過這種事前約定記錄位置的方法,實現快速遍歷根節點引用
安全點的由來自己也是爲了解決一個難題而產生的:
不一樣的廠商,不一樣版本的虛擬機所提供的垃圾收集器差異很大,爲了方便討論,這裏以 JDK 1.7 Update 14 爲基礎進行討論。
上圖展現了7種做用於不一樣分代的收集器,若是兩個收集器之間存在連線,就說明它們能夠搭配使用。虛擬機所處的區域,則表示它是屬於新生代收集器仍是老年代收集器。
ParNew 默認開啓的垃圾收集器線程數就是CPU數量,可經過-XX:parallelGCThreads參數來限制收集器線程數
另:
從 ParNew 收集器開始,後續還有幾款併發和並行收集器。這裏解釋一下這兩個名詞:併發和並行。這兩個名詞都是併發編程中的概念,在談論垃圾收集器的上下文語境中,它們能夠解釋以下:
並行(Parallel):指多條垃圾收集線程並行工做,但此時用戶線程仍處於等待狀態。
併發(Concurrent):指用戶線程與垃圾收集線程同時執行(但不必定是並行的,可能會交替執行),用戶程序在繼續運行,而垃圾收集程序運行於另外一個CPU上。
提供了兩個參數來精確控制吞吐量:
最大垃圾收集器停頓時間(-XX:MaxGCPauseMillis 大於0的毫秒數,停頓時間小了就要犧牲相應的吞吐量和新生代空間),
設置吞吐量大小(-XX:GCTimeRatio 大於0小於100的整數,默認99,也就是容許最大1%的垃圾回收時間)。
還有一個參數表示自適應調節策略(GC Ergonomics)(-XX:UseAdaptiveSizePolicy)。就不用手動設置新生代大小(-Xmn)、Eden和Survivor區的比例(-XX:SurvivorRatio)晉升老年代對象大小(-XX:PretenureSizeThreshold),會根據當前系統的運行狀況手機監控信息,動態調整停頓時間和吞吐量大小。也是其與PreNew收集器的一個重要區別,也是其沒法與CMS收集器搭配使用的緣由(CMS收集器儘量地縮短垃圾收集時用戶線程的停頓時間,以提高交互體驗)。
另:所謂吞吐量就是CPU用於運行用戶代碼的時間與CPU總消耗時間的比值,即吞吐量 = 運行用戶代碼時間 / (運行用戶代碼時間 + 垃圾收集時間)。
虛擬機總共運行了100分鐘,其中垃圾收集花掉1分鐘,那麼吞吐量就是99%。
(圖畫錯了,老年代應該是並行收集纔對)
CMS收集器是基於「標記-清除」算法實現的,整個收集過程大體分爲4個步驟:
①.初始標記(CMS initial mark)
②.併發標記(CMS concurrenr mark)
③.從新標記(CMS remark)
④.併發清除(CMS concurrent sweep)
其中初始標記、從新標記這兩個步驟任然須要停頓其餘用戶線程(Stop The World)。
初始標記僅僅只是標記出 GC ROOTS 能直接關聯到的對象,速度很快,併發標記階段是進行 GC ROOTS 根搜索算法階段,會斷定對象是否存活。而從新標記階段則是爲了修正併發標記期間,因用戶程序繼續運行而致使標記產生變更的那一部分對象的標記記錄,這個階段的停頓時間會被初始標記階段稍長,但比並發標記階段要短。
因爲整個過程當中耗時最長的併發標記和併發清除過程當中,收集器線程均可以與用戶線程一塊兒工做,因此總體來講,CMS收集器的內存回收過程是與用戶線程一塊兒併發執行的。
關於CMS的三個缺點,這裏有更詳細的解釋說明:
CMS收集器對CPU資源很是敏感。在併發(併發標記、併發清除)階段,雖然不會致使用戶線程停頓,可是會佔用CPU資源而致使應用程序變慢,總吞吐量降低。CMS默認啓動的回收線程數是:(CPU數量+3) / 4。收集器線程所佔用的CPU數量爲:(CPU+3)/4=0.25+3/(4*CPU)。所以這時垃圾收集器始終不會佔用少於25%的CPU,所以當進行併發階段時,雖然用戶線程能夠跑,可是很緩慢,特別是雙核CPU的時候,已經佔用了5/8的CPU,吞吐量會很低。爲了解決這種狀況,產生了「增量式併發收集器」(Incremental Concurrent Mark Sweep/i-CMS)。就是採用搶佔方式來模擬多任務機制,就是在併發(併發標記、併發清除)階段,讓GC線程、用戶線程交替執行,儘可能減小GC線程獨佔CPU,這樣垃圾收集過程更長,可是對用戶程序影響小一些。實際上i-CMS效果很通常,目前已經被聲明爲「deprecated」。
CMS收集器沒法處理浮動垃圾,可能出現「Concurrent Mode Failure「,失敗後而致使另外一次Full GC的產生。因爲CMS併發清理階段用戶線程還在運行,伴隨程序的運行自熱會有新的垃圾不斷產生,這一部分垃圾出如今標記過程以後,CMS沒法在本次收集中處理它們,只好留待下一次GC時將其清理掉。這一部分垃圾稱爲「浮動垃圾」。也是因爲在垃圾收集階段用戶線程還須要運行,即須要預留足夠的內存空間給用戶線程使用,所以CMS收集器不能像其餘收集器那樣等到老年代幾乎徹底被填滿了再進行收集,須要預留一部份內存空間提供併發收集時的程序運做使用。在默認設置下,CMS收集器在老年代使用了68%的空間時就會被激活,也能夠經過參數-XX:CMSInitiatingOccupancyFraction的值來提升觸發百分比,以下降內存回收次數提升性能。JDK1.6中,CMS收集器的啓動閾值已經提高到92%。要是CMS運行期間預留的內存沒法知足程序其餘線程須要,就會出現「Concurrent Mode Failure」失敗,這時候虛擬機將啓動後備預案:臨時啓用Serial Old收集器來從新進行老年代的垃圾收集,這樣停頓時間就很長了。因此說參數-XX:CMSInitiatingOccupancyFraction設置的太高將會很容易致使「Concurrent Mode Failure」失敗,性能反而下降。
最後一個缺點,CMS是基於「標記-清除」算法實現的收集器,使用「標記-清除」算法收集後,會產生大量碎片。空間碎片太多時,將會給對象分配帶來不少麻煩。好比說大對象,內存空間找不到連續的空間來分配不得不提早觸發一次Full GC。爲了解決這個問題,CMS收集器提供了一個-XX:UseCMSCompactAtFullCollection開關參數,用於在Full GC以後增長一個內存碎片的合併整理過程,可是內存整理過程是沒法併發的,所以解決了空間碎片問題,卻使停頓時間變長。還可經過-XX:CMSFullGCBeforeCompaction參數設置執行多少次不壓縮的Full GC以後,跟着來一次碎片整理過程(默認值是0,表示每次進入Full GC時都進行碎片整理)。
G1收集器之因此能創建可預測的停頓時間模型,是由於它能夠有計劃地避免在整個Java堆中進行全區域的垃圾收集。
在G1以前的其餘收集器進行收集的範圍都是整個新生代或者老年代,而G1再也不是這樣。使用G1收集器時,Java堆的內存佈局與就與其餘收集器有很大差異,它將整個Java堆劃分爲多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代再也不是物理隔離的了,它們都是一部分Region(不須要連續)的集合。G1跟蹤各個Region裏面的垃圾堆積的價值大小(回收所得到的空間大小以及回收所需時間的經驗值),在後臺維護一個優先列表,每次根據容許的收集時間,優先回價值最大的Region(這也就是Garbage-First名稱的來由)。這種使用Region劃份內存空間以及有優先級的區域回收方式,保證了G1收集器在有限的時間內獲能夠獲取儘量高的收集效率。
可是,G1把內存「化整爲零」的思路,理解起來彷佛很容易理解,其中的實現細節卻遠遠沒有現象中簡單,不然也不會從04年Sun實驗室發表第一篇G1的論文拖至今將近8年時間都尚未開發出G1的商用版。筆者舉個一個細節爲例:把Java堆分爲多個Region後,垃圾收集是否就真的能以Region爲單位進行了?聽起來瓜熟蒂落,再仔細想一想就很容易發現問題所在:Region不多是孤立的。一個對象分配在某個Region中,它並不是只能被本Region中的其餘對象引用,而是能夠與整個Java堆任意的對象發生引用關係。那在作可達性斷定肯定對象是否存活的時候,豈不是還得掃描整個Java堆才能保障準確性?這個問題其實並不是在G1中才有,只是在G1中更加突出了而已。在之前的分代收集中,新生代的規模通常都比老年代要小許多,新生代的收集也比老年代要頻繁許多,那回收新生代中的對象也面臨過相同的問題,若是回收新生代時也不得不一樣時掃描老年代的話,Minor GC的效率可能降低很多。。
在G1收集器中Region之間的對象引用以及其餘收集器中的新生代與老年代之間的對象引用,虛擬機都是使用Remembered Set來避免全堆掃描的。G1中每一個Region都有一個與之對應的Remembered Set,虛擬機發現程序在對Reference類型的數據進行寫操做時,會產生一個Write Barrier暫時中斷寫操做,檢查Reference引用的對象是否處於不一樣的Region之中(在分代的例子中就是檢查引是否老年代中的對象引用了新生代中的對象),若是是,便經過CardTable把相關引用信息記錄到被引用對象所屬的Region的Remembered Set之中。當進行內存回收時,GC根節點的枚舉範圍中加入Remembered Set便可保證不對全堆掃描也不會有遺漏。
忽略Remembered Set的維護,G1的運行步驟可簡單描述爲:
①.初始標記(Initial Marking) ②.併發標記(Concurrenr Marking) ③.最終標記(Final Marking) ④.篩選回收(Live Data Counting And Evacution)
1.初始標記:初始標記僅僅標記GC Roots能直接關聯到的對象,而且修改TAMS(Next Top at Mark Start)的值,讓下一階段用戶程序併發運行時,能在正確可用的Region中建立新的對象。這階段須要停頓線程,不可並行執行,可是時間很短。
2.併發標記:此階段是從GC Roots開始對堆中對象進行可達性分析,找出存活對象,此階段時間較長可與用戶程序併發執行。
3.最終標記:此階段是爲了修正在併發標記期間由於用戶線程繼續運行而致使標記產生變更的那一份標記記錄,虛擬機將這段時間對象變化記錄在線程Remembered Set Logs裏面,最終標記階段須要把Remembered Set Logs的數據合併到Remembered Set中,這段時間須要停頓線程,可是可並行執行。
4.篩選回收:對各個Region的回收價值和成本進行排序,根據用戶指望的GC停頓時間來制定回收計劃。
-XX:+<option> 啓用選項
-XX:-<option> 不啓用選項
-XX:<option>=<number>
-XX:<option>=<string>
表面上看,Java 和 C 比起來,因爲內存的動態分配與內存回收技術已經相對成熟,平常的代碼中也不怎麼須要關注內存的申請與釋放。爲何咱們還要關注這些問題呢?
筆者認爲,一方面越是日常不會關注的東西,在關鍵的時候越珍貴,由於存在排查各類內存溢出、內存泄漏問題、又或者當垃圾收集稱爲系統達到更高併發量瓶頸時,對這些「自動化」功能細節的瞭解,爲咱們提供了更廣闊的思路。另外一方面,不一樣業務場景總有類似的一面,今天借鑑到的實現思想的細節,一直積累下去,或許將來的某天忽然就豁然開朗了。
《深刻理解Java虛擬機:JVM高級特效與最佳實現》,第2-3章——周志明著