淺談JVM與垃圾回收

detectiveHLH發表於2020-07-03

寫在前面

簡單的介紹一下JVM(Java Virtual Machine)吧,它也叫Java虛擬機器。雖然它叫虛擬機器,但是實際上不是我們所理解的虛擬機器,它更像作業系統中的一個程式。JVM遮蔽了各個作業系統底層的相關的東西,Java程式只需要生成對應的位元組碼檔案,然後由JVM來負責解釋執行。

介紹幾個容易混淆的概念,JDK(Java Development Kit) 可以算是整個Java的核心,其中有編譯、除錯的工具包和基礎類庫,它也包含了JRE

JRE(Java Runtime Environment),包含了JVM和基礎類庫。而JVM就是我們今天要聊的主角,開篇聊到,JVM負責解釋執行,它會將自己的指令對映到當前不同裝置的CPU指令集上,所以只需要在不同的作業系統上裝不同版本的虛擬機器即可。這也給了Java跨平臺的能力。

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)編譯器和逃逸分析技術的成熟,所有物件都在堆上分配記憶體就變得沒有那麼絕對了。

JIT編譯器

不知道你有沒有聽說過,二八定律在我們的程式中也同樣適用,那就是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的方法。

GC

其實堆中的區域還可以劃分為新生代和老年代,再分割的細一點,可以到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的過程與標記-清楚是一樣的,只不過會讓所有的存活物件往同一邊移動,這樣一來就不會像標記-整理那樣留下大量的記憶體碎片。

249993-20170308200502734-920263398

分代收集

這也是當前主流虛擬機器所採用的演算法,其實就是針對不同的記憶體區域的特性,使用上面提到過的不同的演算法。

例如新生代的特性是大部分的物件都是需要被回收掉的,只有少量物件會存活下來。所以新生代一般都是採用複製演算法

而老年代屬於物件存活率都很高的記憶體空間,則採用標記-清除標記-整理演算法來進行垃圾回收。

垃圾收集器

新生代收集器

聊完了垃圾回收的演算法,我們需要再瞭解一下GC具體是通過什麼落地的, 也就是上面的演算法的實際應用。

Serial

Serial採用的是複製演算法的垃圾收集器,而且是單執行緒運作的。也就是說,當Serial進行垃圾收集時,必須要暫停其他所有執行緒的工作,直到垃圾收集完成,這個動作叫STW(Stop The World) 。Golang中的GC也會存在STW,在其標記階段的準備過程中會暫停掉所有正在執行的Goroutine。

而且這個暫停動作對使用者來說是不可見的,使用者可能只會知道某個請求執行了很久,沒有經驗的話是很難跟GC掛上鉤的。

但是從某些方面來看,如果你的系統就只有單核,那麼Serial就不會存線上程之間的互動的開銷,可以提高GC的效率。這也是為什麼Serial仍然是Client模式下的預設新生代收集器。

249993-20170308204330750-898195038

ParNew

ParNew與Serial只有一個區別,那就是ParNew是多執行緒的,而Serial是單執行緒的。除此之外,其使用的垃圾收集演算法和收集行為完全一樣。

該收集器如果在單核的環境下,其效能可能會比Serial更差一些,因為單核無法發揮多執行緒的優勢。在多核環境下,其預設的執行緒與CPU數量相同。

249993-20170308210151797-1882924644

Parallel Scavenge

Parallel Scavenge是一個多執行緒的收集器,也是在server模式下的預設垃圾收集器。上面的兩種收集器關注的重點是如何減少STW的時間,而Parallel Scavenge則更加關注於系統的吞吐量

例如JVM已經執行了100分鐘,而GC了1分鐘,那麼此時系統的吞吐量(100 - 1)/100 = 99%

吞吐量短停頓時間其側重的點不一樣,需要根據自己的實際情況來判斷。

高吞吐量

GC的總時間越短,系統的吞吐量則越高。換句話說,高吞吐量則意味著,STW的時間可能會比正常的時間多一點,也就更加適合那種不存在太多互動的後臺的系統,因為對實時性的要求不是很高,就可以高效率的完成任務。

短停頓時間

