您好,登錄后才能下訂單哦!
這篇文章主要講解了“如何理解JVM和垃圾回收”,文中的講解內容簡單清晰,易于學習與理解,下面請大家跟著小編的思路慢慢深入,一起來研究和學習“如何理解JVM和垃圾回收”吧!
就跟我們用三方庫一樣,同樣的功能有不同的實現。JVM也是一樣的,第一款JVM是Sun公司的Classic VM,JDK1.2之前JVM都是采用的Classic VM,而之后,逐漸被我們都知道的HotSpot給替代,直到JDK1.4,Classic VM才完全被棄用。
HotSpot應該是目前使用最廣泛的虛擬機(自信點,把應該去掉),也是OpenJDK中所帶的虛擬機。但是你可能不知道,HotSpot最開始并不是由Sun公司開發,而是由一家小公司設計并實現的,而且最初也不是為Java語言設計的。Sun公司看到了這個虛擬機在JIT上的優勢,于是就收購了這家公司,從而獲得了HotSpot VM。
可能你經歷過被靈魂拷問是什么滋味,如果線上發生了OOM(Out Of Memory),該怎么排查?如果要你來對一個JVM的運行參數進行調優,你該怎么做?
不像C++可以自己來主宰內存,同時扮演上帝和最底層勞工的角色,Java里我們把內存管理交給了JVM,如果我們不能了解其中具體的運行時內存分布以及垃圾回收的原理,那等到問題真正出現了,很可能就無從查起。這也是要深入的了解JVM的必要性。
Java在運行時會將內存分成如下幾個區域進行管理,堆、方法區、虛擬機棧、本地方法棧和程序計數器。
堆(Java Heap)是JVM所管理的內存中最大的一塊了。我們平常開發中使用new
關鍵字來進行實例化的對象幾乎都會在堆中分配內存,所有線程都可以共享被分配在堆上的對象。
堆也是JVM垃圾回收的主要區域,正因為垃圾回收的分代機制,其實堆中還可以分為更細的新生代、老年代。GC這塊后面會細講。
那為什么是幾乎呢?在JVM本身的規范中是規定了所有的對象都會在堆上分配內存的,但是隨著JIT(Just In Time)編譯器和逃逸分析技術的成熟,所有對象都在堆上分配內存就變得沒有那么絕對了。
不知道你有沒有聽說過,二八定律在我們的程序中也同樣適用,那就是20%的代碼占用了系統運行中80%的資源。在我們寫的代碼中,就可能會存在一些熱點代碼,頻繁的被調用。除了被頻繁的調用的代碼,還有被執行多次的循環體也算熱點代碼。
那此時JIT編譯器就會對這部分的代碼進行優化,將它們編譯成Machine Code,并做一些對應的優化。不熟悉的同學可能會說,我們的代碼不都已經被編譯成了字節碼了嗎?怎么又被編譯成了Machine Code?
因為字節碼只是一個中間狀態,真正的運行是JVM在運行的時候,就跟解釋型語言一樣將字節碼逐條的翻譯成了Machine Code,這個Machine Code才是操作系統能夠識別直接運行的指令。而JIT就會把編譯好的熱點代碼所對應的Machine Code保存下來, 下載再調用時就省去了從字節碼編譯到Machine Code的過程,效率自然也就提高了。
我們剛剛提到過,Java中幾乎所有的對象都在堆上分配空間,堆中的內存空間是所有線程共享的,所以在多線程下才需要去考慮同步的相關問題。那如果這個變量是個局部變量,只會在某個函數中被訪問到呢?
這種局部變量就是未逃逸的變量,而這個變量如果在其他的地方也能被訪問到呢?這說明這個變量逃逸出了當前的作用域。通過逃逸分析我們可以知道哪些變量沒有逃逸出當前作用域,那這個對象內存就可以在棧中分配,隨著調用的結束,隨著線程的繼續執行完成,棧空間被回收,這個局部變量分配的內存也會一起被回收。
方法區存放了被加載的Class信息、常量、靜態變量和JIT編譯之后的結果等數據,與堆一樣,方法區也是被所有線程共享的內存區域。但與堆不同,相對于堆的GC力度,這塊的垃圾回收力度可以說是小了非常多,但是仍然有針對常量的GC。
虛擬機棧是線程私有的,所以在多線程下不需要做同步的操作,是線程安全的。當每個方法執行時,就會在當前線程中虛擬機棧中創建一個棧幀,每個方法從調用到結束的過程,就對應了棧幀在虛擬機棧中的入棧、出棧的過程。那自然而然,棧幀中應該存放的就是方法的局部變量、操作數棧、動態鏈接和對應的返回信息。
不知道你遇到過在方法內寫遞歸時,由于退出條件一直沒有達到,導致程序陷入了無限循環,然后就會看到程序拋出了一個StackOverflow
的錯誤。其所對應的棧就是上面提到的操作數棧。
當然這是在內存足夠的情況下,如果內存不夠,則會直接拋出OutOfMemory
,也就是常說的OOM。
本地方法棧的功能與虛擬機棧類似,區別在于虛擬機棧是服務于JVM中的Java方法,而本地方法棧則服務于Native的方法。
其實堆中的區域還可以劃分為新生代和老年代,再分割的細一點,可以到Eden、From Survivor、To Survivor。首先分配的對象實例會到Eden區,在新生代這塊區域一般是最大的,與From Survivor的比例是8:1,當然這個比例可以通過JVM參數來改變。而且當分配的對象實體很大的時候將會直接進入到老年代。
為什么要對堆進行更加細致的內存區域劃分,其實是為了讓垃圾回收更加的高效。
那JVM是如何判斷哪些對象是“垃圾”需要被回收呢?我們就需要來了解一下JVM是如何來判斷哪些內存需要進行回收的。
實現的思路是,給每個對象添加一個引用計數器,每當有其他的對象引用了這個對象,就把引用計數器的值+1,如果一個對象的引用計數為0則說明沒有對象引用它。
乍一看是沒有問題的,那為什么Java并沒有采取這種呢?
想象一下這個場景,一個函數中定義了兩個對象O1和O2,然后O1引用了O2,O1又引用了O1,這樣一來,兩個對象的引用計數器都不為0,但是實際上這兩個對象再也不會被訪問到了。
所以我們需要另外一種方案來解決這個問題。
可達性分析可以理解為一棵樹的遍歷,根節點是一個對象,而其子節點是引用了當前對象的對象。從根節點開始做遍歷,如果發現從所有根節點出發的遍歷都已經完成了,但是仍然有對象沒有被訪問到,那么說明這些對象是不可用的,需要將內存回收掉。
這些根節點有個名字叫做GC Roots,哪些資源可以被當作GC Roots呢?
棧幀中的局部變量所引用的對象
方法區中類靜態屬性所引用的對象
方法區中常量所引用的對象
本地方法棧所引用的對象
我們剛剛聊過,在引用計數中,如果其引用計數器的值為0,則占用的內存會被回收掉。而在可達性分析中,如果沒有某個對象沒有任何引用,它也不一定會被回收掉。
聊完了JVM如何判斷一個對象是否需要回收,接下來我們再聊一下JVM是如何進行回收的。
顧名思義,其過程分為兩個階段,分別是標記和清除。首先標記出所有需要回收的對象,然后統一對標記的對象進行回收。這個算法的十分的局限,首先標記和清除的兩個過程效率都不高,而且這樣的清理方式會產生大量的內存碎片,什么意思呢?
就是雖然總體看起來還有足夠的剩余內存空間,但是他們都是以一塊很小的內存分散在各個地方。如果此時需要為一個大對象申請空間,即使總體上的內存空間足夠,但是JVM無法找到一塊這么大的連續內存空間,就會導致觸發一次GC。
其大致的思路是,將現有的內存空間分為兩半A和B,所有的新對象的內存都在A中分配,然后當A用完了之后,就開始對象存活判斷,將A中還存活的對象復制到B去,然后一次性將A中的內存空間回收掉。
這樣一來就不會出現使用標記-清除所造成的內存碎片的問題了。但是,它仍然有自己的不足。那就是以內存空間縮小了一半為代價,而在某些情況下,這種代價其實是很高的。
堆中新生代就是采用的復制算法。剛剛提到過,新生代被分為了Eden、From Survivor、To Survivor,由于幾乎所有的新對象都會在這里分配內存,所以Eden區比Survivor區要大很多。因此Eden區和Survivor區就不需要按照復制算法默認的1:1的來分配內存。
在HotSpot中Eden和Survivor的比例默認是8:1,也就意味著只有10%的空間會被浪費掉。
看到這你可能會發現一個問題。
既然你的Eden區要比Survivor區大這么多,要是一次GC之后的存活對象的大小大于Survivor區的總大小該怎么處理?
的確,在新生代GC時,最壞的情況就是Eden區的所有對象都是存活的,那這個JVM會怎么處理呢?這里需要引入一個概念叫做內存分配擔保。
當發生了上面這種情況,新生代需要老年代的內存空間來做擔保,把Survivor存放不下的對象直接存進老年代中。
標記-整理其GC的過程與標記-清楚是一樣的,只不過會讓所有的存活對象往同一邊移動,這樣一來就不會像標記-整理那樣留下大量的內存碎片。
這也是當前主流虛擬機所采用的算法,其實就是針對不同的內存區域的特性,使用上面提到過的不同的算法。
例如新生代的特性是大部分的對象都是需要被回收掉的,只有少量對象會存活下來。所以新生代一般都是采用復制算法。
而老年代屬于對象存活率都很高的內存空間,則采用標記-清除和標記-整理算法來進行垃圾回收。
聊完了垃圾回收的算法,我們需要再了解一下GC具體是通過什么落地的, 也就是上面的算法的實際應用。
Serial采用的是復制算法的垃圾收集器,而且是單線程運作的。也就是說,當Serial進行垃圾收集時,必須要暫停其他所有線程的工作,直到垃圾收集完成,這個動作叫STW(Stop The World) 。Golang中的GC也會存在STW,在其標記階段的準備過程中會暫停掉所有正在運行的Goroutine。
而且這個暫停動作對用戶來說是不可見的,用戶可能只會知道某個請求執行了很久,沒有經驗的話是很難跟GC掛上鉤的。
但是從某些方面來看,如果你的系統就只有單核,那么Serial就不會存在線程之間的交互的開銷,可以提高GC的效率。這也是為什么Serial仍然是Client模式下的默認新生代收集器。
ParNew與Serial只有一個區別,那就是ParNew是多線程的,而Serial是單線程的。除此之外,其使用的垃圾收集算法和收集行為完全一樣。
該收集器如果在單核的環境下,其性能可能會比Serial更差一些,因為單核無法發揮多線程的優勢。在多核環境下,其默認的線程與CPU數量相同。
Parallel Scavenge是一個多線程的收集器,也是在server模式下的默認垃圾收集器。上面的兩種收集器關注的重點是如何減少STW的時間,而Parallel Scavenge則更加關注于系統的吞吐量。
例如JVM已經運行了100分鐘,而GC了1分鐘,那么此時系統的吞吐量為(100 - 1)/100 = 99%
。
吞吐量和短停頓時間其側重的點不一樣,需要根據自己的實際情況來判斷。
GC的總時間越短,系統的吞吐量則越高。換句話說,高吞吐量則意味著,STW的時間可能會比正常的時間多一點,也就更加適合那種不存在太多交互的后臺的系統,因為對實時性的要求不是很高,就可以高效率的完成任務。
STW的時間短,則說明對系統的響應速度要求很高,因為要跟用戶頻繁的交互。因為低響應時間會帶來較高的用戶體驗。
Serial Old是Serial的老年代版本,使用的標記-整理算法, 其實從這看出來,新生代和老年代收集器的一個差別。
新生代:大部分的資源都是需要被回收
老年代:大部分的資源都不需要被回收
所以,新生代收集器基本都是用的復制算法,老年代收集器基本都是用的標記-整理算法。
Serial Old也是給Client模式下JVM使用的。
Parallel Old是Parallel Scavenge的老年代版本,也是一個多線程的、采用標記-整理算法的收集器,剛剛討論過了系統吞吐量,那么在對CPU的資源十分敏感的情況下, 可以考慮Parallel Scavenge和Parallel Old這個新生代-老年代的垃圾收集器組合。
CMS全稱(Concurrent Mark Sweep),使用的是標記-清除的收集算法。重點關注于最低的STW時間的收集器,如果你的應用非常注重與響應時間,那么就可以考慮使用CMS。
從圖中可以看出其核心的步驟:
首先會進行初始標記,標記從GCRoots出發能夠關聯到的所有對象,此時需要STW,但是不需要很多時間
然后會進行并發標記,多線程對所有對象通過GC Roots Tracing進行可達性分析,這個過程較為耗時
完成之后會重新標記,由于在并發標記的過程中,程序還在正常運行,此時有些對象的狀態可能已經發生了變化,所以需要STW,來進行重新標記,所用的時間大小關系為
初始標記 < 重新標記 < 并發標記
。標記階段完成之后,開始執行并發清楚。
CMS是一個優點很明顯的的垃圾收集器,例如可以多線程的進行GC,且擁有較低的STW的時間。但是同樣的,CMS也有很多缺點。
我們開篇也提到過,使用標記-清除算法會造成不連續的內存空間,也就是內存碎片。如果此時需要給較大的對象分配空間,會發現內存不足,重新觸發一次Full GC。
其次,由于CMS可能會比注重吞吐量的收集器占用更多的CPU資源,但是如果應用程序本身就已經對CPU資源很敏感了,就會導致GC時的可用CPU資源變少,GC的整個時間就會變長,那么就會導致系統的吞吐量降低。
G1全稱Garbage First,業界目前對其評價很高,JDK9中甚至提議將其設置為默認的垃圾收集器。我們前面講過,Parallel Scavenge更加關注于吞吐量,而CMS更加關注于更短的STW時間,那么G1就是在實現高吞吐的同時,盡可能的減少STW的時間。
我們知道,上面聊過的垃圾收集器都會把連續的堆內存空間分為新生代、老年代,新生代則被劃分的更加的細,有Eden和兩個較小的Survivor空間,而且都是連續的內存空間。而G1則與眾不同,它引入了新的概念,叫Region。
Region是一堆大小相等但是不連續的內存空間,同樣是采用了分代的思想,但是不存在其他的收集器的物理隔離,屬于新生代和老年代的region分布在堆的各個地方。
上面H則代表大對象,也叫Humongous Object。為了防止大對象的頻繁拷貝,會直接的將其放入老年代。G1相比于其他的垃圾收集器有什么特點呢?
從宏觀上來看,其采用的是標記-整理算法, 而從region到region來看,其采用的是復制算法的,所以G1在運行期間不會像CMS一樣產生內存碎片。
除此之外,G1還可以通過多個CPU,來縮短STW的時間,與用戶線程并發的執行。并且可以建立可預測的停頓時間模型,讓使用者知道在某個時間片內,消耗在GC上的時間不得超過多少毫秒。之所以G1能夠做到這點,是因為沒像其余的收集器一樣收集整個新生代和老年代,而是在有計劃的避免對整個堆進行全區域的垃圾收集。
這個圖來自于參考中的博客,總結的很到位。
收集器 | 串行、并行or并發 | 新生代/老年代 | 算法 | 目標 | 適用場景 |
---|---|---|---|---|---|
Serial | 串行 | 新生代 | 復制算法 | 響應速度優先 | 單CPU環境下的Client模式 |
Serial Old | 串行 | 老年代 | 標記-整理 | 響應速度優先 | 單CPU環境下的Client模式、CMS的后備預案 |
ParNew | 并行 | 新生代 | 復制算法 | 響應速度優先 | 多CPU環境時在Server模式下與CMS配合 |
Parallel Scavenge | 并行 | 新生代 | 復制算法 | 吞吐量優先 | 在后臺運算而不需要太多交互的任務 |
Parallel Old | 并行 | 老年代 | 標記-整理 | 吞吐量優先 | 在后臺運算而不需要太多交互的任務 |
CMS | 并發 | 老年代 | 標記-清除 | 響應速度優先 | 集中在互聯網站或B/S系統服務端上的Java應用 |
G1 | 并發 | both | 標記-整理+復制算法 | 響應速度優先 | 面向服務端應用,將來替換CMS |
感謝各位的閱讀,以上就是“如何理解JVM和垃圾回收”的內容了,經過本文的學習后,相信大家對如何理解JVM和垃圾回收這一問題有了更深刻的體會,具體使用情況還需要大家實踐驗證。這里是億速云,小編將為大家推送更多相關知識點的文章,歡迎關注!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。