九神帶你入門JVM(上)

01

概述

九神帶你入門JVM(上)

本篇較長,九神帶你從0入門JVM,全文包括包括JVM的分類、JVM垃圾回收綜述、JVM的記憶體模型(Java 8)、物件存活判斷、GC演算法、常見的GC回收器、GC日誌一共七個部分。

下面是一張基於Java 8的JVM思維導圖,如果需要,請關注公號:“九神說程式設計”,回覆“JVM”獲取。

九神帶你入門JVM(上)

02

JVM的分類

對於新手,最大的認識偏差就是覺得只有一種JVM,這個認知是不對的。SUN公司有對JVM的規範,只要符合JVM規範大家都可以在此基礎上開發出自己的虛擬機器。事實上,我們現在最常用的HotSpot VM就不是SUN公司開發的。HotSpot VM是對JVM相關JSR(Java Specification Requests,Java規範)的一個RI(Reference Implementation,參考實現)。

關於相關的JSR可以在JCP(Java Community Process,Java社群組織)的官網上尋找。

如果你想知道你使用的Java虛擬機器的種類,可以在java -version中查詢:

九神帶你入門JVM(上)

1、HotSpot VM

HotSpot VM是絕對的主流。大家用它的時候很可能就沒想過還有別的選擇,或者是為了遷就依賴了Oracle/Sun JDK某些具體實現的爛程式碼而選擇用HotSpot VM省點心。

Oracle / Sun JDK、OpenJDK的各種變種(例如IcedTea、Zulu),用的都是相同核心的HotSpot VM。 從Java SE 7開始,HotSpot VM就是Java規範的“參考實現”(RI,Reference Implementation)。把它叫做“標準JVM”完全不為過。

當在面試中問道“Java效能如何如何”、“Java有多少種GC”、“JVM如何調優”等等,預設問的就是特指HotSpot VM,可見其“主流性”。(其實這不是件好事,可能有些面試官自己都不知道VM需要分類,當我們討論與JVM相關的問題是還是要具體到哪個VM才正確嚴禁)

JDK8的HotSpot VM已經是以前的HotSpot VM與JRockit VM的合併版,也就是傳說中的“HotRockit”,只是產品里名字還是叫HotSpot VM。這個合併並不是要把JRockit的部分程式碼插進HotSpot裡,而是把前者一些有價值的功能在後者裡重新實現一遍。移除PermGen、Java Flight Recorder、jcmd等都屬於合併專案的一部分。

不過要留意的是,這裡我說的HotSpot VM特指“正常配置”版,而不包括“Zero / Shark”版。Wikipedia那個頁面上把後者稱為“Zero Port”。用這個版本的人應該相當少,很多時候它的release版在其指定OS上都build不成功!

下面是抄來的一段與之相關的歷史:

提起HotSpot VM,相信所有Java程式設計師都知道,它是Sun JDK和OpenJDK中所帶的虛擬機器,也是目前使用範圍最廣的Java虛擬機器。 但不一定所有人都知道的是,這個目前看起來“血統純正”的虛擬機器在最初並非由Sun公司開發,而是由一家名為“Longview Technologies”的小公司設計的; 甚至這個虛擬機器最初並非是為Java語言而開發的,它來源於Strongtalk VM, 而這款虛擬機器中相當多的技術又是來源於一款支援Self語言實現“達到C語言50%以上的執行效率”的目標而設計的虛擬機器, Sun公司注意到了這款虛擬機器在JIT編譯上有許多優秀的理念和實際效果,在1997年收購了Longview Technologies公司,從而獲得了HotSpot VM。

HotSpot VM既繼承了Sun之前兩款商用虛擬機器的優點(如前面提到的準確式記憶體管理),也有許多自己新的技術優勢, 如它名稱中的HotSpot指的就是它的熱點程式碼探測技術(其實兩個VM基本上是同時期的獨立產品,HotSpot還稍早一些,HotSpot一開始就是準確式GC, 而Exact VM之中也有與HotSpot幾乎一樣的熱點探測。 為了Exact VM和HotSpot VM哪個成為Sun主要支援的VM產品,在Sun公司內部還有過爭論,HotSpot打敗Exact並不能算技術上的勝利), HotSpot VM的熱點程式碼探測能力可以透過執行計數器找出最具有編譯價值的程式碼,然後通知JIT編譯器以方法為單位進行編譯。 如果一個方法被頻繁呼叫,或方法中有效迴圈次數很多,將會分別觸發標準編譯和OSR(棧上替換)編譯動作。 透過編譯器與直譯器恰當地協同工作,可以在最最佳化的程式響應時間與最佳執行效能中取得平衡,而且無須等待原生代碼輸出才能執行程式, 即時編譯的時間壓力也相對減小,這樣有助於引入更多的程式碼最佳化技術,輸出質量更高的原生代碼。

