02.28 Java虛擬機(JVM),看這篇就夠了

1. 什麼是JVM

與JVM的初次見面,是在我們Java SE的開始,認識Java跨平臺原理的時候.時隔多日,我們先來回顧一下.

Java的廣告語是,"編寫一次,到處運行",而它憑藉的就是JVM(Java Virtual Machine).而對於不同的平臺,Windows,Linux,Mac OS等,有具體不同的JVM版本.這些JVM屏蔽了平臺的不同,提供了統一的運行環境,讓Java代碼無需考慮平臺的差異,運行在相同的環境中.

下圖即Oracle官網下載JDK 8時所需要進行選擇的頁面

Java虛擬機(JVM),看這篇就夠了

而至於JRE和JDK,就不再贅述了,包含關係應該很清楚的,而今天我們的重點就在於對JVM的進一步認識以及對它進行優化調整.

2. 為什麼要優化JVM

正如前面我們所回顧的,我們的Java代碼都是運行在JVM中的,而部署的硬件及應用場景有所不同時,仍然採用默認的配置不見得能起到最好的效果,甚至可能會導致運行效率更差,又或者面臨高併發情況下,想讓程序平穩順暢的運行,所以我們需要針對實際的需要來進行優化.

3. 分析工具

我們只知道有JVM的存在,但它的運行對於我們來說感覺像是摸不著看不見的,所以我們需要藉助工具來監控它的一個實時狀態,就像Windows的性能監視器一樣,JDK也有自己的可視化工具.

我們以管理員身份運行DOS

Java虛擬機(JVM),看這篇就夠了

輸入jvisualvm,將Java VisualVM啟動

Java虛擬機(JVM),看這篇就夠了

在這裡我們可以看到

Java虛擬機(JVM),看這篇就夠了

本地列表中有多個條目,而一眼也可以看到我們SpringBoot項目的main方法,直接雙擊

經過短時間的加載後,得到這樣一個界面

Java虛擬機(JVM),看這篇就夠了

這個是概述頁面,可以得到很多信息,但對於我們分析JVM的運行還是沒有什麼幫助,所以我們切換到監視頁

Java虛擬機(JVM),看這篇就夠了

監視頁展示的就是實時的JVM信息,應該還是很直觀的

Java虛擬機(JVM),看這篇就夠了

現在安裝插件,插件的安裝屬於VisualVM的一個重要功能,憑藉插件我們可以將這個工具的功能變得更強大。

打開工具->插件;選擇"可用插件"頁;我們在這裡安裝一個Visual GC,方便我們看到內存回收以及各個分代的情況;打上勾之後點擊安裝,就是常規的next以及同意協議等,網絡不是很穩定,有時候可能需要多嘗試幾次。

安裝完成後我們將當前監控頁關掉,再次打開,就可以看到Profiler後面多了一個Visual GC頁。

Java虛擬機(JVM),看這篇就夠了

在這裡我們可以看到JIT活動時間,類加載活動時間,GC活動時間以及各個分代的情況。

需要注意的是,當前課件使用的JDK版本為1.8,仍然自帶了VisualVM,從1.9開始的版本是沒有自帶的,需要額外下載,下載的github地址:

另外,如果開發工具使用的是Intellij IDEA的話,可以下載一個插件,VisualVM Launcher,通過插件啟動可以直接到上述頁面,不用在左邊的條目中尋找自己的項目.

當然也有其他的工具,但這個在可預見的未來都會是主力發展的多合一故障處理工具.所以我們後面將會使用這個工具來分析我們的JVM運行情況,進而優化.而需要優化我們還需要對JVM的組成有進一步的瞭解.接下來我們來看一下JVM的組成

4. JVM組成


Java虛擬機(JVM),看這篇就夠了

從圖上可以看到,大致分為以下組件:

  1. 類加載器子系統
  2. 運行時數據區
  3. 執行引擎
  4. 本地方法庫

而本地庫接口也就是用於調用本地方法的接口,在此我們不細說,主要關注的是上述的4個組件

4.1類加載器子系統

顧名思義,這是用於類加載的一個子系統.

4.1.1類加載的過程

類加載的過程包括了加載,驗證,準備,解析和初始化這5個步驟

