01、JVM的初步認識
1. 什麼是JVM
與JVM的初次見面,是在我們Java SE的開始,認識Java跨平臺原理的時候.時隔多日,我們先來回顧一下.
Java的廣告語是,"編寫一次,到處執行",而它憑藉的就是JVM(Java Virtual Machine).而對於不同的平臺,Windows,Linux,Mac OS等,有具體不同的JVM版本.這些JVM遮蔽了平臺的不同,提供了統一的執行環境,讓Java程式碼無需考慮平臺的差異,執行在相同的環境中.
下圖即Oracle官網下載JDK 8時所需要進行選擇的頁面
而至於JRE和JDK,就不再贅述了,包含關係應該很清楚的,而今天我們的重點就在於對JVM的進一步認識以及對它進行優化調整.
2. 為什麼要優化JVM
正如前面我們所回顧的,我們的Java程式碼都是執行在JVM中的,而部署的硬體及應用場景有所不同時,仍然採用預設的配置不見得能起到最好的效果,甚至可能會導致執行效率更差,又或者面臨高併發情況下,想讓程式平穩順暢的執行,所以我們需要針對實際的需要來進行優化.
3. 分析工具
我們只知道有JVM的存在,但它的執行對於我們來說感覺像是摸不著看不見的,所以我們需要藉助工具來監控它的一個實時狀態,就像Windows的效能監視器一樣,JDK也有自己的視覺化工具.
我們以管理員身份執行DOS
輸入jvisualvm,將Java VisualVM啟動
在這裡我們可以看到
本地列表中有多個條目,而一眼也可以看到我們SpringBoot專案的main方法,直接雙擊
經過短時間的載入後,得到這樣一個介面
這個是概述頁面,可以得到很多資訊,但對於我們分析JVM的執行還是沒有什麼幫助,所以我們切換到監視頁
監視頁展示的就是實時的JVM資訊,應該還是很直觀的
現在安裝外掛,外掛的安裝屬於VisualVM的一個重要功能,憑藉外掛我們可以將這個工具的功能變得更強大。
開啟工具->外掛;選擇"可用外掛"頁;我們在這裡安裝一個Visual GC,方便我們看到記憶體回收以及各個分代的情況;打上勾之後點選安裝,就是常規的next以及同意協議等,網路不是很穩定,有時候可能需要多嘗試幾次。
安裝完成後我們將當前監控頁關掉,再次開啟,就可以看到Profiler後面多了一個Visual GC頁。
在這裡我們可以看到JIT活動時間,類載入活動時間,GC活動時間以及各個分代的情況。
需要注意的是,當前課件使用的JDK版本為1.8,仍然自帶了VisualVM,從1.9開始的版本是沒有自帶的,需要額外下載,下載的github地址:
另外,如果開發工具使用的是Intellij IDEA的話,可以下載一個外掛,VisualVM Launcher,通過外掛啟動可以直接到上述頁面,不用在左邊的條目中尋找自己的專案.
當然也有其他的工具,但這個在可預見的未來都會是主力發展的多合一故障處理工具.所以我們後面將會使用這個工具來分析我們的JVM執行情況,進而優化.而需要優化我們還需要對JVM的組成有進一步的瞭解.接下來我們來看一下JVM的組成
4. JVM組成
從圖上可以看到,大致分為以下元件:
- 類載入器子系統
- 執行時資料區
- 執行引擎
- 本地方法庫
而本地庫介面也就是用於呼叫本地方法的介面,在此我們不細說,主要關注的是上述的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雙親委派機制
類載入器一般有4種,其中前3種是必然存在的
- 啟動類載入器:載入<JAVA_HOME>lib下的
- 擴充套件類載入器:載入<JAVA_HOME>libext下的
- 應用程式類載入器:載入Classpath下的
- 自定義類載入器
而雙親委派機制是如何運作的呢?
我們以應用程式類載入器舉例,它在需要載入一個類的時候,不會直接去嘗試載入,而是委託上級的擴充套件類載入器去載入,而擴充套件類載入器也是委託啟動類載入器去載入.
啟動類載入器在自己的搜尋範圍內沒有找到這麼一個類,表示自己無法載入,就再讓擴充套件類載入器去載入,同樣的,擴充套件類載入器在自己的搜尋範圍內找一遍,如果還是沒有找到,就委託應用程式類載入器去載入.如果最終還是沒找到,那就會直接丟擲異常了.
而為什麼要這麼麻煩的從下到上,再從上到下呢?
這是為了安全著想,保證按照優先順序載入.如果使用者自己編寫一個名為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啟動時間變長,記憶體消耗也會增加.所以需要根據實際情況權衡,在啟動時附加命令選擇執行模式.
- 純解釋執行模式:-Xint
- 純編譯執行模式:-Xcomp
- 混合模式:預設
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.
通過引用計數來看,node1被node2引用著,node2也被node1引用著,兩個互相引用,卻沒有其他地方在引用,應該被清除掉,但引用計數器的值並沒有減為0,無法回收。所以幾乎已經被現代語言拋棄掉了,取而代之的是可達性分析標記存活物件而後使用其他演算法.
可達性分析是從一個GC Root節點開始找引用的節點,找到後繼續找其引用的節點,直到查詢完畢,其餘沒有被找到過的節點就是垃圾節點,一般作為GC Root的物件有Java棧中的本地變數物件,方法區的靜態變數引用的物件,方法區的常量引用的物件,本地方法棧中引用的物件等.
如上圖所示,遍歷所有的GC Root(黑色的物件),然後向下尋找所有的引用關係,能夠找到的就標記為存活(藍色的物件)。而無法找到的,也就無法打上標記(黃色的物件),這些沒有存活標記的就是可以回收的物件。
4.4.3基本垃圾回收演算法
大多數人對於GC的直觀感受是,飄忽不定,它執行的時間是不確定的,就算手動呼叫System.gc()也不見得會執行.但其實不盡然,GC作為一個守護執行緒,它的優先順序是隨著記憶體使用情況不斷變化的,會在可用記憶體低到一定程度後自動呼叫.
基本GC演算法主要是標記-清除演算法,複製演算法,標記-整理演算法.
標記-清除演算法其實在JVM中沒怎麼露臉,但它是現代GC演算法的基礎。通過可達性分析,將存活的物件打上標記,然後對全部物件進行掃描,將沒有標記的物件清除掉.這種演算法會有一個問題,清除廢棄物件後,釋放的記憶體並不是連續的,而是一個個記憶體碎片,這對於後續JVM分配記憶體並不是很好,如果需要一塊較大的連續記憶體就沒有辦法將這些碎片利用起來.並且它需要遍歷所有的物件,清除沒有標記的,這種效能消耗很大。
複製演算法,一般應用於新生代,這也是為什麼新生代要設計成一個Eden,兩個Survivor區的原因。所有物件都在Eden創建出來,每次gc就會把Eden和其中一個正在使用的Survivor區中存活的物件複製到另外一個沒有使用的Survivor區。然後清除掉原來記憶體區的所有物件,也就是廢棄的物件。每次gc都這樣操作,始終留一個Survivor區不使用。這種演算法的好處在於不會殘留記憶體碎片,方便記憶體管理,但是需要預留一塊記憶體,並且效能消耗是根據存活物件多少而來的,不適用於存活物件較多的情況。
標記-整理演算法,是標記-清除演算法的升級版,一般用於老年代。它將標記存活的物件統一移到記憶體的某一端,然後將邊界外的空間清空。這樣既不會佔著一塊記憶體作為備用,也不會存在記憶體碎片無法有效利用。但是由於要遍歷存活的物件,還有重新存活物件的引用地址,所以效率要低於複製演算法。
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也是一片廣闊的知識海洋,有興趣可以根據以下的優化技術名稱搜尋瞭解詳情,在此就不贅述了。
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個垃圾回收器:
- Serial
- ParNew
- Parallel Scavenge
- Serial Old
- Parallel Old
- CMS
- G1
可以從這張圖大概看到,哪些垃圾回收器是用於回收哪個代的,以及連線表示可以搭配組合使用。
5.3.1Serial
Serial是最基本,也是發展最悠久的垃圾回收器。它採用單執行緒收集,在單CPU環境下效率很高,沒有執行緒切換,專注於垃圾回收。它作為Client模式JVM的預設垃圾回收器。
我們通過-XX:+UseSerialGC來選擇使用它。
5.3.2ParNew
這個也就是Serial的多執行緒版本,程式碼重複度都很高。它是作為Server模式JVM的預設垃圾回收器。但需要注意的是,多執行緒是它的特點,並不見得是優點。在單核環境下是絕對不如Serial的效率,在雙核環境下都不能保證100%比Serial的效率高。它預設的執行緒數和CPU核數相同,在CPU核數非常多的環境下,比如32個,我們沒有必要同時用32個執行緒來進行垃圾回收,執行緒的切換也是有相當大的效能開銷的。
我們可以通過-XX:+UseParNewGC來選擇使用它,通過-XX:ParallelGCThreads來指定執行緒數。
5.3.3Parallel 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.4Serial Old
看名字應該能夠猜得出來,這就是Serial收集器的老年代版本,它同樣也是Client模式下預設的垃圾回收器,但它在Server模式下有一個另外的用途,作為CMS收集器的後備預案。這個不用手動開啟,一般在指定Serial收集器的時候就自動搭配了Serial Old收集器。
5.3.5Parallel Old
這個是Parallel Scavenge收集器的老年代版本,專門用於與Parallel Scavenge搭配使用。不用手動開啟,在我們開啟Parallel Scavenge收集器的時候自動使用。
5.3.6CMS
Concurrent-Mark-Sweep收集器,它是併發收集,低停頓的。它的目標就是儘量減少停頓時間,我們通過-XX:+UseConcMarkSweepGC開啟CMS收集器,開啟後就會使用ParNew+CMS+Serial Old組合,而Serial Old是作為CMS回收失敗時的後備GC。
5.3.7G1
G1是現目前最前沿的技術,它跟上面所說的那些收集器完全不同。我們現在只需要知道它的目標也是低停頓,與CMS目標一致,但CMS更為成熟,G1卻潛力更大,可能在Java9作為預設的垃圾收集器。
我們可以通過-XX:+UseG1GC來指定使用G1收集器。
後記:
在最新的Java 11中,已經提供了ZGC這種新型的垃圾回收器了,相比G1不再像其他垃圾回收器一樣將新生代,老年代分為固定記憶體區域,而是分成了很多個Region,每個Region可以是新生代或者老年代。它更加靈活,在大堆的情況下能夠顯著改善記憶體回收的停頓時間。
而ZGC更是逆天,無論堆記憶體大小是多大,最高的JVM停頓不超過10ms,而SPECjbb2015測試中,128G的堆記憶體,最大停頓才1.68ms,是最大。這樣一來基本就可以告別上文所說的一切調優了,但無論如何,靜下心來學習始終是一件有著重要意義的事情(並不是可惜我啃了這麼久的書後面發現可能再也用不上了),對於ZGC有興趣的同學可以自己去看看,Java 11是一個值得期待的版本。