在C語言中,有些由記憶體需要程式員在代碼中進行手動回收,但是在Java中,沒有這樣的聲明式操作。有沒有人有去想過,Java到底做了什麼可以自動進行垃圾回收呢?Java中的垃圾回收,是一點都不需要程式員關心,萬無一失的嗎?
本文将從:Jvm中的垃圾收集器和記憶體配置設定政策。虛拟機中對已經死亡的對象都有哪些垃圾回收是算法,兩部分和大家談談Java虛拟機的垃圾收集器與記憶體配置設定政策。
重垃圾收集器和記憶體配置設定政策
垃圾收集(Garbage Collection,GC),并不是随着Java一起誕生的。GC的曆史比Java來得更加久遠,早在1960年的時候,MIT的Lisp是第一門真正使用記憶體動态配置設定和垃圾收集技術的語言。當Lisp還在胚胎時期時,人們就在思考GC需要完成的三件事情:哪些記憶體需要回收?什麼時候回收?如何回收?
在經過半個世紀的發展後,對于這三個問題的答案越來越清晰,總結成就是:當需要排查各種記憶體溢出、記憶體洩漏問題時,當垃圾收內建為系統達到更高并發量的瓶頸時,我們就需要對這些“自動化”的技術實施必要的監控和調節。
在Java程式編寫的過程中,我們可以知道代碼的邏輯是怎樣的,但是具體的分支隻有在運作過程中才能知道。而這部分的記憶體配置設定和回收也是動态進行的,垃圾收集器主要關注的就是這部分記憶體。
那麼實際中,一個需要解決的問題就是,如何判斷對象是否存活,對于不再存活的對象,進行垃圾回收。
在經過漫長的發展後,目前主要有下面幾種算法來進行對象存活判斷。
▐ 引用計數算法
算法的定義為:給對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器都為0的對象就是不可能再被使用的。
這是實作簡單,且效率非常高效的一種算法。在redis、python的虛拟機、FlashPlayer等應用中,也都有采用這樣的算法。但是Java中并沒有采用這樣的算法實作,主要原因是其存在互相循環引用的問題。
簡單來說,A對象引用B對象,B對象引用A對象的情況下。A和B互相引用,于是他們的計數器都不會為0,于是GC收集器便就永遠無法回收他們。
▐ 根搜尋算法
算法的定義為:通過一系列名為“GC Roots”的對象作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鍊(Reference Chain),當一個對象到GC Roots沒有任何引用鍊相連,或者說不可達的時候,則證明此對象不可用。
在Java語言中,可以作為GC Roots的對象包括下面幾種:
- 虛拟機棧(棧幀中的本地變量表)中的引用的對象。
- 方法區中的類靜态屬性引用的對象。
- 方法區中的常量引用的對象。
- 本地方法棧中JNI(即一般說的Native方法)的引用的對象
▐ 引用
在早期的JDK定義中,引用的定義為,如果reference類型的資料中存儲的數值代表的是另外一塊記憶體的起始位址,就稱這塊記憶體代表着一個引用。但這樣的定義方式過于純粹,一個對象隻有兩種狀态,即被引用或者沒有被引用兩種。對于一些緩存類型的資料,則顯得有些雞肋,更無法展現記憶體配置設定的價值。
之後JDK對于引用進行了概念擴充,将引用分為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)四種,這四種引用強度依次逐漸減弱。
- 強引用就是指在程式代碼之中普遍存在的,類似“Object obj = new Object()”這類的引用,隻要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象。
- 軟引用用來描述一些還有用,但并非必需的對象。對于軟引用關聯着的對象,在系統将要發生記憶體溢出異常之前,将會把這些對象列進回收範圍之中并進行第二次回收。如果這次回收還是沒有足夠的記憶體,才會抛出記憶體溢出異常。在JDK 1.2之後,提供了SoftReference類來實作軟引用。
- 弱引用也是用來描述非必需對象的,但是它的強度比軟引用更弱一些,被弱引用關聯的對象隻能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論目前記憶體是否足夠,都會回收掉隻被弱引用關聯的對象。在JDK 1.2之後,提供了WeakReference類來實作弱引用。
- 虛引用也稱為幽靈引用或者幻影引用,它是最弱的一種引用關系。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象執行個體。為一個對象設定虛引用關聯的唯一目的就是希望能在這個對象被收集器回收時收到一個系統通知。在JDK 1.2之後,提供了PhantomReference類來實作虛引用。
▐ 是否死亡
在根搜尋算法中,在GCRoots沒有可以到達的引用鍊之後,就一定會“死亡”嗎?其實也不一定,要真正宣告一個對象死亡,至少要經曆兩次标記過程:如果對象在進行根搜尋後發現沒有與GCRoots相連接配接的引用鍊,那它将會被第一次标記并且進行一次篩選,篩選的條件是此對象是否有必要執行finalize()方法。當對象沒有覆寫finalize()方法,或者finalize()方法已經被虛拟機調用過,虛拟機将這兩種情況都視為“沒有必要執行”。
當這個對象需要執行finalize()方法時,這個對象會被放置在一個名為F-Queue的隊列中,并稍後由一條虛拟機自動建立的、低優先級的Finalizer線程去執行。這裡的“執行”是虛拟機會觸發這個方法,但并不一定會等待它運作結束。因為如果對象在finalize()方法中死循環或者超長時間執行,可能導緻F-Queue隊列中的其他對象永久處于等待狀态,甚至可能導緻記憶體回收系統奔潰。
finalize()方法是對象可以存活的最後一次機會,在這裡可以将自己和引用鍊上的任何一個對象建立關聯即可,否則就會進入到垃圾回收的系統中。但finalize()依舊是一種充滿不确定性的方法,在誕生之初亦是為了C/C++程式員的更容易接受的一種妥協,推薦目前的try-finally方法處理更加優雅,也更安全可靠。
接着我們一起來看看虛拟機中對已經死亡的對象都有哪些垃圾回收是算法。
▐ 标記-清除算法
标記-清除算法(Mark-Sweep)可以說應該是最基礎的收集算法了。從字面意思很好了解,算法的過程分為标記過程和清楚過程。首先标記出所有需要回收的對象,在标記完了之後,對标記對象進行統一的回收工作。哪些對象需要标記,哪些對象不需要标記,這個再上一篇文章中進行了詳細的介紹,可以回顧再了解下。
這個算法的缺點也非常明顯,記憶體中的被标記的資料不一定都是連續,是以标記清楚之後,記憶體中會産生大量的記憶體碎片,碎片的存在也會導緻在後續配置設定較大對象時候找不到足夠的連續空間,導緻記憶體不足。還有一個問題,便是标記和清楚的效率都不高。
但之是以說這是最基礎的收集算法,是因為後續是算法基本上都是由此改進得來的。

