前言
我們都知道Java的垃圾回收並不需要程序員主動的去寫代碼回收內存,JVM會自動的幫我們去回收內存,既然JVM會自動幫我們進行內存回收,那是不是就不會出現內存不足的情況,顯然不是的。即使JVM幫我們進行回收,但是還是有可能出現內存溢出。下面主要將JVM GC(垃圾回收機制)分為幾個部分:對象是否是垃圾的判斷算法、垃圾收集算法、垃圾收集器,JVM調優幾個方面
1.垃圾判斷算法
垃圾收集器在對內存中的對象回收前,首先就是要確定內存中的對象是不是垃圾,那些對象還被引用着。判斷對象是否存活的算法有:1.1 引用計數算法,1.2 可達性分析算法
1.1引用計數算法
這種算法比較簡單,意思就是給每個對象添加一個引用計數器,每當這個對象在其他地方引用時,這個計數器就+1;引用失效時,這個計數器就-1;當這個計數器是0時就代表着這個對象可以被回收。
優點:實現起來也比較簡單,判斷能否被回收也簡單,效率快
缺點:無法解決對象之間循環引用的問題,假設有兩個對象,對象A,對象B,對象A引用對象B,對象B也引用對象A,這就造成了這兩個對象相互引用,這時計數器無法減為0,導致這兩個對象都無法被回收
1.2可達性分析算法
為了解決對象之間循環引用而導致無法被回收的問題,后面又出現了可達性分析算法。可達性分析算法的基本思想就是:通過被稱為"GC ROOT"的對象作為搜索起始點,從這些跟節點開始向下搜索,搜索走過的路徑就被稱為引用鏈,如果有些對象沒有在這些引用鏈上,說明這些對象是可以被回收的。算法如圖:
從圖中我們可以看出,可達性分析算法就可以解決對象之間循環依賴的問題,右邊的對象因為沒在GC ROOT所在的引用鏈上,所以JVM判定是可以被回收的。可達性分析算法也引申了另外一個問題,就是內存中的哪些對象可以被作為"GC ROOT"根節點?在Java中,可以被作為GC ROOT的對象包括以下幾種:
(1) 棧幀中局部變量表引用的對象,也就是我們所new一個對象,然后將這個new出來的對象賦值給一個變量,這個變量可以被作為"GC ROOT"根節點
(2) 方法區中類靜態屬性引用的對象,類中聲明的靜態成員變量變量
(3) 方法區只常量引用的對象,用final修飾的成員變量
無論是引用計數算法還是可達性分析算法,都牽涉到了對象的引用,而在JDK1.2以后,Java又將引用分為了4種,分別是強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)
強引用:強引用也是我們最熟悉的,在代碼中最常見的。類似"Object obj = new Object()"這類的引用,這要強引用還存在,這個對象就不會被垃圾回收給回收掉。如果我們想要該對象被回收的話,可以將obj = null,切斷變量obj與對象Object之間的強引用,這時對象就可以被垃圾回收
軟引用:軟引用跟強引用有點不同,看一段軟引用的使用場景:
SoftReference<Object> softName = new SoftReference<>(new Object());
JDK給我們提供SoftReference對象來使用軟引用,我們可以看到圖中SoftReference()對象有個軟引用執行Object()對象,JVM垃圾回收發現此類對象時,這要內存充足的情況下,對象Object就不會被回收掉。在內存不足的情況下,軟引用引用的Object對象就會被垃圾回收掉
弱引用:使用弱引用的對象擁有的生命周期更加的短,來看一段弱引用的使用場景:
WeakReference<Object> weakName = new WeakReference<Object>(new Object());
JDK給我們提供了WeakReference對象來使用弱引用,可以看到WeakReference()也有一條虛線指向Object()對象,表示虛引用,虛引用一個常見的例子就是ThreadLocal。弱引用跟軟引用的區別就是:JVM GC時,不管內存夠不夠,這要發現此類弱引用對象,對象Object對象就會被回收
虛引用:虛引用是引用中最弱的一種關系,形同虛設。相當於沒被引用一樣,也就相當於代碼中的obj = null,JVM GC一經發現就會被回收。看一段虛引用的場景:
ReferenceQueue<String> queue = new ReferenceQueue<String>();
PhantomReference<String> pr = new PhantomReference<String>(new String("hello"), queue);
我們可以看到虛引用是要配合引用隊列使用的,如果虛引用pr引用的String("hello")對象被回收掉了,就會將虛引用pr加入到queue隊列中,這時我們可以從queue隊列中知道pr引用的String("hello")對象被回收掉了。虛引用一個常見的場景就是JVM回收DirectByteBuffer直接內存的時候
在NIO中,JDK允許開辟堆外內存,也就是直接分配操作系統的內存,但是什么時候去回收直接內存又是一個問題,而JDK引出了虛引用,當JVM內存中的direct對象被回收時,此時就會將回收信息放到ReferenceQueue隊列中,這時JVM監控queue隊列,發現direct對象已經被回收了,這時候發現需要回收堆外內存,就會由GC去釋放直接內存
2.垃圾收集算法
前面一部分講的是哪些對象可以被作為垃圾進行回收,確定了對象可以被回收之后,那么就要講一下具體的垃圾收集了,這里討論幾種常見的垃圾收集算法:標記-清除算法、復制算法、標記-整理算法、分代收集算法
2.1 標記-清除算法
標記-清除算法是最基礎的算法,大致上可以分為兩個階段:標記、清除兩個階段。首先是標記階段,標記出所有可以被回收的對象,標記完成后,再進行清除階段,將所有已經標記的對象進行回收
從圖中我們可以看到被回收之后,存在大量不連續的內存碎片,會造成一個問題就是如果后面過來一個大對象,因為沒有足夠大的連續內存進行分配,全是內存碎片,則就會導致分配失敗。所有標記-清除算法一個缺點就是:(1)造成大量的內存碎片。還有一個缺點就是:(2) 標記和清除兩個階段的效率並不高
2.2 復制算法
為了解決標記-清除算法所帶來的內存碎片問題,出現了復制算法。基本思想就是將內存划分為兩塊大小相等的內存,每次只使用其中的一塊,當一塊的內存使用完成之后,就會將使用的那塊內存存活的對象復制到另外一塊未被使用的內存,再把使用過的內存清空,只需移動復制對象的指針,按順序分配內存
從圖中我們可以看出右邊那部分的內存完全沒有被使用到,這就會造成內存空間上的浪費。還有一個問題就是如果存活的對象過多,那么就需要移動對象的指針,這樣會造成效率問題。不過現在的JVM都采用復制算法來回收年輕代,因為年輕代中的對象具有"朝生夕死"的特點,也就是剛創建,說不定過一會這些對象就會馬上被回收掉,所以內存大小也並不一定要按復制算法那樣平等划分,而年輕代划分的eden:survivor區的比例是8:1:1,每次可以使用的內存是eden和survivor from區,這樣造成的內存空間浪費的比例就只有10%了。又因為年輕代中的對象生命周期比較短,所以復制對象比較少,大部分的對象已經被回收,需要移動復制對象指針也少。
優點:
1. 不會產生內存碎片
2. 回收效率高
缺點:
1. 浪費內存空間
2.3 標記-整理算法
標記清楚和復制算法所遺留下來的問題:(1) 產生內存碎片 (2) 內存空間的浪費。因為老年代的對象特點存活時間長,如果采用復制算法那么需要復制的對象就多,需要移動大量的復制對象指針,所以復制算法並不適用於老年代。而針對老年代的特點,有人提出了另外一種算法:標記-整理算法。標記-整理算法跟標記-清除算法有點類似,也是分為兩個階段,但是后續步驟不是直接對可回收對象進行清理,而是讓存活的對象都向一端進行移動
從圖中我們可以看出標記-整理算法解決了標記-清除算法會產生的內存碎片的問題,也解決了復制算法所造成的內存浪費的缺點,為什么標記-整理算法適合老年代???
1. 老年代回收次數少,整理過一次無需頻繁的整理
2. 對象存活率高,不需要過多的去移動對象,就假如有100個對象,經過一次GC,發現70個對象還存活,那么這70個對象無需移動,只需移動從年輕代往老年代的對象
2.4 分代收集算法
分代收集算法其實並不算是一種思想和理論,它只是融合了前面三種算法的思想,根據不同特區對象的特點而采用最適當的收集算法。前面我們講過新生代中的對象具有"朝生夕死"的特點,所以選用復制算法,只需要讓出少量的內存空間就可以完成收集。而老年代中的對象因為對象的存活率比較高,那么可以采用"標記-清理"或者"標記-整理"算法來進行回收
3.垃圾收集器
前面講的垃圾收集算法只是內存回收的理論,而垃圾收集器則是垃圾收集算法的具體實現,到現在一共出現了7種作用於不同分代的收集器,而不同的收集器之間可以進行組合使用
3.1 Serial收集器
Serial收集器是出現最早的收集器,是單線程的垃圾收集器,在執行GC時,只會有一個線程去進行垃圾回收,用戶線程被暫停,直到GC被執行完,這就造成了"Stop The World",也就是進行GC時,用戶線程必須暫停,如果這樣就會影響我們的程序效率
我們可以看到它是一種新生代的收集算法,並且采用的是復制算法
3.2 ParNew收集器
ParNew收集器跟Serial收集器區別就是:ParNew收集器會采用多線程去進行垃圾回收,這就充分可以利用CPU,ParNew收集器還有一個特點就是:除了Serial收集器外,只有ParNew收集器可以跟CMS收集器(后面會提到)一起組合使用
從圖中我們可以看到ParNew也是一款年輕代收集器,進行垃圾回收時,還是會暫停用戶線程,是多線程回收的,采用的也是復制算法,使用-XX:ParallelGCThreads控制運行GC線程數,默認:如果CPU <= 8,那就是8個線程數,如果CPU > 8, 那么線程數 = (5 * cpu_nums/8) + 3
3.3 Parallel Scavenge收集器
Parallel Scavenge也是一款多線程版本的垃圾收集器,也是對年輕代進行回收的,采用的也是復制算法。但是Parallel Scavenge收集器更加的關注可控制的吞吐量,所謂的吞吐量 = 運行用戶代碼時間 / (運行用戶代碼時間 + 垃圾收集時間)。
Parallel Scavenge收集器提供了兩個參數來控制吞吐量,一個是最大垃圾收集停頓時間-XX:MaxGCPauseMillis參數,還有一個就是設置吞吐量大小的-XX:GCTimeRatio參數。
這時可能會覺得我們將最大垃圾收集停頓時間-XX:MaxGCPauseMillis參數設置的越小不就越好嗎?答案是否定的,GC停頓時間縮短是以犧牲吞吐量和新生代空間換來的,系統會把新生代調小一點,回收300M大小的內存空間肯定要比回收500M的內存要快,因為你的內存變小的,裝的對象就小的,所以會頻繁的造成GC,以前每10秒才回收一次,一次回收停頓100毫秒,現在變成每5秒回收一次,一次回收停頓70毫秒,雖然停頓時間下降了,但是吞吐量也下降了
參數-XX:GCTimeRatio的設置垃圾收集時間占總時間的比率,相當於吞吐量的倒數,如果將此參數設置為19,那么允許的最大GC時間就占總時間的5%(即 1 / (1 + 19)) , 默認值為99,就是允許最大1%(即1 / (1 + 99))的垃圾收集時間
Parallel Scavenge收集器還提供一個參數-XX:+UseAdaptiveSizePolicy自適應的調節策略,如果我們設置了此參數,那么我們無須指定新生代的大小、Eden與Survivor區的比例、晉升老年代對象年齡等細節參數了,虛擬機會根據當前系統的運行情況收集性能監控信息,動態調整參數以達到最合適的停頓時間或者最大的吞吐量。如果我們使用Parallel Scavenge收集器時,我們只需關注MaxGCPauseMillis或GCTimeRatio參數,設置一個要優化到的目標,具體的優化調節細節可以交給Parallel Scavenge收集器去實現
3.4 Serial Old收集器
跟Serial收集器一樣,單線程的。跟Serial收集器不同的是,Serial Old收集器是作用在老年代中的,采用的是"標記-整理"算法
3.5 Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,作用於老年代中,采用"標記-整理"算法。在JDK1.6之前,Parallel Scavenge收集器只能配合Serial Old收集器一起使用,由於Serial Old收集器采用單線程效率不高,所以出現了Parallel Old收集器來配合Parallel Scavenge收集器一起使用,如果比較關注吞吐量和CPU資源的情況下可以采用這種組合
3.6 CMS收集器
CMS收集器關注的點是盡可能的縮短垃圾收集時用戶線程停頓的時間,希望停頓時間最短,這樣用戶線程執行越快。CMS收集器作用於老年代,采用的是"標記-清除"算法。CMS收集器運行的整個過程分為4個階段:
1. 初始標記
2.並發標記
3.重新標記
4.並發清除
在4個階段中,只有初始標記和重新標記是需要暫停用戶線程的,我們可以這樣記並發標記和並發清除是並發的,所以不需要暫停用戶線程。這樣用戶線程就沒必要在整個GC階段全部暫停,縮短了用戶線程的暫停時間
雖然CMS收集器可以降低用戶線程停頓時間,但是CMS也有幾個缺點,一個是對CPU資源非常敏感,我們可以看到在並發階段,GC線程和用戶線程是一起執行的,這樣就會產生一個問題,就會產生CPU資源的搶占,可能會造成線程之間交替運行,反而使效率降低。還有一個問題就是CMS收集器因為是采用"標記-清除"算法去實現的那么就會產生大量的內存空間碎片
3.7 G1收集器
G1收集器是目前既能回收年輕代,又能回收老年代的收集器,無須跟其他收集器進行組合使用,針對不同的代,保留了分代收集的特點,也就是針對不同的代采用不同的收集算法。G1收集器跟CMS的"標記-清除"算法不同,G1收集器采用的是不會產生內存碎片的"標記-整理"算法。G1相對於CMS收集器還有一大特點就是可預測的停頓,G1和CMS共同的關注點都是降低用戶線程停頓時間,但G1除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒,G1還可以設定GC暫停的時間,根據預測模型選取性價比收益更高。且將堆划分成一定數量的 Region(默認2048),每個Region能回收多少是多少在設定的時間內。
G1收集器運行的大致可以划分為4個步驟:
1.初始標記
2.並發標記
3.最終標記
4.篩選回收
下面來看一張各種收集器之間的組合使用的關系圖:
我們可以看到:
CMS可以跟Serial、ParNew組合使用,而Serial Old是CMS在老年代的后背方案
Serial Old可以跟Serial、ParNew、Parallel Scavenge組合使用
Parallel Old只可以跟Parallel Scavenge組合使用
而G1收集器則不需要任何組合,自己可以完成對年輕代和老年代的回收
4.JVM調優
JVM調優總的來說就是優化Full GC的執行時間和執行次數,讓Full GC盡可能的少發生,因為發生Full GC時,會造成用戶線程暫停,執行時間長。導致Full GC的出現就會老年代的空間不足,這時,可以通過下面幾點來進行優化:
1. 一般來說,當survivor區容量不夠時,就會將一些對象放到老年代中,可以進行設置合理的eden區,survivor區及比例大小,可以讓對象盡可能的留在年輕代,可以使用-Xmn設置年輕代的大小
2.對於占用內存比較大的對象,一般會優先選擇在老年代上分配內存,這時我們可以設置參數讓一些大對象也分配在年輕代中,我們設置參數-XX:PetenureSizeThreshold=1000000,單位為B,標明對象大小如果超過這個數值時在老年代上分配內存,否則還是在年輕代上進行分配
3.年輕對象在eden區時,每進行一次GC時,如果對象還活着,則這個對象的分代年齡就會+1,一但達到默認值15,就會被放到老年代中,這時我們可以設置參數-XX:MaxTenuringThreshold比較大的閾值,讓對象盡可能的停留在年輕代被回收掉
4.設置最小堆和最大堆:-Xmx
和-Xms
穩定的堆大小堆垃圾回收是有利的,我們應該將最大堆和最小堆設置成一樣的,這樣系統在運行時堆大小是恆定的,可以防止每次進行GC后,又得重新分配最小堆和最大堆。穩定的堆大小可以防止一個內存抖動的現象
5.通過增大吞吐量提高系統性能,可以通過設置並行垃圾回收收集器。(1)-XX:+UseParallelGC
:年輕代使用並行垃圾回收收集器。這是一個關注吞吐量的收集器,可以盡可能的減少垃圾回收時間。(2)-XX:+UseParallelOldGC
:設置老年代使用並行垃圾回收收集器
當然,JVM調優並不止這幾點,我只是例舉了幾點常見的優化場景來講解,JVM提供了許多的JVM參數來供我們進行設置,具體的參數設置還是要根據生產環境出現的問題進行具體的設置
總結
前面大概講了下判斷對象是否可以被回收的算法,垃圾回收算法,具體實現垃圾回收算法垃圾收集器,例舉了他們的一些不同點。最后針對JVM調優例舉了一些點,可以對JVM的啟動設置參數