1. 加載:找到字節碼文件,讀取到內存中.類的加載方式分為隱式加載和顯示加載兩種。隱式加載指的是程序在使用new關鍵詞創建對象時,會隱式的調用類的加載器把對應的類加載到jvm中。顯示加載指的是通過直接調用class.forName()方法來把所需的類加載到jvm中。

2. 驗證:驗證此字節碼文件是不是真的是一個字節碼文件,畢竟後綴名可以隨便改,而內在的身份標識是不會變的.在確認是一個字節碼文件後,還會檢查一系列的是否可運行驗證,元數據驗證,字節碼驗證,符號引用驗證等.Java虛擬機規範對此要求很嚴格,在Java 7的規範中,已經有130頁的描述驗證過程的內容.

3. 準備:為類中static修飾的變量分配內存空間並設置其初始值為0或null.可能會有人感覺奇怪,在類中定義一個static修飾的int,並賦值了123,為什麼這裡還是賦值0.因為這個int的123是在初始化階段的時候才賦值的,這裡只是先把內存分配好.但如果你的static修飾還加上了final,那麼就會在準備階段就會賦值.

4. 解析:解析階段會將java代碼中的符號引用替換為直接引用.比如引用的是一個類,我們在代碼中只有全限定名來標識它,在這個階段會找到這個類加載到內存中的地址.

5. 初始化:如剛才準備階段所說的,這個階段就是對變量的賦值的階段.

4.1.2類與類加載器

每一個類,都需要和它的類加載器一起確定其在JVM中的唯一性.換句話來說,不同類加載器加載的同一個字節碼文件,得到的類都不相等.我們可以通過默認加載器去加載一個類,然後new一個對象,再通過自己定義的一個類加載器,去加載同一個字節碼文件,拿前面得到的對象去instanceof,會得到的結果是false.

4.1.3雙親委派機制


Java虛擬機(JVM),看這篇就夠了

類加載器一般有4種,其中前3種是必然存在的

  1. 啟動類加載器:加載<java>\\lib下的/<java>
  2. 擴展類加載器:加載<java>\\lib\\ext下的/<java>
  3. 應用程序類加載器:加載Classpath下的
  4. 自定義類加載器

而雙親委派機制是如何運作的呢?

我們以應用程序類加載器舉例,它在需要加載一個類的時候,不會直接去嘗試加載,而是委託上級的擴展類加載器去加載,而擴展類加載器也是委託啟動類加載器去加載.

啟動類加載器在自己的搜索範圍內沒有找到這麼一個類,表示自己無法加載,就再讓擴展類加載器去加載,同樣的,擴展類加載器在自己的搜索範圍內找一遍,如果還是沒有找到,就委託應用程序類加載器去加載.如果最終還是沒找到,那就會直接拋出異常了.

而為什麼要這麼麻煩的從下到上,再從上到下呢?

這是為了安全著想,保證按照優先級加載.如果用戶自己編寫一個名為java.lang.Object的類,放到自己的Classpath中,沒有這種優先級保證,應用程序類加載器就把這個當做Object加載到了內存中,從而會引發一片混亂.而憑藉這種雙親委派機制,先一路向上委託,啟動類加載器去找的時候,就把正確的Object加載到了內存中,後面再加載自行編寫的Object的時候,是不會加載運行的.

4.2運行時數據區

運行時數據區分為虛擬機棧,本地方法棧,堆區,方法區和程序計數器.

4.2.1程序計數器

程序計數器是線程私有的,雖然名字叫計數器,但主要用途還是用來確定指令的執行順序,比如循環,分支,跳轉,異常捕獲等.而JVM對於多線程的實現是通過輪流切換線程實現的,所以為了保證每個線程都能按正確順序執行,將程序計數器作為線程私有.程序計數器是唯一一個JVM沒有規定任何OOM的區塊.

4.2.2Java虛擬機棧

Java虛擬機棧也是線程私有的,每個方法執行都會創建一個棧幀,局部變量就存放在棧幀中,還有一些其他的動態鏈接之類的.通常有兩個錯誤會跟這個有關係,一個是StackOverFlowError,一個是OOM(OutOfMemoryError).前者是因為線程請求棧深度超出虛擬機所允許的範圍,後者是動態擴展棧的大小的時候,申請不到足夠的內存空間.而前者提到的棧深度,也就是剛才說到的每個方法會創建一個棧幀,棧幀從開始執行方法時壓入Java虛擬機棧,執行完的時候彈出棧.當壓入的棧幀太多了,就會報出這個StackOverflowError.