▐ 複制算法
為了解決效率問題,誕生了一種叫複制(Copying)的算法。該算法将可以用的記憶體空間劃分為兩大塊,每次隻使用其中的一塊。當這塊記憶體使用完了之後,就将還存活的對象複制到另一塊空間中去。這樣就不需要考慮記憶體碎片的問題,隻需要移動堆頂指針,按順序配置設定記憶體即可,簡單高效。同樣缺點也很明顯,這樣做了之後很明顯,我們隻能使用記憶體中的一半記憶體。代價還是比較高。
那麼目前的虛拟機新生代中,就采用了這種回收算法。新生代的空間相對較小,記憶體空間由Eden,和兩塊Survivor空間組成,配置設定比例為8:1:1,也就是最多隻有10%的空間是處于空閑的。當進行回收時,将新生代的Eden和其中一塊的Survivor中的還存活的對象一次性拷貝到另一塊Survivor的空間上,然後清理掉Eden和剛才用過的Survivor的空間。如果當Survivor的無法存放時候,就會進入老年代存放。
▐ 标記-整理算法
複制算法在對象存活較高的時候,就會執行較多的複制操作,進而降低整體的回收效率,還有存在50%的空間浪費。基于這種情況,有人對标記-清楚算法進行改進,進而衍生出标記-整理(Mark-Compact)算法。
這種算法的标記過程和”标記-清楚“算法一緻,不同的是标記完成之後,讓所有存活的對象都移動到記憶體的一端,然後清理掉邊界外面的記憶體。
▐ 分代收集算法
目前商業虛拟機的垃圾收集都采用“分代收集”(Generational Collection)算法,這種算法并沒有什麼新的思想,隻是根據對象的存活周期的不同将記憶體劃分為幾塊。一般是把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點采用最适當的收集算法。在新生代中,每次垃圾收集時都發現有大批對象死去,隻有少量存活,那就選用複制算法,隻需要付出少量存活對象的複制成本就可以完成收集。而老年代中因為對象存活率高、沒有額外空間對它進行配置設定擔保,就必須使用“标記-清理”或“标記-整理”算法來進行回收。
哪些垃圾回收是算法?
▐ 垃圾收集器
收集算法是用以支撐記憶體回收的理論,在虛拟機中對應的具體實作就是垃圾收集器。不同的廠商和開發者,可以依據自己的應用特點來實作對應的收集器,是以不同版本之間的收集器可能存在較大的差别。
以下收集器内容摘錄自參考書籍《深入了解Java虛拟機》
▐ Serial垃圾收集器
Serial是最基本、曆史最悠久的垃圾收集器,使用複制算法,曾經是JDK1.3.1之前新生代唯一的垃圾收集器。
Serial是一個單線程的收集器,它不僅僅隻會使用一個CPU或一條線程去完成垃圾收集工作,并且在進行垃圾收集的同時,必須暫停其他所有的工作線程,直到垃圾收集結束。
Serial垃圾收集器雖然在收集垃圾過程中需要暫停所有其他的工作線程,但是它簡單高效,對于限定單個CPU環境來說,沒有線程互動的開銷,可以獲得最高的單線程垃圾收集效率,是以Serial垃圾收集器依然是java虛拟機運作在Client模式下預設的新生代垃圾收集器。
▐ ParNew垃圾收集器
ParNew垃圾收集器其實是Serial收集器的多線程版本,也使用複制算法,除了使用多線程進行垃圾收集之外,其餘的行為和Serial收集器完全一樣,ParNew垃圾收集器在垃圾收集過程中同樣也要暫停所有其他的工作線程。
ParNew收集器預設開啟和CPU數目相同的線程數,可以通過-XX:ParallelGCThreads參數來限制垃圾收集器的線程數。
ParNew雖然是除了多線程外和Serial收集器幾乎完全一樣,但是ParNew垃圾收集器是很多java虛拟機運作在Server模式下新生代的預設垃圾收集器。
▐ Parallel Scavenge收集器
Parallel Scavenge收集器也是一個新生代垃圾收集器,同樣使用複制算法,也是一個多線程的垃圾收集器,它重點關注的是程式達到一個可控制的吞吐量(Thoughput,CPU用于運作使用者代碼的時間/CPU總消耗時間,即吞吐量=運作使用者代碼時間/(運作使用者代碼時間+垃圾收集時間)),高吞吐量可以最高效率地利用CPU時間,盡快地完成程式的運算任務,主要适用于在背景運算而不需要太多互動的任務。
Parallel Scavenge收集器提供了兩個參數用于精準控制吞吐量:
- XX:MaxGCPauseMillis:控制最大垃圾收集停頓時間,是一個大于0的毫秒數。
- XX:GCTimeRation:直接設定吞吐量大小,是一個大于0小于100的整數,也就是程式運作時間占總時間的比率,預設值是99,即垃圾收集運作最大1%(1/(1+99))的垃圾收集時間。
Parallel Scavenge是吞吐量優先的垃圾收集器,它還提供一個參數:-XX:+UseAdaptiveSizePolicy,這是個開關參數,打開之後就不需要手動指定新生代大小(-Xmn)、Eden與Survivor區的比例(-XX:SurvivorRation)、新生代晉升年老代對象年齡(-XX:PretenureSizeThreshold)等細節參數,虛拟機會根據目前系統運作情況收集性能監控資訊,動态調整這些參數以達到最大吞吐量,這種方式稱為GC自适應調節政策,自适應調節政策也是ParallelScavenge收集器與ParNew收集器的一個重要差別。
▐ Serial Old收集器
Serial Old是Serial垃圾收集器年老代版本,它同樣是個單線程的收集器,使用标記-整理算法,這個收集器也主要是運作在Client預設的java虛拟機預設的年老代垃圾收集器。
在Server模式下,主要有兩個用途:
- 在JDK1.5之前版本中與新生代的Parallel Scavenge收集器搭配使用。
- 作為年老代中使用CMS收集器的後備垃圾收集方案。
▐ Parallel Old收集器
Parallel Old收集器是Parallel Scavenge的年老代版本,使用多線程的标記-整理算法,在JDK1.6才開始提供。
在JDK1.6之前,新生代使用ParallelScavenge收集器隻能搭配年老代的Serial Old收集器,隻能保證新生代的吞吐量優先,無法保證整體的吞吐量,Parallel Old正是為了在年老代同樣提供吞吐量優先的垃圾收集器,如果系統對吞吐量要求比較高,可以優先考慮新生代Parallel Scavenge和年老代Parallel Old收集器的搭配政策。
▐ CMS收集器
Concurrent mark sweep(CMS)收集器是一種年老代垃圾收集器,其最主要目标是擷取最短垃圾回收停頓時間,和其他年老代使用标記-整理算法不同,它使用多線程的标記-清除算法。
最短的垃圾收集停頓時間可以為互動比較高的程式提高使用者體驗,CMS收集器是Sun HotSpot虛拟機中第一款真正意義上并發垃圾收集器,它第一次實作了讓垃圾收集線程和使用者線程同時工作。
CMS工作機制相比其他的垃圾收集器來說更複雜,整個過程分為以下4個階段:
- 初始标記:隻是标記一下GC Roots能直接關聯的對象,速度很快,仍然需要暫停所有的工作線程。
- 并發标記:進行GC Roots跟蹤的過程,和使用者線程一起工作,不需要暫停工作線程。
- 重新标記:為了修正在并發标記期間,因使用者程式繼續運作而導緻标記産生變動的那一部分對象的标記記錄,仍然需要暫停所有的工作線程。
- 并發清除:清除GC Roots不可達對象,和使用者線程一起工作,不需要暫停工作線程。
由于耗時最長的并發标記和并發清除過程中,垃圾收集線程可以和使用者現在一起并發工作,是以總體上來看CMS收集器的記憶體回收和使用者線程是一起并發地執行。
CMS收集器有以下三個不足:
- CMS收集器對CPU資源非常敏感,其預設啟動的收集線程數=(CPU數量+3)/4,在使用者程式本來CPU負荷已經比較高的情況下,如果還要分出CPU資源用來運作垃圾收集器線程,會使得CPU負載加重。
-
CMS無法處理浮動垃圾(Floating Garbage),可能會導緻Concurrent ModeFailure失敗而導緻另一次Full GC。由于CMS收集器和使用者線程并發運作,是以在收集過程中不斷有新的垃圾産生,這些垃圾出現在标記過程之後,CMS無法在本次收集中處理掉它們,隻好等待下一次GC時再将其清理掉,這些垃圾就稱為浮動垃圾。
CMS垃圾收集器不能像其他垃圾收集器那樣等待年老代機會完全被填滿之後再進行收集,需要預留一部分空間供并發收集時的使用,可以通過參數-XX:CMSInitiatingOccupancyFraction來設定年老代空間達到多少的百分比時觸發CMS進行垃圾收集,預設是68%。
如果在CMS運作期間,預留的記憶體無法滿足程式需要,就會出現一次ConcurrentMode Failure失敗,此時虛拟機将啟動預備方案,使用Serial Old收集器重新進行年老代垃圾回收。
- CMS收集器是基于标記-清除算法,是以不可避免會産生大量不連續的記憶體碎片,如果無法找到一塊足夠大的連續記憶體存放對象時,将會觸發是以Full GC。CMS提供一個開關參數-XX:+UseCMSCompactAtFullCollection,用于指定在Full GC之後進行記憶體整理,記憶體整理會使得垃圾收集停頓時間變長,CMS提供了另外一個參數-XX:CMSFullGCsBeforeCompaction,用于設定在執行多少次不壓縮的Full GC之後,跟着再來一次記憶體整理。
▐ G1收集器
Garbage first垃圾收集器是目前垃圾收集器理論發展的最前沿成果,相比與CMS收集器,G1收集器兩個最突出的改進是:
- 基于标記-整理算法,不産生記憶體碎片。
- 可以非常精确控制停頓時間,在不犧牲吞吐量前提下,實作低停頓垃圾回收。
G1收集器避免全區域垃圾收集,它把堆記憶體劃分為大小固定的幾個獨立區域,并且跟蹤這些區域的垃圾收集進度,同時在背景維護一個優先級清單,每次根據所允許的收集時間,優先回收垃圾最多的區域。
區域劃分和優先級區域回收機制,確定G1收集器可以在有限時間獲得最高的垃圾收集效率。
總結
其實相對于C和C++語言,Java程式員依賴JVM的強大記憶體管理能力,已經不再需要對記憶體進行配置設定或者釋放等操作。是以Java程式員往往很少關注記憶體中潛在的洩露和溢出等問題。但當這個問題出現時候,如果對虛拟機記憶體管理機制沒有足夠多的掌握,會難以定位和解決問題。去了解虛拟機的發展曆程以及現有的管理機制,可以更好地了解為什麼這樣設計,同樣能提高自己的問題解決能力。
淘系技術部-天貓奢侈品團隊
我們是一支支撐天貓奢侈品、品牌客戶、淘寶心選等大店資料化經營解決方案的技術團隊,依托于阿裡大中台推動品牌經營解決方案更新,不斷提升客戶經營的效率,持續提升業務價值賦能業務。
如果您有興趣可講履歷發至:[email protected],期待您的加入!
關注「淘系技術」微信公衆号,一個有溫度有内容的技術社群~