STW的時間短,則說明對系統的響應速度要求很高,因為要跟使用者頻繁的互動。因為低響應時間會帶來較高的使用者體驗。

老年代收集器

Serial Old

Serial Old是Serial的老年代版本,使用的標記-整理演算法, 其實從這看出來,新生代和老年代收集器的一個差別。

新生代:大部分的資源都是需要被回收

老年代:大部分的資源都不需要被回收

所以,新生代收集器基本都是用的複製演算法,老年代收集器基本都是用的標記-整理演算法。

Serial Old也是給Client模式下JVM使用的。

Parallel Old

Parallel Old是Parallel Scavenge的老年代版本,也是一個多執行緒的、採用標記-整理演算法的收集器,剛剛討論過了系統吞吐量,那麼在對CPU的資源十分敏感的情況下, 可以考慮Parallel Scavenge和Parallel Old這個新生代-老年代的垃圾收集器組合。

249993-20170309210552797-797186750

CMS

CMS全稱(Concurrent Mark Sweep),使用的是標記-清除的收集演算法。重點關注於最低的STW時間的收集器,如果你的應用非常注重與響應時間,那麼就可以考慮使用CMS。

249993-20170312201047482-791570909

從圖中可以看出其核心的步驟:

  • 首先會進行初始標記,標記從GCRoots出發能夠關聯到的所有物件,此時需要STW,但是不需要很多時間
  • 然後會進行併發標記,多執行緒對所有物件通過GC Roots Tracing進行可達性分析,這個過程較為耗時
  • 完成之後會重新標記,由於在併發標記的過程中,程式還在正常執行,此時有些物件的狀態可能已經發生了變化,所以需要STW,來進行重新標記,所用的時間大小關係為初始標記 < 重新標記 < 併發標記
  • 標記階段完成之後,開始執行併發清楚

CMS是一個優點很明顯的的垃圾收集器,例如可以多執行緒的進行GC,且擁有較低的STW的時間。但是同樣的,CMS也有很多缺點。

缺點

我們開篇也提到過,使用標記-清除演算法會造成不連續的記憶體空間,也就是記憶體碎片。如果此時需要給較大的物件分配空間,會發現記憶體不足,重新觸發一次Full GC。

其次,由於CMS可能會比注重吞吐量的收集器佔用更多的CPU資源,但是如果應用程式本身就已經對CPU資源很敏感了,就會導致GC時的可用CPU資源變少,GC的整個時間就會變長,那麼就會導致系統的吞吐量降低。

G1

G1全稱Garbage First,業界目前對其評價很高,JDK9中甚至提議將其設定為預設的垃圾收集器。我們前面講過,Parallel Scavenge更加關注於吞吐量,而CMS更加關注於更短的STW時間,那麼G1就是在實現高吞吐的同時,儘可能的減少STW的時間。

我們知道,上面聊過的垃圾收集器都會把連續的堆記憶體空間分為新生代、老年代,新生代則被劃分的更加的細,有Eden和兩個較小的Survivor空間,而且都是連續的記憶體空間。而G1則與眾不同,它引入了新的概念,叫Region

Region是一堆大小相等但是不連續的記憶體空間,同樣是採用了分代的思想,但是不存在其他的收集器的物理隔離,屬於新生代和老年代的region分佈在堆的各個地方。

8ca16868

上面H則代表大物件,也叫Humongous Object。為了防止大物件的頻繁拷貝,會直接的將其放入老年代。G1相比於其他的垃圾收集器有什麼特點呢?

從巨集觀上來看,其採用的是標記-整理演算法, 而從region到region來看,其採用的是複製演算法的,所以G1在執行期間不會像CMS一樣產生記憶體碎片。

除此之外,G1還可以通過多個CPU,來縮短STW的時間,與使用者執行緒併發的執行。並且可以建立可預測的停頓時間模型,讓使用者知道在某個時間片內,消耗在GC上的時間不得超過多少毫秒。之所以G1能夠做到這點,是因為沒像其餘的收集器一樣收集整個新生代和老年代,而是在有計劃的避免對整個堆進行全區域的垃圾收集。

總結

這個圖來自於參考中的部落格,總結的很到位。

image.png

參考

相關文章