在2006年的JavaOne大會上,Sun公司宣佈最終會把Java開源,並在隨後的一年,陸續將JDK的各個部分(其中當然也包括了HotSpot VM)在GPL協議下公開了原始碼, 並在此基礎上建立了OpenJDK。這樣,HotSpot VM便成為了Sun JDK和OpenJDK兩個實現極度接近的JDK專案的共同虛擬機器。

在2008年和2009年,Oracle公司分別收購了BEA公司和Sun公司,這樣Oracle就同時擁有了兩款優秀的Java虛擬機器:JRockit VM和HotSpot VM。 Oracle公司宣佈在不久的將來(大約應在釋出JDK 8的時候)會完成這兩款虛擬機器的整合工作,使之優勢互補。 整合的方式大致上是在HotSpot的基礎上,移植JRockit的優秀特性,譬如使用JRockit的垃圾回收器與MissionControl服務, 使用HotSpot的JIT編譯器與混合的執行時系統。

2、J9 VM

J9是IBM開發的一個高度模組化的JVM,這也是我們在工作中可能會用到的另外一個VM(除了這兩個,在中國的大廠裡不大可能接觸到其他的了)。

在許多平臺上,IBM J9 VM都只能跟IBM產品一起使用。這不是技術限制,而是許可證限制。例如說在Windows上IBM JDK不是免費公開的,而是要跟IBM其它產品一起捆綁釋出的;使用IBM Rational、IBM WebSphere的話都有機會用到J9 VM(也可以自己選擇配置使用別的Java SE JVM)。

根據許可證,這種捆綁在產品裡的J9 VM不應該用於執行別的Java程式,但是大家自己“偷偷的”拿來跑別的程式,IBM也沒力氣管。而在一些IBM的硬體平臺上,很少客戶是隻買硬體不買配套軟體的,IBM給一整套解決方案,裡面可能就包括了IBM JDK。這樣自然而然就用上了J9 VM。所以J9 VM得算在主流裡,雖然很少是大家主動選擇的首選。

J9 VM的效能水平大致跟HotSpot VM是一個檔次的。有時HotSpot快些,有時J9快些。不過J9 VM有一些HotSpot VM在JDK8還不支援的功能,最顯著的一個就是J9支援AOT編譯和更強大的class data sharing。

3、Sun Classic VM

從名字就可以看出來這是SUN公司開發的第一款商用的虛擬機器,現在此款虛擬機器已經淘汰了。 這個虛擬機器只能使用純直譯器的方式來執行Java程式碼。

4、Exact VM

這是一款被HotSpot VM給PK掉的VM,上面講歷史的時候講過。它只存在了很短暫的時間,而且只在Solaris平臺釋出過。

5、JRockit VM

JRockit VM曾經號稱“世界上速度最快的Java虛擬機器” 。由於專注於伺服器端應用,它可以不太關注程式啟動速度,因此JRockit內部不包含解析器實現,全部程式碼都靠即時 編譯器編譯後執行。

除此之外,JRockit的垃圾收集器和MissionControl服務套件等部分的實現,在眾多Java虛擬機器中也一直處於領先水平。這一套思想已經在Java 8中被HotSpot VM給用上了。

6、其他

其他的VM還有很多很多,比如Dalvik VM、Microsoft JVM、Azul VM、Liquid VM、Zing VM等等。但是這些VM和我們實際中不大會有交集,也不是歷史,更不是HotSpot VM曾經的競品,所以這裡就不探討了。

03

JVM垃圾回收綜述

為了更快搞明白JVM裡面的相關概念,先綜述JVM的垃圾回收(基於Java 8)。

Hotspot VM的垃圾回收採用“分代回收”的演算法。“分代回收”是基於這樣一個事實:物件的生命週期不同,所以針對不同生命週期的物件可以採取不同的回收方式,以便提高回收效率。

Hotspot VM將堆劃分為不同的物理區,就是“分代”思想的體現。如圖所示,JVM堆主要由新生代、老年代、元空間構成。

九神帶你入門JVM(上)

