到底谁才是垃圾?
△Hollis, 一個對Coding有著獨特追求的人△
這是Hollis的第?363?篇原創分享
作者 l zyz1992
來源 l Hollis(ID:hollischuang)
作為 Java 程序員,我們是幸福的,因為我們不需要管理系統中的垃圾。我們只需要將重點放在業務中就可以了。至于垃圾什么的就交給天生的垃圾收集器就可以了。
那既然都這么說了,我們干嘛還要花心思來學習這些呢?我們學習這些肯定是為了更好的理解我們系統的底層運行原理啊,這樣才能有針對性的寫出 “更適合” JVM的代碼。才能讓我們的代碼更健壯和安全,更關鍵的是能根據 JVM 的特性有針對性的進行調節和優化,最終寫出執行效率高的代碼。
到底誰是垃圾?
什么樣的對象可以稱為垃圾對象?換句話說:在垃圾收集器工作的時候,哪些對象是可以被回收的,哪些對象是不可以被回收的?判斷的標準是什么?系統中的對象千千萬,怎么才能準確無誤的找出來并“殺”掉就顯得尤為重要。
為了解決上面的問題。JVM 專門設計一套判斷對象是的是垃圾的算法——可達性分析。
可達性分析的原理是:根據每一個對象,一層一層的引用往上找,說白了就是看看那些地方在引用著這個對象。直到找到能被稱之為GC Roots的對象在引用這個這個對象,那么這個時候 JVM 就認為這個對象是不是垃圾對象。
也就是在垃圾回收的時候是不會去回收這部分對象的。反之,這樣的對象就可以被稱為垃圾對象。也就意味著是會被在垃圾收集器工作的時候就會回收這部分對象。
GC Roots
說到這里,哪些是垃圾對象我們是可以判斷了。那么剛剛提到的 GC Roots 又是什么鬼?簡單的來講,靜態變量、局部變量、常量、本地方法棧中的對象都可以當做GC Roots。但是一般最常見的就是:靜態變量、局部變量。
我們姑且先這個記住,也就是凡是被這些對象引用的對象,就是不能被回收的。換言之,系統是在某些地方還在使用這些對象,這些對象我們也稱之為強引用。對應的還有軟引用,弱引用和虛引用。
強引用(使用頻率:☆☆☆☆☆)
????我們平時開發時候通過 new 關鍵創建出來的對象就是強引用,這類對象在垃圾回收的時候只要是能找到 G CRoots,那么他們是不會被回收的。
軟引用(使用頻率:☆☆☆☆)
????所謂軟引用,就是表示該對象在垃圾回收期間,不軟是否被其他對象引用,只要是內存空間不夠了,那么該對象就會別垃圾收集器回收。(PS:這個也是大家很容易和弱引用搞混淆的一個術語。我相信你平時開發常用的一定是 SoftReference ,而很少使用 WeakReference 。也就是說,強引用下面的一個就是軟引用。希望能幫助大家理解這兩個之間的區別。)
弱引用(使用頻率:☆)
????這類引用存在的價值更容易被忽視,只要是在垃圾回收階段,不管內存是否足夠,該類型的對象都會被垃圾收集器回收。
虛引用(使用頻率:程序員基本不會使用到)
????虛引用”顧名思義,就是形同虛設,與其他幾種引用都不同,虛引用并不會決定對象的生命周期。如果一個對象僅持有虛引用,那么它就和沒有任何引用一樣,在任何時候都可能被垃圾回收。虛引用主要用來跟蹤對象被垃圾回收的活動。虛引用與軟引用和弱引用的一個區別在于:虛引用必須和引用隊列(ReferenceQueue)聯合使用
JVM 內存結構
到此為止,我們已經知道了哪些對象是垃圾已經如何判斷垃圾對象了。接下來就是要回收了。但是在學習回收之前,我們還需要知道JVM內存區域的劃分。換句話說就是回收對象是在哪里進行的?我們先來看下 JVM 的內存結構(這種模型僅僅是人們為了更好的學習和理解 JVM 還虛擬出來的)
以上結構看起來并不復雜,主要由五大部分組成:方法區、堆內存、虛擬機棧(棧)、本地方法棧(一般不關注)、程序計數器。其中方法區和堆內存是線程共享的。其他三個是線程私有的。他們的主要作用如下:
方法區
被所有線程共享的區間,用來保存類信息、常量、靜態變量、也就是被虛擬機編譯后的代碼。換句話說:靜態變量、常量、類信息(版本信息、方法簽名、屬性等)和運行時常量池存在方法區中。其中常量池是方法區的一部分。
堆內存(垃圾回收的重中之重)
是 Java 虛擬機所管理的所有的內存區域中最大的一塊內存區域。堆內存被所有線程的共享。
主要存放使用 new 關鍵字創建的對象。所有對象實例以及數組都要在堆上分配。
垃圾收集器就是根據GC算法,收集堆上對象所占用的內存空間(這也是垃圾回收的重點核心區域)。
虛擬機棧
虛擬棧,是由一個一個的棧幀(棧幀:理解為方法的標記即可),他是線程私有的,生命周期同線程一樣。
每個方法在執行的同時都會創建一個棧幀(Stack Frame)用于存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。
局部變量表存放了編譯期可知的各種基本數據類型(8個基本數據類型)、對象引用(地址指針)、returnAddress類型。局部變量表所需的內存空間在編譯期間完成分配。在運行期間不會改變局部變量表的大小。
這個區域規定了兩種異常狀態:如果線程請求的棧深度大于虛擬機所允許的深度,則拋出StackOverflowError異常;如果虛擬機棧可以動態擴展,在擴展時無法申請到足夠的內存,就會拋出OutOfMemoryError異常。
程序計數器
程序計數器是線程私有的,里面記錄的就是即將要執行的一條的CPU指令,因為在多線程環境中,必然會存在線程之之間的切換,這樣JVM就需要有一套方案來記錄某個線程在之前執行到了哪里。這就是程序計數器的作用
本地方法棧
記錄的就是本地的通過C/C++ 寫的一些程序(PS:這個空間中沒有規定 OOM,也就是不會發生OOM的情況。因為程序計數器存儲的是字節碼文件的行號,而這個范圍是可知曉的,在一開始分配內存時就可以分配一個絕對不會溢出的內存)
堆內存的詳細結構
上面是說到了,堆內存是垃圾回收的重中之重,但是這并不意味這對象就是很籠統的在堆內存中的,他們也會被安排和分配到堆的不同的區域中。而堆內存主要是這么劃分的,堆首先被劃分成兩大部分:年輕代(新生代)和老年代。年輕代又劃分為:Eden、From Survivor、To Survivor。
其中年輕代和老年代所占的內存口空間比例為:1:2。年輕代中的Eden、From Survivor、To Survivor 占比為:8:1:1。畫個圖來幫助大家更形象的理解下:
大家不要急,一步一步來。
垃圾回收算法
從本小節開始,就是本文的重點了。JVM 在垃圾回收的時候:
① 到底使用了哪些垃圾回收算法?
② 分別在什么場景下使用?
③ 各自的優缺點?
下面就來正式的介紹下垃圾回收算法
標記-清除
標記清除是最簡單和干脆的一種垃圾回收算法,他的執行流程是這樣子的:當 JVM 標記出內存中的垃圾以后,直接將其清除,但是這樣有一個很明顯的缺點,就是會導致內存空間的不連續,也就是會產生很多的內存碎片。先畫個圖來看下
我們使用上圖左邊的圖來表示垃圾回收之前的樣子,黑色的區域表示可以被回收的垃圾對象。這些對象在內存空間中不是連續的。右側這張圖表示是垃圾回收過后的內存的樣子。可以很明顯的看到里面纏身了斷斷續續的 內存碎片。
那說半天垃圾不是已經被回收了嗎?內存碎片就內存碎片唄。又能咋地?
好,我來這么告訴你,現在假設這些內存碎片所占用的口空間之和是1 M,現在新創建了一個對象大小就是 1 M,但是很遺憾的是,此時內存空間雖然加起來有 1 M,但是并不是連續的,所以也就無法存放這大對象。也就是說這樣勢必會造成內存空間的浪費,這就是內存碎片的危害。
這么一說標記-清除就沒有優點了嗎?優點還是有的:速度快
到此,我們來對標記-清除來做一個簡單的優缺點小結:
優點
????速度快,因為不需要移動和復制對象
缺點
????會產生內存碎片,造成內存的浪費
標記-復制
上面的清除算法真的太差勁了。都不管后來人能不能存放的下,就直接啥也不管的去清除對象。所以升級后就來了復制算法。
復制算法的工作原理是這樣子的:首先將內存劃分成兩個區域。新創建的對象都放在其中一塊內存上面,當快滿的時候,就將標記出來的存活的對象復制到另一塊內存區域中(注意:這些對象在在復制的時候其內存空間上是嚴格排序且連續的),這樣就騰出來一那一半就又變成了空閑空間了。依次循環運行。
在回收前將存活的對象復制到另一邊去。然后再回收垃圾對象,回收完就類似下面的樣子:
如果再來新對象被創建就會放在右邊那塊內存中,當內存滿了,繼續將存活對象復制到左邊,然后清除掉垃圾對象。
標記-復制算法的明顯的缺點就是:浪費了一半的內存,但是優點是不會產生內存碎片。所以我們在做技術的時候經常會走向一個矛盾點地方,那就是:一個新的技術的引入,必然會帶來新的問題。
到這里我們來簡單小結下標記-復制算法的優缺點:
優點
????內存空間是連續的,不會產生內存碎片
缺點
????1、浪費了一半的內存空間
????2、復制對象會造成性能和時間上的消耗
說到底,似乎這兩種垃圾回收回收算法都不是很好。而且在解決了原有的問題之后,所帶來的新的問題也是無法接受的。所以又有了下面的垃圾回收算法。
標記-整理
標記-整理算法是結合了上面兩者的特點進行演化而來的。具體的原理和執行流程是這樣子的:我們將其分為三個階段:
第一階段為標記;
第二階段為整理;
標記:它的第一個階段與標記-清除算法是一模一樣的,均是遍歷 GC Roots,然后將存活的對象標記。
整理:移動所有存活的對象,且按照內存地址次序依次排列,然后將末端內存地址以后的內存全部回收。因此,第二階段才稱為整理階段。
我們是畫圖說話,下面這張圖是垃圾回收前的樣子。
下圖圖表示的第一階段:標記出存活對象和垃圾對象;并清除垃圾對象
白色空間表示被清理后的垃圾。
下面就開始進行整理:
可以看到,現在即沒有內存碎片,也沒有浪費內存空間。
但是這就完美了嗎?他在標記和整理的時候會消耗大量的時間(微觀上)。但是在大廠那種高并發的場景下,這似乎有點差強人意。
到此,我們將標記-整理的優缺點整理如下:
優點
????1、不會產生內存碎片
????2、不會浪費內存空間
缺點
????太耗時間(性能低)
到此為止,我們已經了知道了標記-清除、標記-復制、標記-整理三大垃圾回收算法的優缺點。
單純的從時間長短上面來看:標記-清除 < 標記-復制 < 標記-整理。
單純從結果來看:標記-整理 > 標記-復制 >= 標記-清除
知道了垃圾回收算法,還有以下這些問題等著我們去分析:
① 垃圾收集器都有哪些呢?
② 年輕代和老年代又分別是哪些垃圾收集算法?
③ 不同的垃圾收集器對應哪些垃圾回收算法?
④ 年輕代和老年代分別使用哪些垃圾收集器?
帶著這些問題,讓我們繼續往下看。
什么樣的垃圾會進入到老年代
我們現在已經知道了什么是垃圾,那現在問題是:什么樣的垃圾會進入到老年代?對象進入老年代的條件有三個,滿足一個就會進入到老年代:
1、躲過15次GC。每次垃圾回收后,存活的對象的年齡就會加1,累計加到15次(jdk8默認的),也就是某個對象躲過了15次垃圾回收,那么JVM就認為這個是經常被使用的對象,就沒必要再帶著年輕代中了。具體的次數可以通過 -XX:MaxTenuringThreshold 來設置在躲過多少次垃圾收集后進去老年代。
2、動態對象年齡判斷。規則:在某個 Survivor 中,如果有一批對象的大小總是大于該 Survivor 的 50%,那么此時大于等于該批對象年齡的對象機會會直接到老年代中。
3、大對象直接進入老年代。-XX:PretenureSizeThreshold 來設置大對象的臨界值,大于該值的就被認為是大對象,就會直接進入老年代。
針對上面的三點來逐一分析。
躲過15次 GC
這個沒啥好說的,最好理解,就是在執行了15次GC后,對象依舊存活,那么就將其移動到老年代中去,沒執行一次垃圾回收,存活的對象的年齡就+1,具體的執行次數可以通過:-XX:PretenureSizeThreshold參數來設置。
動態對象年齡判斷
這就有點難理解了,不過一定會給你講清楚的
再來看下這個規則:在某個 Survivor 中,如果有一批對象的大小總是大于該 Survivor 的 50%,那么此時大于等于該批對象年齡的對象機會會直接到老年代中。
o(╥﹏╥)o 還是沒理解。。。我們畫圖來理解試試
假設現在 To 里面的如圖兩個對象大小總和50 M,且都是3歲了,因為 To 是100 M,所以這個時候我們就說在某個 Survivor 中,如果有一批對象的大小總是大于該Survivor 的 50%。這個時候大于等于該批對象年齡的對象機會會直接到老年代中。
再換換句話說就是:當前放對象的Survivor區域里(其中一塊區域,放對象的那塊s區),一批對象的總大小大于這塊Survivor區域內存大小的50%(-XX:TargetSurvivor 修可以指定),那么此時大于等于這批對象年齡最大值的對象,就可以直接進入老年代了。
例如Survivor區域里現在有一比對象,年齡1+年齡2+年齡n的多個年齡對象總和超過了的多個年齡對象總和超過了區域的50%,此時就會把年齡n(含)以上的對象都放入老年代)。這個規則其實是希望那些可能是長期存活的對象,盡早進入老年代。對象動態年齡判斷機制一般是在 Minor GC 之后觸發的。
大對象直接進入老年代
這個就簡單了,-XX:PretenureSizeThreshold 來設置大對象的臨界值。如 -XX:PretenureSizeThreshold=1024 * 1024。即對象超過1M直接進入老年代。其實大對象直接進入到老年代還包含這種情況:那就是當 Eden 中執行了 Minor GC 后,存活的對象的大小是 超過了100M了(上圖 from 和 to 都是100M)此時這些存活的對象也是直接進入到老年代。
說了半天對象都跑到老年代去了,那既然老年代這個牛逼,干嘛還分年輕代和老年代?年輕人,你不要急。后文我會全部道來。我們下面先來看看老年代空間如果不夠用怎么辦?
老年代空間分配擔保
上面說到了,對象在哪些情況下會進入到老年代,年輕代倒是省心了,你不夠了就放到老年代,那如果老年代也不夠了呢?那又是如何處理呢?
實際上是這樣的。在年輕代執行 Minor GC 之前,首先會檢查老年代的可用空間的大小是否是大于新生代所有對象的大小。為什么是所有對象,不應該是存活的對象嗎?
你想啊,假如年輕代經過一次 Minor GC 后所有的對象都是存活的,這是不是就尷尬了(PS:所以這還是我們需要考慮的“臨界情況”。不要覺得一般情況或者是泛泛的說法,程序的嚴謹性就是在臨界情況下體現出來的)
現在假設在 Minor GC之前,檢查發現老年代空間還真不夠了,那么首先會去檢查-XX:HandlerPromotionFailure的參數是否設置了,這個參數表示:是否設置空間分配擔保。
是:就會判斷老年代的剩余的空間的大小是否是大于之前的每一次 MinorGC 后進入老年代的對象的平均的大小
否:那么此時就會進行FULL GC來為老年代騰出一些空間
假設現在開啟了空間分配擔保,并且發現之前的每次 Minor GC 后的對象的平均大小(假設是10 M)是小于老年代可用空間的大小(假設現在是12 M)的,那么就會認為本次 Minor GC 后差不多也是10 M的對象進入到老年代。但是如果最終垃圾回收剩余存活對象大于13 M,那么就直接 OOM;
如果沒有開啟空間分配擔保機制,那么就會觸發一次 Full GC(老年代的垃圾回收稱之為 Full GC),這樣看看能不能在老年代中在騰出一些空間,因為一般老年代中的對象是長時間存活的,所以這招可能作用不是很大。
假設Full GC 結束了,再嘗試進行 Minor GC ,此時又可能有好幾種情況:
第一種情況:Minor GC 后,剩余的存活對象的大小是小于 from 區大小的,那么對象直接進入 from 區即可;
第二種情況:Minor GC 后,剩余的存活對象的大小是大于 from 區大小的,但是是小于老年區可用空間大小的,那么對象直接進入老年代;
第三種情況:Minor GC 后,剩余的存活的對象的大小是大于 from 區大小的,同時也大于老年區可用的空間的大小,這個時候就會根據XX:HandlerPromotionFailure的設置來觸發一次 Full GC,如果此時 Full GC后老年代的空間還是不夠存放 Minor GC 后剩下的對象。那么就 OOM。
上面說了這么多我們來畫個圖整理和理解下,以年輕代快滿了為出發點(Minor GC前):
年輕代的垃圾回收算法
我們先來回頭看下這張圖(為了方便閱讀,我直接復制下來)
對象在剛創建的時候(排除直接進入到老年代的情況)。我們認為都是被分配到年輕代的 Eden 中的,當 Eden 快滿的時候,就會觸發一次垃圾回收,稱之為:Minor GC(一般情況下 Eden 中經過一次垃圾回收后存活的對象非常少,這就好像是一次請求創建了很多的臨時變量和對象,請求結束這些基本就全是垃圾了,這就是為什么 from 和 to 比例這么小的原因)
將存活的對象移動到 from 區域,此時存活的對象的年齡就 +1 ,并且將 from 和 to 的指向交換位置。首先來看下剛剛回收完垃圾將對象轉移到 from 的圖
然后我們強調了一個詞,將 from 和 to 的指向交換位置:
這樣子其實就是下面的樣子:
(PS:真正的內存空間的位置并沒有變化,實際變化的是from 和 to 的指向,這樣下次執行 Minor GC 的時候還是將存回的對象放在 from 區域,你懂了沒?)
然后 Eden 區域繼續存放新對象,當 Eden 再次快滿的時候,又會技術出發 Yong GC(Minor GC 的另一個名字,為了讓大家了解的更全面,故意都使用下),此時垃圾回收的是 Eden 和 to 區域中的垃圾,因為上一次存活了的對象到這一次不一定就存活了。然后將他們存活的對象在移動到 from 區域。然后交換 from 和 to 的位置指向。以此循環往復。
垃圾收集器
關于垃圾收集器其實現在更多關注的是 G1垃圾收集器,但是本文不會去介紹,這個會放在單獨的一篇文章去介紹的。目前常見的垃圾收集器有:
①Serial 垃圾收集器
②Serial Old 垃圾收集器
③ParNew 垃圾收集器
④CMS 垃圾收集器
⑤Parallel Scavenge 垃圾收集器
⑥Parallel Old 垃圾收集器
⑦G1 垃圾收集器
他們具體工作在年輕代還是老年代我們來通過一張圖說明:
箭頭表示年輕代是 xxx 老年代可以是 xxx,表示一種對應關系。
通過java -XX:+PrintCommandLineFlags -version命令可以查看當前 JVM 使用的垃圾收集器
新生代的垃圾收集器
Serial(Serial/Serial Copying)
最古老,最穩定,簡單高效,GC時候需要暫停用戶線程,限定單核CPU環境,是Client模式下的默認新生代收集器(基本不再使用)。
對應的 JVM 參數為 -XX:UseSerialGC;開啟后,會使用Serial(Young區使用)+Serial Old(Old區使用)組合收集器。新生代、老年代都會使用串行回收收集器,新生代使用【標記-復制算法】老年代使用【標記-整理算法】
ParNew(Parallel New Generation)
新生代是并行老年代是串行。
ParNew其實就是【Serial收集器新生代】的【并行多線程】版本。它是很多java虛擬機在運行Server模式下的新生代的默認的垃圾收集器。
最常見的場景的是配合老年代的CMS GC工作,其余的行為和Serial收集器一樣 ParNew工作的時候同樣需要暫停其他的所有的線程 對應的JVM參數為 -XX:UserParNewGC 啟用ParNew收集器,只作用于新生代,不影響老年代 開啟后,會使用ParNew(Young區使用)+Serial Old的收集器組合,新生代使用【復制算法】老年代 使用【標記-整理算法】
并行回收GC(Parallel/ParallelScavenge)(默認收集器)
新生代和老年代都是并行。
Parallel Scavenge 收集器類似ParNew也是一個新生代的垃圾收集器,使用的【復制算法】,是一個并行多線程的垃圾收集器。俗稱吞吐量優先的收集器。一句話:串行收集器在新生代和老年的并行化。
可控的吞吐量(運行用戶的的代碼時間/(運行用戶的代碼時間+垃圾收集時間))。也即運行100分 鐘,垃圾收集時間為1分鐘,那么吞吐量就是99%。高吞吐量意味著高效的CPU利用率
自適應調節策略也是Parallel Scavenge 和 ParNew 的一個重要的區別(虛擬機會根據當前的 系統的運行情況手機性能監控信息,動態的調整這些參數以提供最合適的停頓時間(- XX:MaxGCPauseMillis)或最大的吞吐量)
如果新生區激活-XX:+UseParallelGC(或者是-XX:UseParallelOldGC他們可以互相激活)老 年區就自動使用Parallel Old,使用Parallel Scavenge收集器 - -XX:ParallelGCThreads=N 表示啟動多少個線程 cpu>8 N=5/8 cpu<8 N=實際個數
老年代的垃圾收集器
串行GC(Serial Old/Serial MSC)
運行在Client模式(基本不再使用)
并行GC(Parallel Old/Parallel MSC)
是Parallel Scavenage的老年大版本,采用【標記-整理】算法;
并發標記清除GC(CMS)
是一種以獲取最短回收停頓時間為目標的收集器。適用于重視服務器的相應速度,希望系統的停頓時間最短。
GC線程和用戶線程一起執行(Serial Old將作為CMS出錯的后備收集器),主要有4步過程(重要) - 初始標記 - 并發標記(和用戶線程一起工作) - 重新標記 - 并發清除(和用戶線程一起工作)
優點他的優點是并發收集低停頓、缺點是并發執行,對CPU壓力大、采用的【標記-清除】算法會導致大量的碎片
目前最常見的組合是:ParNew + CMS 垃圾收集器。鑒于篇幅限制和為了大家更好的消化本文內容。更多關于 ParNew 和 CMS 的工作原理將放在下一篇文章分析。
本文小結
本文從【垃圾】到【垃圾回收算法】再到【垃圾收集器】,詳細的介紹了 JVM 中最最基本的一些概念和原理。本文重點關注點是原理和流程,所以內容稍多,另外 JVM 的博大精深并非一朝一夕更不可能是一篇文章就能學會的。
但是本文希望能給大家在 JVM 在學習之路上添磚加瓦。
?
技術交流群
最近有很多人問,有沒有讀者交流群,想知道怎么加入。
最近我創建了一些群,大家可以加入。交流群都是免費的,只需要大家加入之后不要隨便發廣告,多多交流技術就好了。
目前創建了多個交流群,全國交流群、北上廣杭深等各地區交流群、面試交流群、資源共享群等。
有興趣入群的同學,可長按掃描下方二維碼,一定要備注:全國 Or 城市 Or 面試 Or 資源,根據格式備注,可更快被通過且邀請進群。
▲長按掃描
往期推薦字節跳動將取消大小周,加班要打申請!此前1/3員工不同意,有人擔心“一年少賺近10萬”
谷歌:. apk 成為歷史!
在線求CR,你覺得我這段Java代碼還有優化的空間嗎?
如果你喜歡本文,
請長按二維碼,關注?Hollis.
轉發至朋友圈,是對我最大的支持。
點個?在看?
喜歡是一種感覺
在看是一種支持
↘↘↘
總結
- 上一篇: ant design pro总是跨域,p
- 下一篇: WPF中的Bitmap与byte