4.2.3本地方法棧

本地方法棧中存放的是JVM實際需要調用到的native方法,實際上還是和Java虛擬機棧很相似的.

4.2.4方法區

方法區是所有線程共享的一塊內存分區,它的名字其實感覺不太恰當,它主要保存的就是我們前面說的,類加載器加載到JVM中的類信息等.(而方法區在JVM規範中只是規定了它的存在和作用,並沒有限制它的實現,所以HotSpot就在Java7以及之前版本的設計中搞了個永生代來實現方法區,其他的廠商都沒有這個永生代.而這個設計經過多個版本的驗證,並不是一個好的設計,所以在Java 8的時候就移除掉了永生代,使用一個本地的內存塊來替代,命名為MetaSpace)

4.2.5堆

堆和方法區一樣(確切來說JVM規範中方法區就是堆的一個邏輯分區),就是一個所有線程共享的,存放對象的區域,也是GC的主要區域.其中的分區分為新生代,老年代.新生代中又可以細分為一個Eden,兩個Survivor區(From,To).Eden中存放的是通過new 或者newInstance方法創建出來的對象,絕大多數都是很短命的.正常情況下經歷一次gc之後,存活的對象會轉入到其中一個Survivor區,然後再經歷默認15次的gc,就轉入到老年代.這是常規狀態下,在Survivor區已經滿了的情況下,JVM會依據擔保機制將一些對象直接放入老年代。

4.3執行引擎

執行引擎包含即時編譯器(JIT)和垃圾回收器(GC),對即時編譯器我們簡單介紹一下,主要重點在於垃圾回收器.

4.3.1即時編譯器(JIT,Just-In-Time Compiler)

看到這個東西的存在可能有些人會感到疑問,不是通過javac命令就把我們的java代碼編譯成字節碼文件了嗎,這個即時編譯器又是幹嘛的?

我們需要明確一個概念就是,計算機實際上只認識0和1,這種由0和1組成的命令集稱之為"機器碼",而且會根據平臺不同而有所不同,可讀性和可移植性極差.我們的字節碼文件包含的並不是機器碼,不能由計算機直接運行,而需要JVM"解釋"執行.JVM將字節碼文件中所寫的命令解釋成一個個計算機操作命令,再通知計算機進行運算.

JIT並不是Java虛擬機規範定義中規定必須存在的.但它又是JVM性能重要影響因素之一.

在上面的內容裡,提到了HotSpot這麼一個名字,它是我們一直使用的這款虛擬機的名稱.HotSpot中文意思是"熱點",而HotSpot VM的特點之一也就是可以探測並優化熱點代碼,JIT就是它進行優化的方式.

HotSpot通過計數以及其他方式,監測到某些方法或者某些代碼塊執行的頻率很高,就會將其編譯成為平臺相關的機器碼,甚至於在保證結果的情況下通過優化執行順序等方式進行優化,這種機器碼的執行效率比解釋執行要高出很多.而編譯完成後,會通過"棧上替換"等方式進行動態的替換,比如循環執行,循環一次JIT的計數器就+1,到了閾值的時候就開始編譯重複執行的代碼,同時為了不影響系統的運行,原來的解釋執行仍然繼續,直到在第N次循環時,編譯完成,會在N+1次執行前替換成編譯後的機器碼執行.

計數器分為兩種,一種方法調用計數器,一種回邊計數器

方法計數器就是用於統計方法的直接調用,而回邊計數器用於循環代碼的技術。檢測的是頻率,所以他們的計數值不會一直累加,而是在一定時間段內疊加,而超過時間段還沒有達到閾值,就減半。這個減半稱為"熱度衰減",而這個時間段被稱為"半衰週期"

但編譯成為機器碼需要時間,會導致JVM啟動時間變長,內存消耗也會增加.所以需要根據實際情況權衡,在啟動時附加命令選擇執行模式.

  1. 純解釋執行模式:-Xint
  2. 純編譯執行模式:-Xcomp
  3. 混合模式:默認

JIT包含兩種編譯器,Client Compiler,Server Compiler.