1、新生代(Young Generation):大多數物件在新生代中被建立,其中很多物件的生命週期很短。每次新生代的垃圾回收(又稱Minor GC)後只有少量物件存活,所以選用複製演算法,只需要少量的複製成本就可以完成回收。

新生代內又分三個區:一個Eden區,一般而言有兩個Survivor區,大部分物件在Eden區中生成。當Eden區滿時,還存活的物件將被複制到第一個Survivor區。當第一個Survivor區滿時,此區的存活且不滿足“晉升”條件的物件將被複制到第一個Survivor區。

物件每經歷一次Minor GC,年齡加1,達到“晉升年齡閾值”後,被放到老年代,這個過程也稱為“晉升”。顯然,“晉升年齡閾值”的大小直接影響著物件在新生代中的停留時間,在Serial和ParNew GC兩種回收器中,“晉升年齡閾值”透過引數MaxTenuringThreshold設定,預設值為15。

2、老年代(Old Generation):在新生代中經歷了N次垃圾回收後仍然存活的物件,就會被放到年老代,該區域中物件存活率高。老年代的垃圾回收(又稱Major GC)通常使用“標記-清理”或“標記-整理”演算法。整堆包括新生代和老年代的垃圾回收稱為Full GC(HotSpot VM裡,除了CMS之外,其它能收集老年代的GC都會同時收集整個GC堆,包括新生代)。

3、元空間(Metaspace):主要存放元資料,例如類元資訊、欄位、靜態屬性、方法、常量,還有執行時常量池等(不含字串常量),與垃圾回收要回收的Java物件關係不大。相對於新生代和年老代來說,該區域的劃分對垃圾回收影響很小。

04

JVM的記憶體模型(Java 8)

JVM記憶體模型分為堆(heap)、元空間、棧、本地方法棧、程式計數器。

JDK8的記憶體模型如下圖:

九神帶你入門JVM(上)

其中,堆和元空間是執行緒共享的,在Java虛擬機器中只有一個堆、一個元空間,並在JVM啟動的時候就建立,JVM停止才銷燬。棧、本地方法棧、程式計數器是每個執行緒私有的,隨著執行緒的建立而建立,隨著執行緒的結束而死亡。

九神帶你入門JVM(上)

1. 本地方法棧

提供虛擬機器使用到的本地Native方法服務。

2. 程式計數器(Program Counter Register)

程式計數器是一塊較小的記憶體空間。暫存器儲存指令相關的現場資訊,由於CPU時間片輪限制,眾多執行緒在併發執行過程中,任何一個確定的時刻,一個處理器或者多核處理器中的一個核心,只會執行某個執行緒中的一條指令。這樣必然導致經常中斷或恢復,如何保證分毫無差呢?

每個執行緒在建立後,都會產生自己的程式計數器和棧幀,程式計數器用來存放執行指令的偏移量和行號指示器等,執行緒執行或恢復都要依賴程式計數器。程式計數器在各個執行緒之間互不影響,此區域也不會發生記憶體溢位異常。

特點:

一塊較小的記憶體空間

執行緒私有

是唯一一個不會出現OOM的記憶體區域

3. 棧(Stack)

JVM中的虛擬機器棧是描述Java方法執行的記憶體區域,它是執行緒私有的。每個方法在執行的同時都會建立一個棧幀用於儲存區域性變數、運算元棧、動態連結、方法出口等資訊,每一個方法被呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程。

如果執行緒請求的棧深度大於虛擬機器所允許的深度,將會丟擲stackoverflowError通常出現在遞迴方法中;如果虛擬機器可以動態擴充套件,但是無法申請到足夠的記憶體時,就會丟擲outOfMemoryError異常。

九神帶你入門JVM(上)

4、堆

Heap儲存著幾乎所有的物件及陣列,JVM8中把靜態變數(字串常量池)也移到堆區進行儲存。

堆是OOM故障最主要的發源地,也是是垃圾回收的主要區域,所以也被稱為GC堆。通常情況下,它佔用的空間是所有記憶體區域中最大的,但如果無節制地建立大量物件,也容易消耗完所有的空間。堆的記憶體空間既可以固定大小,也可執行時動態地調整,透過如下引數設定初始值和最大值,比如

-Xms256M。 -Xmx1024M

