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