Client Compiler,就是俗稱的C1編譯器.Server Compiler也就是俗稱的C2編譯器.JVM會根據版本及宿主機的硬件性能來自動選擇,也可以通過附加命令"-client"或者"-server"手動選擇.

C1編譯器編譯速度快,但編譯後的質量可靠,但性能優化程度不高.

C2編譯器編譯速度慢,但編譯後的性能優化程度很高,有時候會根據性能的監控情況採取"激進"優化.當然,這種激進優化如果失敗了,仍然會"逆優化"回退到解釋執行來保證代碼的正常運行.

4.4垃圾回收器(Garbage Collection)

4.4.1什麼是垃圾

說到垃圾回收器,首先需要說一下什麼叫垃圾.

所有的對象都存放在堆中,而有些對象用過之後就不會再被使用了,這種就叫做垃圾.概念很容易理解,但對於JVM來說,怎麼確定一個對象是否是垃圾或者說怎麼找到所有的垃圾對象就需要算法的支持.

4.4.2怎麼確定一個對象是垃圾

不得不提的一種是引用計數法,實現起來最簡單,一個對象被引用一次,計數器就+1,失去引用就計數器-1,等到計數器減為0了,這個對象就沒有其他對象在使用了,也就可以對它進行回收了.這種算法效率很高,但這種會有一個問題在於,兩個對象相互引用,但兩個對象都沒有被其他對象繼續引用了,計數器仍然不會減為0.

Java虛擬機(JVM),看這篇就夠了

通過引用計數來看,node1被node2引用著,node2也被node1引用著,兩個互相引用,卻沒有其他地方在引用,應該被清除掉,但引用計數器的值並沒有減為0,無法回收。所以幾乎已經被現代語言拋棄掉了,取而代之的是可達性分析標記存活對象而後使用其他算法.

可達性分析是從一個GC Root節點開始找引用的節點,找到後繼續找其引用的節點,直到查找完畢,其餘沒有被找到過的節點就是垃圾節點,一般作為GC Root的對象有Java棧中的本地變量對象,方法區的靜態變量引用的對象,方法區的常量引用的對象,本地方法棧中引用的對象等.

Java虛擬機(JVM),看這篇就夠了

如上圖所示,遍歷所有的GC Root(黑色的對象),然後向下尋找所有的引用關係,能夠找到的就標記為存活(藍色的對象)。而無法找到的,也就無法打上標記(黃色的對象),這些沒有存活標記的就是可以回收的對象。

4.4.3基本垃圾回收算法

大多數人對於GC的直觀感受是,飄忽不定,它執行的時間是不確定的,就算手動調用System.gc()也不見得會執行.但其實不盡然,GC作為一個守護線程,它的優先級是隨著內存使用情況不斷變化的,會在可用內存低到一定程度後自動調用.

基本GC算法主要是標記-清除算法,複製算法,標記-整理算法.

Java虛擬機(JVM),看這篇就夠了

標記-清除算法其實在JVM中沒怎麼露臉,但它是現代GC算法的基礎。通過可達性分析,將存活的對象打上標記,然後對全部對象進行掃描,將沒有標記的對象清除掉.這種算法會有一個問題,清除廢棄對象後,釋放的內存並不是連續的,而是一個個內存碎片,這對於後續JVM分配內存並不是很好,如果需要一塊較大的連續內存就沒有辦法將這些碎片利用起來.並且它需要遍歷所有的對象,清除沒有標記的,這種性能消耗很大。

Java虛擬機(JVM),看這篇就夠了

複製算法,一般應用於新生代,這也是為什麼新生代要設計成一個Eden,兩個Survivor區的原因。所有對象都在Eden創建出來,每次gc就會把Eden和其中一個正在使用的Survivor區中存活的對象複製到另外一個沒有使用的Survivor區。然後清除掉原來內存區的所有對象,也就是廢棄的對象。每次gc都這樣操作,始終留一個Survivor區不使用。這種算法的好處在於不會殘留內存碎片,方便內存管理,但是需要預留一塊內存,並且性能消耗是根據存活對象多少而來的,不適用於存活對象較多的情況。

Java虛擬機(JVM),看這篇就夠了

