面試必問 | 聊聊JVM性能調(diào)優(yōu)?
經(jīng)過幾天的思考,我決定暫時放下【字節(jié)碼編程】的更新,先更新一些面試中經(jīng)常會被問及的一些知識點,以便能夠幫助小伙伴們系統(tǒng)的梳理面試中需要掌握的知識技能。
主要的方式是以面試的角度,深度聊聊面試中經(jīng)常被問及的各項知識點。
對于工作3年左右的Java程序員來說,在面試大廠的過程中,面試官可能不會太關(guān)注你做了多少個項目、你的CRUD水平如何。更多的是關(guān)注你對某項技術(shù)點的理解深度,所以說,工作3年左右的小伙伴一定要把自己的重心放到技術(shù)的深度上來。
今天,我們先一起聊聊關(guān)于JVM性能調(diào)優(yōu)的話題,本文的主要結(jié)構(gòu)如下所示。
常見面試題
關(guān)于JVM,一道常見的面試題就是:Java中創(chuàng)建的對象是存儲在JVM中的哪個區(qū)域的?
例如,這里,我們簡單的列舉一行代碼,如下所示。
- User user = new User();
關(guān)于上面的代碼,不少小伙伴都知道,創(chuàng)建出來的User對象是放在JVM中的堆區(qū)域的,而User對象的引用是放在棧中的。但如果你只是了解到這種程度,那面試官就會認為你了解的太淺顯了,可能就會達不到他們的要求。其實面試官想要了解你是否對JVM有一個更深入的認識。
站在面試官的角度來看這個問題時,回答創(chuàng)建出來的User對象是放在JVM的堆區(qū),也并沒有錯。但是JVM的堆內(nèi)存區(qū)域又會分為年輕代和老年代,而年輕代又會分為Eden區(qū)和Survivor區(qū)。JVM堆空間的邏輯結(jié)構(gòu)如下圖所示。
而面試官更想了解的是你能不能說出來創(chuàng)建的對象具體是存放在JVM堆空間的哪個區(qū)域。
在JVM內(nèi)部,會將整個堆空間劃分成年輕代和老年代,年輕代默認會占整個堆內(nèi)存空間的1/3,老年代默認會占整個堆內(nèi)存空間的2/3。年輕代又會劃分為Eden區(qū)和兩個Survivor區(qū),它們之間的默認比例是Eden:Survivor1:Survivor2 = 8:1:1。
如果你能回答出 新創(chuàng)建的User對象是存放在JVM堆空間中年輕代的Eden區(qū),那面試官就會對你刮目相看了。當然,這里沒有考慮JVM的逃逸分析情況,關(guān)于JVM的逃逸分析,大家可以參考《逃逸分析》一文。
JVM體系結(jié)構(gòu)
JVM主要由三個子系統(tǒng)構(gòu)成,分別為:類加載器子系統(tǒng)、運行時數(shù)據(jù)區(qū)(內(nèi)存結(jié)構(gòu))和字節(jié)碼執(zhí)行引擎。
關(guān)于JVM的體系結(jié)構(gòu)全貌,我們先來看一張圖。
當我們開發(fā)Java程序時,首先會編寫.java文件,之后,會將.java文件編譯成.class文件。
JVM中,會通過類裝載子系統(tǒng)將.class文件的內(nèi)容裝載到JVM的運行時數(shù)據(jù)區(qū),而JVM的運行時數(shù)據(jù)區(qū)又會分為:方法區(qū)、堆、棧、本地方法棧和程序計數(shù)器 幾個部分。
在裝載class文件的內(nèi)容時,會將class文件的內(nèi)容拆分為幾個部分,分別裝載到JVM運行時數(shù)據(jù)區(qū)的幾個部分。其中,值得注意的是:程序計數(shù)器的作用是:記錄程序執(zhí)行的下一條指令的地址。
方法區(qū)也叫作元空間,主要包含了:運行時常量池、類型信息、字段信息、方法信息、類加載器的引用、對應(yīng)的Class實例的引用等信息。
在JVM中,程序的執(zhí)行是通過執(zhí)行引擎進行的,執(zhí)行引擎會調(diào)用本地方法的接口來執(zhí)行本地方法庫,進而完成整個程序邏輯的執(zhí)行。
我們常說的垃圾收集器是包含在執(zhí)行引擎中的,在程序的運行過程中,執(zhí)行引擎會開啟垃圾收集器,并在后臺運行,垃圾收集器會不斷監(jiān)控程序運行過程中產(chǎn)生的內(nèi)存垃圾信息,并根據(jù)相應(yīng)的策略對垃圾信息進行清理。
這里,大家需要注意的是:棧、本地方法棧和程序計數(shù)器是每個線程運行時獨占的,而方法區(qū)和堆是所有線程共享的。所以,棧、本地方法棧和程序計數(shù)器不會涉及線程安全問題,而方法區(qū)和堆會涉及線程安全問題。
方法區(qū)(元空間)
很多小伙伴一看到方法區(qū)三個字,腦海中的第一印象可能是存儲方法的地方吧。
實則不然,方法區(qū)的另一個名字叫作元空間,相信不少小伙伴或多或少的聽說過元空間。這個區(qū)域是JDK1.8中劃分出來的。主要包含:運行時常量池、類型信息、字段信息、方法信息、類加載器的引用、對應(yīng)的Class實例的引用等信息。方法區(qū)中的信息能夠被多個線程共享。
例如,在程序中聲明的常量、靜態(tài)變量和有關(guān)于類的信息等的引用,都會存放在方法區(qū),而這些引用所指向的具體對象 一般都會在堆中開辟單獨的空間進行存儲,也可能會在直接內(nèi)存中進行存儲。
堆
堆中主要存儲的是實際創(chuàng)建的對象,也就是會存儲通過new關(guān)鍵字創(chuàng)建的對象,堆中的對象能夠被多個線程共享。堆中的數(shù)據(jù)不需要事先明確生存期,可以動態(tài)的分配內(nèi)存,不再使用的數(shù)據(jù)和對象由JVM中的GC機制自動回收。對JVM的性能調(diào)優(yōu)一般就是對堆內(nèi)存的調(diào)優(yōu)。
Java中基本類型的包裝類:Byte、Short、Integer、Long、Float、Double、Boolean、Character類型的數(shù)據(jù)是存儲在堆中的。
堆一般會被分成年輕代和老年代。而年輕代又會被進一步分為1個Eden區(qū)和2個Survivor區(qū)。在內(nèi)存分配上,如果保持默認配置的話,年輕代和老年代的內(nèi)存大小比例為1 : 2,年輕代中的1個Eden區(qū)和2個Survivor區(qū)的內(nèi)存大小比例為:8 : 1 : 1。
棧
棧一般又叫作線程棧或虛擬機棧,一般存儲的是局部變量。在Java中,每個線程都會有一個單獨的棧區(qū),每個棧中的元素都是私有的,不會被其他的棧所訪問。棧中的數(shù)據(jù)大小和生存期都是確定的,存取速度比較快。
在Java中,所有的基本數(shù)據(jù)類型(byte、short、int、long、float、double、boolean、char)和引用變量(對象引用)都是在棧中的。一般情況下,線程退出或者方法退出時,棧中的數(shù)據(jù)會被自動清除。
程序在執(zhí)行過程中,會在棧中為不同的方法創(chuàng)建不同的棧幀,在棧幀中又包含了:局部變量表、操作數(shù)棧、動態(tài)鏈接和方法出口。
關(guān)于局部變量表、操作數(shù)棧、動態(tài)鏈接和方法出口的具體作用,會在《架構(gòu)師進階系列》中的后續(xù)文章中詳細闡述。
棧中一般會存儲對象的引用,這些引用所指向的具體對象一般都會在堆中開辟單獨的地址空間進行存儲,也有可能存儲在直接內(nèi)存中。
注意:這里說的是這些引用所指向的具體對象一般都會在堆中開辟單獨的地址空間進行存儲,也有可能存儲在直接內(nèi)存中。
因為在JVM中,如果開啟了逃逸分析和標量替換,則可能不會再在堆上創(chuàng)建對象,可能會將對象直接分配到棧上,也可能不再創(chuàng)建對象,而是進一步分解對象中的成員變量,將其直接在棧上分配空間并賦值。
本地方法棧
本地方法棧相對來說比較簡單,就是保存native方法進入?yún)^(qū)域的地址。
例如,在Java中創(chuàng)建線程,調(diào)用Thread對象的start()方法時,會通過本地方法start0()調(diào)用操作系統(tǒng)創(chuàng)建線程的方法。此時,本地方法棧就會保存start0()方法進入?yún)^(qū)域的內(nèi)存地址。
程序計數(shù)器
程序計數(shù)器也叫作PC計數(shù)器,只要存儲的是下一條將要執(zhí)行的命令的地址。
雙親委派機制
何為雙親委派?
JVM中是通過類的雙親委派機制來加載的,那什么是雙親委派機制呢?我們先來看一張圖。
當JVM加載某個類的時候,不會直接使用當前類的加載器加載該類,會先委托父加載器尋找要加載的目標類,找不到再委托上層的父加載器進行加載,直到引導(dǎo)類加載器同樣找不到要加載的目標類,就會在自己的類加載路徑中查找并加載目標類。
簡單來說:雙親委派機制就是:先使用父加載器加載,如果父加載器找不到要加載的目標類,就使用子加載器自己加載。
為何使用雙親委派機制?
這里,小伙伴們有沒有想過這樣一個問題:JVM為何要使用雙親委派機制呢?
為了更好的說明問題,我們自己創(chuàng)建一個java.lang包,并在java.lang包下,創(chuàng)建一個String類,如下所示。
- package java.lang;
- /**
- * @author binghe (公眾號:冰河技術(shù))
- * @version 1.0.0
- * @description 測試時雙親委派機制
- */
- public class String {
- public static void main(String[] args){
- System.out.println("自定義的String類");
- }
- }
這里,我們自己創(chuàng)建一個java.lang.String類,而JDK中也存在一個java.lang.String類,如果運行我們自己創(chuàng)建的java.lang.String會發(fā)生什么呢?會輸出如下錯誤信息。
錯誤: 在類 java.lang.String 中找不到 main 方法, 請將 main 方法定義為:
- public static void main(String[] args)
否則 JavaFX 應(yīng)用程序類必須擴展javafx.application.Application
那JVM為何要使用雙親委派機制呢?試想,如果我們自己寫的類能夠隨隨便便覆蓋JDK中的類的話,那JDK中的代碼是不是就沒有任何安全性可言了?沒錯,JVM為了代碼的安全性,也即是沙箱安全機制,使用了雙親委派機制。
另外,使用雙親委派機制,也能防止JVM內(nèi)存中出現(xiàn)多份相同的字節(jié)碼。例如,兩個類A和B,都需要加載System類。如果JVM沒有提供雙親委派機制,那么A和B兩個類就會分別加載一份System的字節(jié)碼,這樣JVM內(nèi)存中就會出現(xiàn)這份System字節(jié)碼。
相反,JVM提供了雙親委派機制的話,在加載System類的過程中,會遞歸的向父加載器查找并加載,整個過程會優(yōu)先選用BootStrapClassLoader加載器,也就是我們通常說的引導(dǎo)類加載器。如果找不到就逐級向下使用子加載器進行加載。
而System類可以在BootStrapClassLoader中進行加載,如果System類已經(jīng)通過A類的引用加載過,此時B類也要加載System類,也會從BootStrapClassLoader開始加載System類,此時,BootStrapClassLoader發(fā)現(xiàn)已經(jīng)加載過System類了,就會直接返回內(nèi)存中的System,不再重新加載。
這樣,在JVM內(nèi)存中,就只會存在一份System類的字節(jié)碼。
類加載器的父子關(guān)系
如何確認類加載器的父子關(guān)系呢?這里,我們再來看一個示例代碼,如下所示。
- /**
- * @author binghe (公眾號:冰河技術(shù))
- * @version 1.0.0
- * @description 類的雙親委派機制
- */
- public class User {
- public static void main(String[] args){
- User user = new User();
- System.out.println(user.getClass().getClassLoader());
- System.out.println(user.getClass().getClassLoader().getParent());
- System.out.println(user.getClass().getClassLoader().getParent().getParent());
- }
- }
這段代碼也比較簡單,創(chuàng)建了一個User對象,打印User對象的類加載器,父類加載和上層父加載器。在IDEA中運行上述代碼,會輸出如下信息。
- sun.misc.Launcher$AppClassLoader@18b4aac2
- sun.misc.Launcher$ExtClassLoader@135fbaa4
- null
可以看到,User對象的類加載器是AppClassLoader,父加載器是ExtClassLoader。而輸出的null其實是BootStrapClassLoader,而BootStrapClassLoader也就是上層父加載器。
這樣,類加載器的父子關(guān)系就出來了:AppClassLoader的父加載器是ExtClassLoader,ExtClassLoader的父加載器是BootStrapClassLoader。
這里,需要注意的是:父加載器并不是父類。
類加載器加載的類
- 引導(dǎo)類加載器(BootStrapClassLoader):負責加載%JAVA_HOME%/jre/lib目錄下的所有jar包,或者是-Xbootclasspath參數(shù)指定的路徑;
- 擴展類加載器(ExtClassLoader):負責加載%JAVA_HOME%/jre/lib/ext目錄下的所有jar包,或者是java.ext.dirs參數(shù)指定的路徑;
- 應(yīng)用類加載器(AppClassLoader):負責加載用戶類路徑上所指定的類庫。
注意:引導(dǎo)類加載器和擴展類加載器加載的類都是預(yù)先加載好的,而應(yīng)用類加載器用來加載應(yīng)用工程的classes以及l(fā)ib下的類庫,僅僅聲明,并不會提前載入JVM內(nèi)存,等到使用的時候才會加載到JVM內(nèi)存中。
類的加載過程
一個類在JVM中的加載過程大致經(jīng)歷了加載、驗證、準備、解析和初始化。
加載: 主要是在計算機磁盤上通過IO流讀取字節(jié)碼文件(.class文件),當程序需要使用某個類時,才會對這個類進行加載操作,比如,在程序中調(diào)用某個類的靜態(tài)方法,使用new關(guān)鍵字創(chuàng)建某個類的對象等。在加載階段,往往會在JVM的堆內(nèi)存中生成一個代表這個類的Class對象,這個對象作為存放在JVM方法區(qū)中這個類的各種數(shù)據(jù)的訪問入口,也可以叫做訪問句柄。
- 驗證:主要的作用就是校驗字節(jié)碼的正確性,是否符合JVM規(guī)范。
- 準備:為類的靜態(tài)變量分配相應(yīng)的內(nèi)存,并賦予默認值。
- 解析:將程序中的符號引用替換為直接引用,這里的符號引用包括:靜態(tài)方法等。此階段就是將一些靜態(tài)方法等符號引用替換成指向數(shù)據(jù)所在內(nèi)存地址的指針,這些指針就是直接引用。如果是在類加載過程中完成的符號引用到直接引用的替換,這個替換的過程就叫作靜態(tài)鏈接過程。如果是在運行期間完成的符號引用到直接引用的替換,這個替換的過程就叫作動態(tài)鏈接過程。
- 初始化:對類的靜態(tài)變量進行初始化,為其賦予程序中指定的值,并執(zhí)行靜態(tài)代碼塊中的代碼。
注意:在準備階段和初始化階段都會為類的靜態(tài)變量賦值,不同之處就是在準備階段為類的靜態(tài)變量賦予的是默認值,而在初始化階段為類的靜態(tài)變量賦予的是真正要賦予的值。
例如,在程序中有如下靜態(tài)變量。
- public static int count = 100;
在準備階段會為count賦予一個默認值0,而在初始化階段才會真正將count賦值為100。
JVM調(diào)優(yōu)參數(shù)
在JVM中,主要是對堆(新生代)、方法區(qū)和棧進行性能調(diào)優(yōu)。各個區(qū)域的調(diào)優(yōu)參數(shù)如下所示。
- 堆:-Xms、-Xmx
- 新生代:-Xmn
- 方法區(qū)(元空間):-XX:MetaspaceSize、-XX:MaxMetaspaceSize
- 棧(線程):-Xss
為了更加直觀的表述,我們可以將JVM的內(nèi)存區(qū)域和對應(yīng)的調(diào)優(yōu)參數(shù)總結(jié)成下圖所示。
在設(shè)置JVM啟動參數(shù)時,需要特別注意方法區(qū)(元空間)的參數(shù)設(shè)置。
關(guān)于方法區(qū)(元空間)的JVM參數(shù)主要有兩個:-XX:MetaspaceSize和-XX:MaxMetaspaceSize。
-XX:MetaspaceSize: 指的是方法區(qū)(元空間)觸發(fā)Full GC的初始內(nèi)存大小(方法區(qū)沒有固定的初始內(nèi)存大小),以字節(jié)為單位,默認為21M。達到設(shè)置的值時,會觸發(fā)Full GC,同時垃圾收集器會對這個值進行修改。
如果在發(fā)生Full GC時,回收了大量內(nèi)存空間,則垃圾收集器會適當降低此值的大小;如果在發(fā)生Full GC時,釋放的空間比較少,則在不超過設(shè)置的-XX:MetaspaceSize值或者在沒設(shè)置-XX:MetaspaceSize的值時不超過21M,適當提高此值。
-XX:MaxMetaspaceSize: 指的是方法區(qū)(元空間)的最大值,默認值為-1,不受堆內(nèi)存大小限制,此時,只會受限于本地內(nèi)存大小。
最后需要注意的是: 調(diào)整方法區(qū)(元空間)的大小會發(fā)生Full GC,這種操作的代價是非常昂貴的。如果發(fā)現(xiàn)應(yīng)用在啟動的時候發(fā)生了Full GC,則很有可能是方法區(qū)(元空間)的大小被動態(tài)調(diào)整了。
所以,為了盡量不讓JVM動態(tài)調(diào)整方法區(qū)(元空間)的大小造成頻繁的Full GC,一般將-XX:MetaspaceSize和-XX:MaxMetaspaceSize設(shè)置成一樣的值。例如,物理內(nèi)存8G,可以將這兩個值設(shè)置為256M
最后,我們一起看下在物理內(nèi)存8G的情況下,啟動應(yīng)用程序時,可以設(shè)置的JVM參數(shù)。當然,我這里給出的是一些經(jīng)驗值,實際部署到生產(chǎn)環(huán)境時,需要經(jīng)過壓測找到最佳的參數(shù)值。
- 啟動SpringBoot
- java ‐Xms2048M ‐Xmx2048M ‐Xmn1024M ‐Xss512K ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐jar xxx.jar
- 啟動Tomcat(Linux)
在Tomcat bin目錄下catalina.sh文件里配置。
- ‐Xms2048M ‐Xmx2048M ‐Xmn1024M ‐Xss512K ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M
- 啟動Tomcat(Windows)
在Tomcat bin目錄下catalina.bat文件里配置。
- ‐Xms2048M ‐Xmx2048M ‐Xmn1024M ‐Xss512K ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M
總結(jié)
本文以面試為背景,探討了有關(guān)JVM的常見面試問題。文章開頭以一個常見的面試題舉例,說明了JVM在互聯(lián)網(wǎng)大廠面試中的重要性。接下里,介紹了JVM的體系結(jié)構(gòu),包含:方法區(qū)(元空間)、堆、棧、本地方法棧和程序計數(shù)器。
隨后,介紹了JVM中的雙親委派機制,說明了何為雙親委派,為何使用雙親委派機制,類加載器的父子關(guān)系。需要注意的是:這里說的類加載器的父子關(guān)系并不是父類和子類的關(guān)系。隨后,介紹了各個類加載器要加載哪些類。
接下來,介紹了類的加載過程,主要包含:加載、驗證、準備、解析和初始化等步驟,同時,說明了各個步驟的主要作用。
最后,介紹了JVM中常用的調(diào)優(yōu)參數(shù),涵蓋堆、新生代、方法區(qū)(元空間)和棧(線程)常用的調(diào)優(yōu)參數(shù)。并以Tomcat調(diào)優(yōu)為例,詳細說明了如何使用這些調(diào)優(yōu)參數(shù)。
說了這么多你都掌握了嗎?
本文轉(zhuǎn)載自微信公眾號「冰河技術(shù)」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系冰河技術(shù)公眾號。