。其中-X表示它是JVM執行引數,ms是memorystart初始堆容量的簡稱 ,mx是memory max最大堆容量的簡稱。但是在通常情況下,伺服器在執行過程中,堆空間不斷地擴容與回縮,勢必形成不必要的系統壓力,所以在線上生產環境中,JVM的Xms和Xmx設定成一樣大小,避免在GC後調整堆大小時帶來的額外壓力。

堆分成兩大塊:新生代和老年代,物件產生之初在新生代,步入暮年時進入老年代。新生代又分為1個Eden區+ 2個Survivor區,8:1:1的比例。絕大部分物件在Eden(意為伊甸園)區生成,

當Eden區裝填滿的時候,會觸發Young GC

。垃圾回收的時候,在Eden區實現清除策略,沒有被引用的物件則直接回收。依然存活的物件會被移送到Survivor(倖存者)區,這個區真是名副其實的存在。Survivor 區分為S0和S1兩塊記憶體空間,送到哪塊空間呢?每次Young GC的時候,將存活的物件複製到未使用的那塊空間,然後將當前正在使用的空間完全清除,交換兩塊空間的使用狀態。

如果Young GC要移送的物件大於Survivor區容量上限,或者超大物件的閾值超過eden分配擔保設定值的上限,則直接移交給老年代。如果老年代也無法放下,則會觸發Full Garbage Collection(Full GC),如果依然無法放下,則拋OOM。。

假如一些沒有進取心的物件以為可以一直在新生代的Survivor區交換來交換去,那就錯了。每個物件都有一個計數器,每次Young GC都會加1。

-XX:MaxTenuringThreshold

引數能配置計數器的值到達某個閾值的時候,物件從新生代晉升至老年代。預設值是15,可以在Survivor 區交換14次之後,晉升至老年代。

堆出現OOM的機率是所有記憶體耗盡異常中最高的。出錯時的堆內資訊對解決問題非常有幫助,所以給JVM設定執行引數

-XX:+HeapDumpOnOutOfMemoryError

,讓JVM遇到OOM異常時能輸出堆內資訊,使用

-XX:HeapDumpPath

引數指定dump路徑。利用JVM引數

-XX:OnOutOfMemoryError

可以在發生OOM異常時,執行一個本機的指令碼或指令。

九神帶你入門JVM(上)

5、方法區和持久代

注意,這部分並不存在與Java8,屬於Java 8之前的東西。雖然Java 8移除了這部分,但是這裡稍微回顧一下,可供大家比較不同,也防止萬一面試官同學根本不懂,還在問方法區和持久代。

方法區

中存放已經被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。這部分內容中的字串變數在Java8倍丟到了堆裡,其他的部分全部丟入元空間。

方法區與堆(Java Heap)一樣,是各個執行緒共享的記憶體區域。雖然Java虛擬機器規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做非堆(Non-Heap)。他們的區別是堆儲存物件資料,方法區儲存靜態資訊。

九神帶你入門JVM(上)

持久代

,即PermGen space的全稱是Permanent Generation space,是指記憶體的永久儲存區域,有人稱之為永久代。

需要知道的是,上述的方法區是JVM的規範,持久代是HotSpot VM對這一規範的實現。

不同的Java虛擬機器之間可能會進行類共享,因此持久代又分為只讀區和讀寫區。

JVM用於描述應用程式中用到的類和方法的元資料也儲存在持久代中。JVM執行時會用到多少持久代的空間取決於應用程式用到了多少類。除此之外,Java SE庫中的類和方法也都儲存在這裡。我們把所有儲存的內容總結如下:

JVM中類的元資料在Java堆中的儲存區域。

Java類對應的HotSpot虛擬機器中的內部表示也儲存在這裡。

類的層級資訊,欄位,名字。

方法的編譯資訊及位元組碼。

變數

常量池和符號解析

JVM 種類有很多,需要注意的是,PermGen space是Hotspot才有,JRockit以及J9是沒有這個區域。

6、元空間

隨著JDK8的到來,JVM不再有PermGen。但類的元資料資訊(metadata)還在,只不過不再是儲存在連續的堆空間上,而是移動到叫做“Metaspace”的本地記憶體(Native memory)中。

九神帶你入門JVM(上)

元空間由Klass Metaspace和NoKlass Mestaspace組成,其中:

Klass Metaspace:Klass Metaspace就是用來存klass的,klass是我們熟知的class檔案在jvm裡的執行時資料結構,不過有點要提的是我們看到的類似A。class其實是存在heap裡的,是java。lang。Class的一個物件例項。這塊記憶體是緊接著Heap的,和我們之前的perm一樣,這塊記憶體大小可透過-XX:CompressedClassSpaceSize引數來控制,這個引數預設是1G,但是這塊記憶體也可以沒有,假如沒有開啟壓縮指標就不會有這塊記憶體,這種情況下klass都會存在NoKlass Metaspace裡,另外如果我們把-Xmx設定大於32G的話,其實也是沒有這塊記憶體的,因為會這麼大記憶體會關閉壓縮指標開關。還有就是這塊記憶體最多隻會存在一塊。

NoKlass Metaspace:NoKlass Metaspace專門來存klass相關的其他的內容,比如method,constantPool等,這塊記憶體是由多塊記憶體組合起來的,所以可以認為是不連續的記憶體塊組成的。這塊記憶體是必須的,雖然叫做NoKlass Metaspace,但是也其實可以存klass的內容。

Klass Metaspace和NoKlass Mestaspace都是所有classloader共享的,所以類載入器們要分配記憶體,但是每個類載入器都有一個SpaceManager,來管理屬於這個類載入的記憶體小塊。如果Klass Metaspace用完了,那就會OOM了,不過一般情況下不會,NoKlass Mestaspace是由一塊塊記憶體慢慢組合起來的,在沒有達到限制條件的情況下,會不斷加長這條鏈,讓它可以持續工作。

元空間的本質是對JVM規範中方法區的實現。不過元空間與持久代之間最大的區別在於:元空間並不在虛擬機器中,而是使用本地記憶體。因此,預設情況下,元空間的大小僅受本地記憶體限制,但可以透過以下引數來指定元空間的大小:

-XX:MetaspaceSize,初始空間大小,達到該值就會觸發垃圾收集進行型別解除安裝,同時GC會對該值進行調整:如果釋放了大量的空間,就適當降低該值;如果釋放了很少的空間,那麼在不超過MaxMetaspaceSize時,適當提高該值。

-XX:MaxMetaspaceSize,最大空間,預設是沒有限制的。

除了上面兩個指定大小的選項以外,還有兩個與 GC 相關的屬性:

-XX:MinMetaspaceFreeRatio,在GC之後,最小的Metaspace剩餘空間容量的百分比,減少為分配空間所導致的垃圾收集

-XX:MaxMetaspaceFreeRatio,在GC之後,最大的Metaspace剩餘空間容量的百分比,減少為釋放空間所導致的垃圾收集

下面講一下元空間的特點:

充分利用了Java語言規範中的好處:類及相關的元資料的生命週期與類載入器的一致。

每個載入器有專門的儲存空間

只進行線性分配

不會單獨回收某個類

省掉了GC掃描及壓縮的時間

元空間裡的物件的位置是固定的

如果GC發現某個類載入器不再存活了,會把相關的空間整個回收掉

最後講一下元空間的記憶體分配模型:

絕大多數的類元資料的空間都從本地記憶體中分配

用來描述類元資料的類(klasses)也被刪除了

分元資料分配了多個虛擬記憶體空間

給每個類載入器分配一個記憶體塊的列表。塊的大小取決於類載入器的型別; sun/反射/代理對應的類載入器的塊會小一些

歸還記憶體塊,釋放記憶體塊列表

一旦元空間的資料被清空了,虛擬記憶體的空間會被回收掉

減少碎片的策略

7、為什麼移除持久代

對於老的Java程式設計師,我們都會遇到一個異常:java。lang。OutOfMemoryError: PermGen space 。說白了就是持久代記憶體溢位!

為什麼會記憶體溢位呢?因為持久代用於存放Class和Meta的資訊,Class在被 Load的時候被放入PermGen space區域,它和和存放Instance的Heap區域不同,所以如果你的APP會LOAD很多CLASS的話,就很可能出現PermGen space錯誤。這種錯誤常見在web伺服器對JSP進行pre compile的時候。

而這個異常實質是暴露了這一設計根源的一個問題:

持久代大小受到-XX:PermSize和-XX:MaxPermSize兩個引數的限制,而這兩個引數又受到JVM設定的記憶體大小限制,這就導致

在使用中可能會出現持久代記憶體溢位的問題

另外一方面,為了和JRockit進行融合而做的努力,JRockit使用者並不需要配置持久代,所以HotSpot也移除了持久代。

根據上面的內外兩方面原因,持久代最終被移除,

方法區移至Metaspace,字串常量移至Java Heap