標記-整理算法,是標記-清除算法的升級版,一般用於老年代。它將標記存活的對象統一移到內存的某一端,然後將邊界外的空間清空。這樣既不會佔著一塊內存作為備用,也不會存在內存碎片無法有效利用。但是由於要遍歷存活的對象,還有重新存活對象的引用地址,所以效率要低於複製算法。

4.4.4分代回收算法

正如我們前面瞭解到的,新生代和老年代各自的情況不同,直接把某種算法套用在兩個區上,可能效果並不理想。而現在商業虛擬機的GC都是採用的分代回收算法,不同的堆分區採用不同的算法進行回收。

4.5Minor GC和Full GC

在說這兩種回收的區別之前,我們先來說一個概念,"Stop-The-World"。

如字面意思,每次垃圾回收的時候,都會將整個JVM暫停,回收完成後再繼續。如果一邊增加廢棄對象,一邊進行垃圾回收,完成工作似乎就變得遙遙無期了。

而一般來說,我們把新生代的回收稱為Minor GC,Minor意思是次要的,新生代的回收一般回收很快,採用複製算法,造成的暫停時間很短。而Full GC一般是老年代的回收,病伴隨至少一次的Minor GC,新生代和老年代都回收,而老年代採用標記-整理算法,這種GC每次都比較慢,造成的暫停時間比較長,通常是Minor GC時間的10倍以上。

所以很明顯,我們需要儘量通過Minor GC來回收內存,而儘量少的觸發Full GC。畢竟系統運行一會兒就要因為GC卡住一段時間,再加上其他的同步阻塞,整個系統給人的感覺就是又卡又慢。

5JVM的優化

JVM的優化我們可以從JIT優化,內存分區設置優化以及GC選擇優化三個方面入手。

5.1JIT優化

正如前面所說的,在系統啟動的時候,首先Java代碼是解釋執行的,當方法調用次數到達一定的閾值的時候(client:1500,server:10000),會採用JIT優化編譯。而直接將JVM的啟動設置為-Xcomp並不會有想象中那麼好。沒有足夠的profile(側寫,可以大致理解為分析結果),優化出來的代碼質量很差,甚至於執行效率還要低於解釋器執行,並且機器碼的大小很容易就超出字節碼大小的10倍以上。

那麼我們能做的,就是通過附加啟動命令適當的調整這個閾值或者調整熱度衰減行為,在恰當的時候觸發對代碼進行即時編譯。

  • 方法計數器閾值:-XX:CompileThreshold
  • 回邊計數器閾值:-XX:OnStackReplacePercentage(這並不是直接調整閾值,回邊計數器的調整在此僅作簡單介紹,此計數器會根據是Client模式還是Server模式有不同的計算公式)
  • 關閉熱度衰減:-XX:UseCounterDecay
  • 設置半衰週期:-XX:CounterHalfLifeTime

而JIT也是一片廣闊的知識海洋,有興趣可以根據以下的優化技術名稱搜索瞭解詳情,在此就不贅述了。

Java虛擬機(JVM),看這篇就夠了


Java虛擬機(JVM),看這篇就夠了

5.2JVM內存分區優化

我們依據Java Performance這本書的建議的設置原則進行設置,

Java整個堆大小設置,Xmx 和 Xms設置為老年代存活對象的3-4倍,即FullGC之後的老年代內存佔用的3-4倍,Xmx和Xms的大小設置為一樣,避免GC後對內存的重新分配。而Full GC之後的老年代內存大小,我們可以通過前面在Visual VM中添加的插件Visual GC查看。先手動進行一次GC,然後查看老年代的內存佔用。

  • 新生代Xmn的設置為老年代存活對象的1-1.5倍。
  • 老年代的內存大小設置為老年代存活對象的2-3倍。

5.3垃圾回收器的認識

垃圾回收器有很多,他們各自有各自的特點,沒有什麼回收器是最好的,所以才會有這麼多存在,而我們就需要根據實際情況來選擇組合,進行JVM的調優。

主要有以下7個垃圾回收器:

  1. Serial
  2. ParNew
  3. Parallel Scavenge
  4. Serial Old
  5. Parallel Old
  6. CMS
  7. G1


Java虛擬機(JVM),看這篇就夠了

可以從這張圖大概看到,哪些垃圾回收器是用於回收哪個代的,以及連線表示可以搭配組合使用。

5.3.1 Serial

