Java語言從出現到現在,一直佔據程式設計語言前列,他很大的一個原因就是由於java應用程式所啟動並執行平台有關。我們大家都知道java應用程式運行在java虛擬機器上。這樣就大大減少了java應用程式和底層作業系統打交道的頻率。這也就為java程式的跨平台提供了良好的基礎。在java虛擬機器中為我們提供了一個很重要的機制就是java虛擬機器的自動的記憶體管理機制。也就是我們平時所說的記憶體回收機制,這使得開發人員不用自己來管理應用中的記憶體。C/C++開發人員需要通過malloc/free 和new/delete等函數來顯式的分配和釋放記憶體。這對開發人員提出了比較高的要求,容易造成記憶體訪問錯誤和記憶體泄露等問題。今天我們就一起來看一下java虛擬機器給我們提供的這個強大的功能——自動記憶體回收機制。
我們在c/c++的程式中,他們沒有java中的自動記憶體回收機制,這就需要開發人員手動的去分配和釋放記憶體,這樣就要求我們的開發人員要有一定的細心和對記憶體管理的經驗。如果記憶體管理不好,很容易產生最常見的兩個問題。一是“懸掛引用”,二是記憶體溢出。所為的懸掛引用就是一個對象引用所指向的記憶體區塊已經被錯誤的回收並重新分配給新的對象了,程式如果繼續使用這個引用的話會造成不可預期的結果。第二個記憶體溢出就很好理解了,開發人員在做開發的過程中,只顯示的申請記憶體而忘記用完釋放掉記憶體,這樣長時間會導致記憶體溢出的情況。而像java這種具有自動管理記憶體機制的語言來說,我們開發人員只需考慮引用的運用就可以,把記憶體管理這塊交給我們的語言運行環境來管理。。開發人員並不需要關心記憶體的分配和回收的底層細節。Java平台通過記憶體回收行程來進行自動的記憶體管理。這樣就大大減少了開發人員的工作量
一、Java記憶體回收機制
Java 的記憶體回收行程要負責完成3 件任務:
1.分配記憶體
2.確保被引用的對象的記憶體不被錯誤回收
3.回收不再被引用的對象的記憶體空間。
記憶體回收是一個複雜而且耗時的操作。如果JVM 花費過多的時間在記憶體回收上,則勢必會影響應用的運行效能。一般情況下,當記憶體回收行程在進行回收操作的時候,整個應用的執行是被暫時中止(stop-the-world)的。這是因為記憶體回收行程需要更新應用中所有對象引用的實際記憶體位址。不同的硬體平台所能支援的記憶體回收方式也不同。比如在多CPU 的平台上,就可以通過並行的方式來回收垃圾。而單CPU 平台則只能串列進行。不同的應用所期望的記憶體回收方式也會有所不同。伺服器端應用可能希望在應用的整個已耗用時間中,花在記憶體回收上的時間總數越小越好。而對於與使用者互動的應用來說,則可能希望所記憶體回收所帶來的應用停頓的時間間隔越小越好。對於這種情況,JVM 中提供了多種記憶體回收方法以及對應的效能調優參數,應用可以根據需要來進行定製。
二、判斷對象是否該被回收演算法
1.引用計數演算法
給對象添加一個引用計數器,每當有一個地方引用它時,計數器值就加1,當引用失效時,計數器值就減1;任何時刻計數器值都為0時對象就表示它不可能被使用了。這個演算法實現簡單,但很難解決對象之間循環參考的問題,因此Java並沒有用這種演算法!這是很多人都誤解了的地方。
2.根搜尋演算法
通過一系列名為“GC ROOT”的對象作為起始點,從這些結點開始向下搜尋,搜尋所走過的路徑稱為引用鏈,當一個對象到GC ROOT沒有任何引用鏈相連時,則證明這個對象是停用。如果對象在進行根搜尋後發現沒有與GC ROOT相串連的引用鏈,則會被第一次第標記,並看此對象是否需要執行finalize()方法(忘記finalize()這個方法吧,它可以被try-finally或其他方式代替的),當第二次被標記時,對象就會被回收。
三、Java虛擬機器基本記憶體回收演算法:
1.標記-清除(Mark-Sweep)
此演算法執行分兩階段。第一階段從引用根節點開始標記所有被引用的對象,第二階段遍曆整個堆,把未標記的對象清除。它停止所有工作,收集器從根開始訪問每一個活躍的節點,標記它所訪問的每一個節點。走過所有引用後,收集就完成了,然後就對堆進行清除(即對堆中的每一個對象進行檢查),所有沒有標記的對象都作為記憶體回收並返回空閑列表。 展示了垃圾收集之前的堆,陰影塊是垃圾,因為使用者程式不能到達它們:
可到達和不可到達的對象
標記-清除實現起來很簡單,可以容易地回收迴圈的結構,並且不像引用計數那樣增加編譯器或者賦值函數的負擔。但是它也有不足 ―― 收集暫停可能會很長,在清除階段整個堆都是可訪問的,這對於可能有頁面交換的堆的虛擬記憶體系統有非常負面的效能影響。
標記-清除的最大問題是,每一個活躍的(即已指派的)對象,不管是不是可到達的,在清除階段都是可以訪問的。因為很多個物件都可能成為垃圾,這意思著收集器花費大量精力去檢查並處理垃圾。標記-清除收集器還容易使堆產生片段,這會產生地區性問題並可以造成分配失敗,即使看來有足夠的自由記憶體可用。此演算法需要暫停整個應用,同時,會產生記憶體片段。
2.複製(Copying)
此演算法把記憶體空間劃為兩個相等的地區,每次只使用其中一個地區。記憶體回收時,遍曆當前使用地區,把正在使用中的對象複製到另外一個地區中。次演算法每次只處理正在使用中的對象,因此複製成本比較小,同時複製過去以後還能進行相應的記憶體整理,不過出現“片段”問題。當然,此演算法的缺點也是很明顯的,就是需要兩倍記憶體空間。
3.標記-整理(Mark-Compact)
此演算法結合了“標記-清除”和“複製”兩個演算法的優點。也是分兩階段,第一階段從根節點開始標記所有被引用對象,第二階段遍曆整個堆,把清除未標記對象並且把存活對象“壓縮”到堆的其中一塊,按順序排放。此演算法避免了“標記-清除”的片段問題,同時也避免了“複製”演算法的空間問題。
4.增量收集(Incremental Collecting)
實施記憶體回收演算法,即:在應用進行的同時進行記憶體回收。不知道什麼原因JDK5.0中的收集器沒有使用這種演算法的。
5.分代(Generational Collecting)
將堆分成新生代(Eden, From Survivor, To Survivor)和老年代,在新生代中使用複製演算法,即Minor-GC,當一些對象經過多次的Minor-GC後還留在新生代,則會被搬移到老年代中。而老年代中使用標記-清理或標記-整理演算法,即Major GC/Full GC。
-XX:PretenurseSizeThreshold=1024,則大於次參數的對象會直接分配到老年代(儘可能不要寫一些“短命大對象”!)
-XX:MaxTenuringThreshold=15,在survivor空間存活15次之後,則會搬移到老年代
如果是Survivor空間中相同年齡所有對象大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的對象就可以直接進入老年代。
進行Minor GC時,虛擬機器會檢測之前每次晉陞到老年代的平均大小是否大於老年代的剩餘空間大小,如果大於,則直接進行一次Full GC。
在對垃圾收集演算法進行評價時,我們可能要考慮以下所有標準:
· 暫停時間。收集器是否停止所有工作來進行垃圾收集?要停止多長時間?暫停是否有時間限制?
· 暫停可預測性。垃圾收集暫停是否規劃為在使用者程式方便而不是垃圾收集器方便的時間發生?
· CPU 佔用。總的可用 CPU 時間用在垃圾收集上的百分比是多少?
· 記憶體大小。許多垃圾收集演算法需要將堆分割成獨立的記憶體空間,其中一些空間在某些時刻對使用者程式是不可訪問的。這意味著堆的實際大小可能比使用者程式的最大堆駐留空間要大幾倍。
· 虛擬記憶體互動。在具有有限實體記憶體的系統上,一個完整的垃圾收集在垃圾收集過程中可能會錯誤地將非常駐頁面放到記憶體中來進行檢查。因為分頁錯誤的成本很高,所以垃圾收集器正確管理引用的地區性 (locality) 是很必要的。
· 緩衝互動。即使在整個堆可以放到主記憶體中的系統上 ―― 實際上幾乎所有 Java 應用程式都可以做到這一點,垃圾收集也常常會有將使用者程式使用的資料衝出緩衝的效果,從而影響使用者程式的效能。
· 對程式地區性的影響。雖然一些人認為垃圾收集器的工作只是收回不可到達的記憶體,但是其他人認為垃圾收集器還應該盡量改進使用者程式的引用地區性。整理收集器和複製收集器在收集過程中重新安排對象,這有可能改進地區性。
· 編譯器和運行時影響。一些垃圾收集演算法要求編譯器或者運行時環境的重要配合,如當進行指標分配時更新引用計數。這增加了編譯器的工作,因為它必鬚生成這些簿記指令,同時增加了運行時環境的開銷,因為它必須執行這些額外的指令。這些要求對效能有什麼影響呢?它是否會干擾編譯時間最佳化呢?
不管選擇什麼演算法,硬體和軟體的發展使垃圾收集更具有實用性。20 世紀 70 和 80 年代的經驗研究表明,對於大型 Lisp 程式,垃圾收集消耗 25% 到 40% 的運行時。垃圾收集還不能做到完全不可見,這肯定還有很長的路要走。
四、三種記憶體回收行程
目前的收集器主要有三種:串列收集器、並行收集器、並發收集器。
1.串列收集器
使用單線程處理所有記憶體回收工作,因為無需多線程互動,所以效率比較高。但是,也無法使用多處理器的優勢,所以此收集器適合單一處理器機器。當然,此收集器也可以用在小資料量(100M左右)情況下的多處理器機器上。可以使用-XX:+UseSerialGC開啟。
2.並行收集器
1)對年輕代進行並行記憶體回收,因此可以減少記憶體回收時間。一般在多線程多處理器機器上使用。使用-XX:+UseParallelGC.開啟。並行收集器在J2SE5.0第六6更新上引入,在Java SE6.0中進行了增強--可以堆年老代進行並行收集。如果年老代不使用並發收集的話,是使用單線程進行記憶體回收,因此會制約擴充能力。使用-XX:+UseParallelOldGC開啟。
2)使用-XX:ParallelGCThreads=<N>設定並行記憶體回收的線程數。此值可以設定與機器處理器數量相等。
3)此收集器可以進行如下配置:
最大記憶體回收暫停:指定記憶體回收時的最長暫停時間,通過-XX:MaxGCPauseMillis=<N>指定。<N>為毫秒.如果指定了此值的話,堆大小和記憶體回收相關參數會進行調整以達到指定值。設定此值可能會減少應用的輸送量。
輸送量:輸送量為記憶體回收時間與非記憶體回收時間的比值,通過-XX:GCTimeRatio=<N>來設定,公式為1/(1+N)。例如,-XX:GCTimeRatio=19時,表示5%的時間用於記憶體回收。預設情況為99,即1%的時間用於記憶體回收。
3.並發收集器
可以保證大部分工作都並發進行(應用不停止),記憶體回收只暫停很少的時間,此收集器適合對回應時間要求比較高的中、大規模應用。使用-XX:+UseConcMarkSweepGC開啟。
1)並發收集器主要減少年老代的暫停時間,他在應用不停止的情況下使用獨立的記憶體回收線程,跟蹤可達對象。在每個年老代記憶體回收周期中,在收集初期並發收集器會對整個應用進行簡短的暫停,在收集中還會再暫停一次。第二次暫停會比第一次稍長,在此過程中多個線程同時進行記憶體回收工作。
2)並發收集器使用處理器換來短暫的停頓時間。在一個N個處理器的系統上,並發收集部分使用K/N個可用處理器進行回收,一般情況下1<=K<=N/4。
3)在只有一個處理器的主機上使用並發收集器,設定為incremental mode模式也可獲得較短的停頓時間。
4)浮動垃圾:由於在應用啟動並執行同時進行記憶體回收,所以有些垃圾可能在記憶體回收進行完成時產生,這樣就造成了“Floating Garbage”,這些垃圾需要在下次記憶體回收周期時才能回收掉。所以,並發收集器一般需要20%的預留空間用於這些浮動垃圾。
5)Concurrent Mode Failure:並發收集器在應用運行時進行收集,所以需要保證堆在記憶體回收的這段時間有足夠的空間供程式使用,否則,記憶體回收還未完成,堆空間先滿了。這種情況下將會發生“併發模式失敗”,此時整個應用將會暫停,進行記憶體回收。
6)啟動並發收集器:因為並發收集在應用運行時進行收集,所以必須保證收集完成之前有足夠的記憶體空間供程式使用,否則會出現“Concurrent Mode Failure”。通過設定-XX:CMSInitiatingOccupancyFraction=<N>指定還有多少剩餘堆時開始執行並發收集
五、關於垃圾收集的幾點補充
經過上述的說明,可以發現記憶體回收有以下的幾個特點:
(1)垃圾收集發生的不可預知性:由於實現了不同的垃圾收集演算法和採用了不同的收集機制,所以它有可能是定時發生,有可能是當出現系統空閑CPU資源時發生,也有可能是和原始的垃圾收集一樣,等到記憶體消耗出現極限時發生,這與垃圾收集器的選擇和具體的設定都有關係。
(2)垃圾收集的精確性:主要包括2 個方面:(a)垃圾收集器能夠精確標記活著的對象;(b)垃圾收集器能夠精確地定位對象之間的參考關聯性。前者是完全地回收所有廢棄對象的前提,否則就可能造成記憶體流失。而後者則是實現歸併和複製等演算法的必要條件。所有不可達對象都能夠可靠地得到回收,所有對象都能夠重新分配,允許對象的複製和對象記憶體的縮並,這樣就有效地防止記憶體的支離破碎。
(3)現在有許多種不同的垃圾收集器,每種有其演算法且其表現各異,既有當垃圾收集開始時就停止應用程式的運行,又有當垃圾收集開始時也允許應用程式的線程運行,還有在同一時間垃圾收集多線程運行。
(4)垃圾收集的實現和具體的JVM 以及JVM的記憶體模型有非常緊密的關係。不同的JVM 可能採用不同的垃圾收集,而JVM 的記憶體模型決定著該JVM可以採用哪些類型垃圾收集。現在,HotSpot 系列JVM中的記憶體系統都採用先進的物件導向的架構設計,這使得該系列JVM都可以採用最先進的垃圾收集。
(5)隨著技術的發展,現代垃圾收集技術提供許多可選的垃圾收集器,而且在配置每種收集器的時候又可以設定不同的參數,這就使得根據不同的應用環境獲得最優的應用效能成為可能。
參考文獻
l Java 理論與實踐: 垃圾收集簡史
l Java SE 6 HotSpot[tm] Virtual Machine Garbage Collection Tuning
l Java HotSpot VM Options
l A Collection of JVM Options
l Java的記憶體回收機制