Serial是最基本,也是發展最悠久的垃圾回收器。它採用單線程收集,在單CPU環境下效率很高,沒有線程切換,專注於垃圾回收。它作為Client模式JVM的默認垃圾回收器。

我們通過-XX:+UseSerialGC來選擇使用它。

5.3.2 ParNew

這個也就是Serial的多線程版本,代碼重複度都很高。它是作為Server模式JVM的默認垃圾回收器。但需要注意的是,多線程是它的特點,並不見得是優點。在單核環境下是絕對不如Serial的效率,在雙核環境下都不能保證100%比Serial的效率高。它默認的線程數和CPU核數相同,在CPU核數非常多的環境下,比如32個,我們沒有必要同時用32個線程來進行垃圾回收,線程的切換也是有相當大的性能開銷的。

我們可以通過-XX:+UseParNewGC來選擇使用它,通過-XX:ParallelGCThreads來指定線程數。

5.3.3 Parallel Scavenge

這個垃圾回收器的特點感覺跟ParNew都一樣,但它的關注點不同。它的目標是達到一個可控制的吞吐量。

吞吐量是什麼意思呢?假如我們虛擬機總共運行了100分鐘,其中垃圾收集花掉了1分鐘,吞吐量則是99%。

吞吐量越高,那麼響應速度越快,在與用戶的交互中就會感覺更順暢,這在注重交互的環境中更為重要。

我們通過-XX:+UseParallelGC來選擇使用它,然後使用-XX:MaxGCPauseMillis參數設置最大GC暫停時間(毫秒數),然後GC會盡量在這個時間內完成。但並不是越小越好,越小那麼每次回收的內存也就越少,那麼回收的次數也會增長起來,總體的吞吐量也會降低。同樣我們也可以使用-XX:GCTimeRatio設置非GC佔用時間的比重。比如設置為19,那非GC佔用時間的比重就是19/(1+19).

除去上述兩個配置參數外,我們還可以使用-XX:+UseAdaptiveSizePolicy命令,這個命令添加後,就不需要手動去指定新生代大小,以及Eden區和Survivor區的比例,晉升老年代的年齡閾值了,JVM會根據當前系統的運行情況智能調節這些大小比例等。

5.3.4 Serial Old

看名字應該能夠猜得出來,這就是Serial收集器的老年代版本,它同樣也是Client模式下默認的垃圾回收器,但它在Server模式下有一個另外的用途,作為CMS收集器的後備預案。這個不用手動開啟,一般在指定Serial收集器的時候就自動搭配了Serial Old收集器。

5.3.5 Parallel Old

這個是Parallel Scavenge收集器的老年代版本,專門用於與Parallel Scavenge搭配使用。不用手動開啟,在我們開啟Parallel Scavenge收集器的時候自動使用。

5.3.6 CMS

Concurrent-Mark-Sweep收集器,它是併發收集,低停頓的。它的目標就是儘量減少停頓時間,我們通過-XX:+UseConcMarkSweepGC開啟CMS收集器,打開後就會使用ParNew+CMS+Serial Old組合,而Serial Old是作為CMS回收失敗時的後備GC。

5.3.7 G1

G1是現目前最前沿的技術,它跟上面所說的那些收集器完全不同。我們現在只需要知道它的目標也是低停頓,與CMS目標一致,但CMS更為成熟,G1卻潛力更大,可能在Java9作為默認的垃圾收集器。

我們可以通過-XX:+UseG1GC來指定使用G1收集器。

後記:

在最新的Java 11中,已經提供了ZGC這種新型的垃圾回收器了,相比G1不再像其他垃圾回收器一樣將新生代,老年代分為固定內存區域,而是分成了很多個Region,每個Region可以是新生代或者老年代。它更加靈活,在大堆的情況下能夠顯著改善內存回收的停頓時間。

而ZGC更是逆天,無論堆內存大小是多大,最高的JVM停頓不超過10ms,而SPECjbb2015測試中,128G的堆內存,最大停頓才1.68ms,是最大。這樣一來基本就可以告別上文所說的一切調優了,但無論如何,靜下心來學習始終是一件有著重要意義的事情(並不是可惜我啃了這麼久的書後面發現可能再也用不上了),對於ZGC有興趣的同學可以自己去看看,Java 11是一個值得期待的版本。


分享到:


相關文章: