一、JVM 概述
JVM(Java Virtual Machine)即 Java 虛擬機,它是 Java 編程語言的核心組件之一,負責執行 Java 程序。JVM 使得 Java 程序可以實現“一次編寫,到處運行”的特性,因為它提供了一個抽象的運行環境,將 Java 字節碼與具體的硬件和操作系統隔離開來。Java 編譯器(如 javac)將 Java 源代碼編譯成字節碼,這些字節碼被存儲在 .class 文件中,JVM 加載這些字節碼文件,并使用字節碼執行引擎來解釋和執行其中的指令。
二、JVM 運行時數據區域
JVM 在執行 Java 程序的過程中會把它所管理的內存劃分為若干個不同的區域,這些區域各自有各自的用途以及特性。運行時數據區域主要分為線程共享區域和線程私有區域。
2.1 線程共享區域
2.1.1 堆(Heap)
堆是 JVM 中最大的一塊內存區域,用于存儲對象實例和數組,是垃圾收集器管理的主要區域。當堆中沒有足夠的內存來完成實例分配,并且堆也無法再擴展時,將會拋出 OutOfMemoryError
異常。
為了更好地管理堆內存中的對象,包括內存的分配和回收,堆被劃分為新生代和老年代,默認比例為 1:2(可以通過 -XX:NewRatio
調整)。新生代又可以被劃分為三個區域:Eden 區、From Survivor 區和 To Survivor 區,默認的比例為 8:1:1(可以通過 -XX:SurvivorRatio
來設定)。
2.1.2 方法區(Method Area)
方法區用于存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯后的代碼等數據。在 JDK 8 及之前的版本中,方法區被實現為永久代(Permanent Generation),而在 JDK 8 之后的版本中,方法區被替換為元空間(Metaspace),使用本地內存實現。當方法區無法滿足內存分配需求時,將拋出 OutOfMemoryError
異常。
2.2 線程私有區域
2.2.1 程序計數器(Program Counter Register)
程序計數器是一塊較小的內存空間,它可以看作是當前線程所執行的字節碼的行號指示器。為了線程切換后能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各條線程之間的計數器互不影響,獨立存儲。此內存區域是唯一一個在 Java 虛擬機規范中沒有規定任何 OutOfMemoryError
情況的區域。
2.2.2 虛擬機棧(JVM Stack)
虛擬機棧描述的是 Java 方法執行的內存模型,每個方法被執行的時候都會同時創建一個棧幀,用于存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。每一個方法從調用直至執行完成的過程,都對應著一個棧幀在虛擬機棧中從入棧到出棧的過程。如果線程請求的棧深度大于虛擬機所允許的深度,將拋出 StackOverflowError
異常;如果虛擬機棧可以動態擴展,擴展時無法申請到足夠的內存,就會拋出 OutOfMemoryError
異常。
2.2.3 本地方法棧(Native Method Stack)
本地方法棧與虛擬機棧所發揮的作用是非常相似的,其區別只是虛擬機棧為虛擬機執行 Java 方法(也就是字節碼)服務,而本地方法棧則是為虛擬機使用到的本地(Native)方法服務。同樣,本地方法棧也可能拋出 StackOverflowError
和 OutOfMemoryError
異常。
三、對象的創建與訪問定位
3.1 對象的創建
當 Java 虛擬機在遇到一條字節碼 new
指令時,首先將去檢查這個指令的參數是否能在常量池中定位到一個類的符號引用,并且檢查這個符號引用代表的類是否已被加載、解析和初始化過。如果沒有,必須先執行相應的類加載過程。類加載檢查通過后,接下來虛擬機將為新生對象分配內存。
3.2 對象的內存分配
從 Java 堆的內存是否規整的角度,對象的內存分配方式分為指針碰撞和空閑列表兩種。如果 Java 堆中內存是絕對規整的,所有被使用過的內存都被放在一邊,空閑的內存被放在另一邊,中間放著一個指針作為分界點的指示器,那么所分配內存就僅僅是把那個指針向空閑空間方向挪動一段與對象大小相等的距離,這種分配方式稱為“指針碰撞”;如果 Java 堆中的內存并不是規整的,已被使用的內存和空閑的內存相互交錯在一起,那就沒有辦法簡單地進行指針碰撞了,虛擬機就必須維護一個列表,記錄哪些內存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,并更新列表上的記錄,這種分配方式稱為“空閑列表”。
3.3 對象的訪問定位
Java 程序通過棧上的引用變量來訪問堆中的對象實例,對象的訪問方式主要有使用句柄和直接指針兩種。
- 使用句柄:Java 堆中會劃分出一塊內存來作為句柄池,引用變量中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據和類型數據各自的具體地址信息。
- 直接指針:引用變量中存儲的直接就是對象的地址。
使用句柄的好處是引用變量中存儲的是穩定的句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行為)時只會改變句柄中的實例數據指針,而引用變量本身不需要修改;直接指針的好處是訪問速度快,節省了一次指針定位的時間開銷。
四、垃圾收集器和內存分配策略
4.1 判斷對象的生死
4.1.1 引用計數法
引用計數法是垃圾收集器中的早期策略,它為堆中每個對象實例都分配一個引用計數器。當對象被創建時,計數器被設置為 1;當有一個變量被設置為這個對象的引用時,計數器加 1;當這個對象的某個引用被銷毀或指向新的值時,計數器減 1;當計數器為 0 時,這個對象實例就被認為是垃圾,可以被回收。然而,引用計數法存在很大的缺點,當兩個對象循環引用時,由于互相存在引用,所以引用計數器一直為 1,但是沒有任何其他變量指向它們,即使它們已經沒有任何用處,仍然不能當作垃圾進行回收,長時間下來會浪費大量的內存。
4.1.2 可達性分析算法
為了解決引用計數算法無法解決循環引用的問題,推出了可達性分析算法。該算法基于離散數學的有向圖,通過一個 GC Roots 節點開始,尋找它的引用節點,找到后再尋找新節點的引用節點,直到找完所有的節點,那么剩下的節點就會被認為是沒有引用的節點,便會判斷為可以回收的對象。能成為 GC Roots 的對象主要包括:虛擬機棧中的引用對象、方法區中類的靜態屬性和常量引用對象、本地方法棧中的 JNI 引用對象。
4.2 引用類型
在 Java 語言中,引用分為強引用、軟引用、弱引用、虛引用 4 種,這四種引用強度依次逐漸減弱。
- 強引用:在程序代碼中普遍存在的,類似
Object obj = new Object()
這類引用,只要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象。 - 軟引用:用來描述一些還有用但并非必須的對象。對于軟引用關聯著的對象,在系統將要發生內存溢出異常之前,將會把這些對象列進回收范圍之中進行第二次回收。如果這次回收后還沒有足夠的內存,才會拋出內存溢出異常。
- 弱引用:也是用來描述非必需對象的,但是它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象。
- 虛引用:也叫幽靈引用或幻影引用,是最弱的一種引用關系。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。它的作用是能在這個對象被收集器回收時收到一個系統通知,通常用于指向一些對象被回收時的清理操作。
4.3 垃圾收集算法
4.3.1 標記 - 清除算法
標記 - 清除算法分為標記和清除兩個階段,首先標記出所有需要回收的對象,在標記完成后,統一回收掉所有被標記的對象,也可以反過來,標記存活的對象,統一回收所有未被標記的對象。該算法的缺點是執行效率不穩定,如果 Java 堆中包含大量對象,而且其中大部分是需要被回收的,這時必須進行大量標記和清除的動作,導致標記和清除兩個過程的執行效率都隨對象數量增長而降低;另外,還會產生大量不連續的內存碎片,空間碎片太多可能會導致當以后在程序運行過程中需要分配較大對象時無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。
4.3.2 標記 - 復制算法
標記 - 復制算法將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活著的對象復制到另外一塊上面,然后再把已使用過的內存空間一次清理掉。該算法的優點是對于多數對象都是可回收的情況,算法需要復制的就是占少數的存活對象,而且每次都是針對整個半區進行內存回收,分配內存時也就不用考慮有空間碎片的復雜情況,只要移動堆頂指針,按順序分配即可,實現簡單,運行高效。缺點是如果內存中多數對象都是存活的,這種算法將會產生大量的內存間復制的開銷,而且可用內存縮小為了原來的一半,空間浪費未免太多。
4.3.3 標記 - 整理算法
標記 - 整理算法的標記過程仍然與“標記 - 清除”算法一樣,但后續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向內存空間一端移動,然后直接清理掉邊界以外的內存。該算法避免了標記 - 清除算法的內存碎片問題,但整理階段開銷較大。
4.3.4 分代收集算法
分代收集算法是目前大部分 JVM 采用的垃圾收集算法,它將堆內存劃分為不同的代,不同代使用不同的垃圾回收算法。新生代對象生命周期短,使用復制算法;老年代生命周期較長的對象,使用標記 - 清除或標記 - 整理算法。
4.4 經典的垃圾收集器
4.4.1 Serial 收集器
Serial 收集器是單線程的收集器,適用于單核或小內存環境。它在進行垃圾回收時,必須暫停所有應用線程(STW,Stop - The World),影響響應時間,但實現簡單,開銷低。
4.4.2 ParNew 收集器
ParNew 收集器是 Serial 收集器的多線程版本,同樣需要暫停所有應用線程。它在多核處理器環境下可以提高垃圾回收的吞吐量。
4.4.3 Parallel Scavenge 收集器
Parallel Scavenge 收集器是新生代收集器,采用復制算法,是多線程并發的垃圾收集器。它的目標是達到一個可控的吞吐量,適合對吞吐量要求較高的應用場景。該收集器提供了垃圾收集的自適應的調節策略,這是它區別于 ParNew 收集器的一個重要特性。
4.4.4 Serial Old 收集器
Serial Old 收集器是 Serial 收集器的老年代版本,同樣是單線程收集器,使用標記 - 整理算法。
4.4.5 Parallel Old 收集器
Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本,采用多線程并行的方式進行垃圾回收,使用標記 - 整理算法,適合對吞吐量要求較高的應用場景。
4.4.6 CMS 收集器(重點)
CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器,適用于對響應時間要求高的應用。它采用并發標記和清除的方式,減少了垃圾回收的停頓時間。但該收集器需要更多的 CPU 資源,可能出現“浮動垃圾”問題,并且會產生內存碎片。
4.4.7 G1 收集器(重點)
G1(Garbage - First)收集器是近年來廣泛使用的垃圾回收器,特別適合大內存、低延遲場景。它將堆內存劃分為多個區域(Region),并采用并發回收的方式,優先回收垃圾最多的區域,平衡了吞吐量和停頓時間,但實現復雜,調優難度較大。
4.5 內存分配策略
- 對象優先在 Eden 分配:大多數情況下,新創建的對象會優先在新生代的 Eden 區分配內存。
- 大對象直接進入老年代:大對象是指需要大量連續內存空間的對象,如大數組等。為了避免在新生代頻繁進行垃圾回收,大對象會直接進入老年代。
- 長期存活的對象將進入老年代:對象在 Survivor 區每存活一次,年齡就會加 1,當達到一定的年齡(默認是 15,可以通過
-XX:MaxTenuringThreshold
設置)時,就會晉升到老年代。 - 動態對象年齡判定:如果在 Survivor 區中相同年齡所有對象大小的總和大于 Survivor 區空間的一半,年齡大于或等于該年齡的對象就可以直接進入老年代,無須等到
MaxTenuringThreshold
中要求的年齡。 - 空間分配擔保:在發生 Minor GC 之前,虛擬機必須先檢查老年代最大可用的連續空間是否大于新生代所有對象總空間,如果這個條件成立,那么 Minor GC 可以確保是安全的;如果不成立,則虛擬機會查看
-XX:HandlePromotionFailure
設置值是否允許擔保失敗,如果允許,那么會繼續檢查老年代最大可用的連續空間是否大于歷次晉升到老年代對象的平均大小,如果大于,將嘗試進行一次 Minor GC,盡管這次 Minor GC 是有風險的;如果小于,或者-XX:HandlePromotionFailure
設置不允許冒險,那這時就要進行一次 Full GC。
五、JVM 類加載機制
5.1 類加載的時機
Java 虛擬機規范并沒有強制約束類加載過程的第一階段(即加載)什么時候開始,但是對于類的初始化階段,虛擬機規范則是嚴格規定了有且只有 5 種情況必須立即對類進行“初始化”(而加載、驗證、準備自然需要在此之前開始):
- 遇到
new
、getstatic
、putstatic
或invokestatic
這 4 條字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這 4 條指令的最常見的 Java 代碼場景是:使用new
關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被final
修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。 - 使用
java.lang.reflect
包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。 - 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
- 當虛擬機啟動時,用戶需要指定一個要執行的主類(包含
main()
方法的那個類),虛擬機會先初始化這個主類。 - 當使用 JDK 1.7 的動態語言支持時,如果一個
java.lang.invoke.MethodHandle
實例最后的解析結果 REF_getStatic、REF_putStatic、REF_invodeStatic 的方法句柄,并且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。
5.2 類加載的過程
類加載過程包括加載、驗證、準備、解析和初始化五個階段。
5.2.1 加載
加載階段是類加載過程的第一個階段,在這個階段,虛擬機需要完成以下三件事情:
- 通過類的全限定名來獲取定義該類的二進制字節流。
- 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時存儲結構。
- 在內存中生成一個代表該類的
java.lang.Class
對象,作為方法區這個類的各種數據的訪問入口。
5.2.2 驗證
驗證是連接階段的第一步,這一階段的目的是確保 .class
字節碼文件的字節流中包含的信息符合當前虛擬機的要求,并且不會危害虛擬機自身的安全。驗證階段大致會完成下面 4 個階段的檢驗動作:
- 文件格式驗證:驗證字節流是否符合
.class
字節碼文件格式的規范,并且能被當前版本的虛擬機處理。 - 元數據驗證:對字節碼描述的信息進行語義分析,以保證其描述的信息符合 Java 語言規范的要求。
- 字節碼驗證:通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的。
- 符號引用驗證:發生在虛擬機將符號引用轉化為直接引用的時候,這個轉化動作將在連接的第三個階段——解析階段中發生。符號引用驗證的目的是確保解析動作能正常執行。
5.2.3 準備
準備階段是正式為類變量(被 static
修飾的變量)分配內存并設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。這里所說的初始值通常是數據類型的零值,例如,int
類型的初始值是 0,boolean
類型的初始值是 false
等。
5.2.4 解析
解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程。符號引用是以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可;直接引用是可以直接指向目標的指針、相對偏移量或者是一個能間接定位到目標的句柄。
5.2.5 初始化
初始化階段是類加載過程的最后一步,在這個階段,才真正開始執行類中定義的 Java 程序代碼(或者說是字節碼)。初始化階段是執行類構造器 <clinit>()
方法的過程。<clinit>()
方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static
塊)中的語句合并產生的,編譯器收集的順序是由語句在源文件中出現的順序決定的。
5.3 類加載器
Java 中的類加載器主要分為以下幾種:
5.3.1 啟動類加載器(Bootstrap ClassLoader)
啟動類加載器負責加載支撐 JVM 運行的位于 JRE 的 lib
目錄下的核心類庫,比如 rt.jar
、charsets.jar
等。它是用 C++ 實現的,是虛擬機自身的一部分,無法被 Java 程序直接引用。
5.3.2 擴展類加載器(Extension ClassLoader)
擴展類加載器負責加載支撐 JVM 運行的位于 JRE 的 lib
目錄下的 ext
擴展目錄中的 JAR 類包。它由 sun.misc.Launcher$ExtClassLoader
實現。
5.3.3 應用程序類加載器(Application ClassLoader)
應用程序類加載器負責加載 ClassPath 路徑下的類包,主要就是加載用戶自己編寫的那些類。它由 sun.misc.Launcher$AppClassLoader
實現,一般情況下,Java 應用的類都是由它來完成加載的,可以通過 ClassLoader.getSystemClassLoader()
來獲取它。
5.3.4 自定義類加載器
用戶可以通過繼承 java.lang.ClassLoader
類的方式實現自定義類加載器,以滿足一些特殊的需求,比如隔離加載類、修改類加載方式、擴展加載源等。
5.4 雙親委派模型
雙親委派模型是 JVM 類加載器的一種工作模式。當一個類加載器收到了類加載的請求時,它首先不會自己去加載這個類,而是把這個請求委派給父類加載器去完成,每一層的類加載器都是如此,這樣所有的加載請求都會被傳送到頂層的啟動類加載器中,只有當父加載器無法完成加載請求(它的搜索范圍中沒找到所需的類)時,子加載器才會嘗試去加載類。
雙親委派模型的優點是可以保證 Java 程序的安全性和穩定性,避免類的重復加載,同時也可以防止核心 API 庫被隨意篡改。例如,我們自己編寫的 java.lang.String
類不會被加載,因為啟動類加載器會優先加載 JDK 中的 String
類。
六、字節碼執行引擎
6.1 字節碼執行引擎的概念
字節碼執行引擎是 JVM 內用于解釋和執行 Java 字節碼的模塊。Java 編譯器將 Java 源代碼編譯成字節碼,這些字節碼被存儲在 .class
文件中,JVM 加載這些字節碼文件,并使用字節碼執行引擎來解釋和執行其中的指令。字節碼是一種中間表示形式,獨立于具體的硬件和操作系統,這使得 Java 應用程序可以跨平臺運行。
6.2 字節碼執行引擎的工作原理
6.2.1 解釋執行
解釋執行是字節碼執行引擎的基本功能,JVM 內置的解釋器會逐條讀取和解釋字節碼指令,并執行對應的操作。這種執行方式簡單但效率較低。
6.2.2 即時編譯(JIT)
為了提高性能,現代 JVM 實現了即時編譯(Just - In - Time Compilation,JIT)技術。JIT 編譯器在運行時將頻繁執行的字節碼編譯為本地機器碼,以提高執行速度。JIT 編譯后的本地機器碼比解釋執行快得多,因為它可以利用硬件和操作系統的特性進行優化。
6.2.3 棧幀管理
字節碼執行引擎管理每個方法調用的棧幀,棧幀是用于支持虛擬機進行方法調用和方法執行的數據結構,它存儲了方法的局部變量、操作數棧、動態鏈接和方法返回地址等信息。每一個方法從調用開始到執行完成的過程,都對應著一個棧幀在虛擬機棧里從入棧到出棧的過程。
6.2.4 異常處理
字節碼執行引擎負責處理 Java 程序中的異常。當方法中發生異常時,JVM 會查找該方法的異常處理表,以確定是否有對應的異常處理器。如果找到了匹配的異常處理器,JVM 將控制轉移到異常處理器執行;如果當前方法中沒有匹配的異常處理器,JVM 會將異常拋給調用該方法的方法,一直沿著調用棧向上查找,直到找到合適的異常處理器或棧幀用盡。
6.3 字節碼執行引擎的主要組件
6.3.1 解釋器
解釋器是字節碼執行引擎的基本組件,它逐條解釋和執行字節碼指令。解釋器的優點是實現簡單、啟動快,因此適合應用程序的啟動階段。然而,解釋器的執行速度較慢,因為它需要逐條解析和執行字節碼指令。
6.3.2 即時編譯器(JIT Compiler)
JIT 編譯器通過將熱點字節碼編譯為本地機器碼,顯著提高程序的執行速度。JIT 編譯器在運行時對代碼進行動態優化,這些優化可以基于實際的執行情況,如內聯方法調用、消除不必要的代碼和循環優化等。JIT 編譯器有多個級別的優化,可以根據代碼的執行頻率逐步提高優化程度。
6.3.3 垃圾收集器(Garbage Collector,GC)
垃圾收集器不是字節碼執行引擎的一部分,但它在字節碼執行過程中起著重要作用。垃圾收集器負責自動回收不再使用的對象,釋放堆內存,以確保系統不會耗盡內存。字節碼執行引擎和垃圾收集器協同工作,以管理 Java 程序的內存使用。
6.4 字節碼執行引擎的優化技術
6.4.1 即時編譯(JIT)優化
JIT 編譯器使用多種優化技術來提高程序的執行速度,如方法內聯、逃逸分析、循環優化、動態編譯等。方法內聯是將被頻繁調用的方法的代碼直接插入到調用者中,減少方法調用的開銷;逃逸分析是分析對象的作用域,如果對象不會逃逸出方法范圍,可以在棧上分配內存而不是在堆上,減少垃圾收集的壓力。
6.4.2 解釋執行與編譯執行的混合模式
現代 JVM 通常使用解釋執行與即時編譯的混合模式。程序啟動時,JVM 使用解釋器執行字節碼,這樣可以快速啟動程序。當 JVM 檢測到熱點代碼時,觸發 JIT 編譯器對這些代碼進行優化編譯。這種混合模式結合了解釋執行的快速啟動優勢和即時編譯的高效執行優勢。
6.4.3 分層編譯
分層編譯(Tiered Compilation)是 JVM 的一種優化策略,結合了不同級別的編譯和優化。它包括解釋模式、C1 編譯器和 C2 編譯器。解釋模式用于快速啟動和執行程序;C1 編譯器進行基礎的即時編譯,適用于中等熱點代碼,提供較快的編譯速度和適度的優化;C2 編譯器進行高級優化,適用于非常熱的代碼,提供高優化水平但編譯開銷較大。通過分層編譯,JVM 可以在程序生命周期的不同階段應用不同的優化策略,平衡啟動時間和執行效率。
七、JVM 常見問題與調優
7.1 常見問題
7.1.1 內存問題
- 內存泄漏:對象無法被垃圾回收,導致內存占用持續增長。常見的原因包括靜態集合持有對象、未關閉資源(如數據庫連接)、監聽器未注銷等。
- 內存溢出:堆內存或方法區內存不足,無法分配新對象。堆內存溢出可能是由于內存泄漏、數據規模過大、JVM 堆設計不合理、高并發場景等原因導致的;方法區溢出可能是由于動態生成大量類等原因引起的。
7.1.2 性能問題
- CPU 使用率過高:某些線程占用大量 CPU 資源,可能是由于線程競爭(如頻繁鎖競爭)或大量 GC 引起的。
- GC 頻繁或耗時過長:垃圾回收導致應用暫停時間過長,影響應用的性能和響應時間。
7.1.3 線程問題
- 死鎖:多個線程相互等待,導致程序無法繼續執行。
- 線程阻塞:線程因等待資源而被阻塞,可能會影響應用的并發性能。
7.1.4 類加載問題
- 類加載失敗:類加載器無法找到指定的類,可能是由于類路徑配置錯誤、類文件損壞等原因導致的。
- 類沖突:多個類加載器加載了相同類名的不同版本,可能會導致程序出現異常。
7.2 調優步驟
7.2.1 分析需求和瓶頸
通過監控工具(如 JVisualVM、JConsole、Prometheus + Grafana)查看應用的運行情況,找出是內存不足、GC 頻繁還是 CPU 過高等問題。
7.2.2 選擇合適的 GC 算法
根據應用場景選擇適合的垃圾收集器:
- 低延遲:適合實時性要求高的應用,推薦 G1 或 ZGC。
- 高吞吐:適合批處理或后臺任務,推薦 Parallel GC。
- 小內存:推薦 Serial GC。
7.2.3 調整堆內存設置
設置初始堆大小(-Xms
)和最大堆大小(-Xmx
),建議兩者設置為相同值,以避免堆的動態擴展帶來的性能開銷。同時,設置新生代和老年代的比例(-XX:NewRatio
)。
7.2.4 監控與調試
通過日志分析 GC 情況(-XX:+PrintGCDetails
,-Xlog:gc
),調整參數并觀察改動效果。
7.2.5 線上驗證
將調優后的參數部署到測試環境或線上小流量環境,逐步驗證。
7.3 具體調優案例
7.3.1 GC 頻繁導致性能下降
- 現象:應用響應變慢,查看 GC 日志發現每秒觸發多次 Minor GC。
- 解決方案:檢查堆大小配置,增加堆內存大小;調整新生代大小,增加新生代空間比例;替換 GC 算法,如從 Parallel GC 切換到 G1 GC。
7.3.2 內存泄漏導致 OOM
- 現象:應用運行一段時間后崩潰,報
OutOfMemoryError
。 - 解決方案:使用 VisualVM 或 MAT 分析堆轉儲文件,定位泄漏的根本原因,修復代碼問題,如未關閉的資源或緩存問題;配置 OOM 日志輸出。
7.3.3 延遲過高,Full GC 頻繁
- 現象:響應延遲明顯增大,GC 日志顯示 Full GC 頻繁,每次暫停時間較長。
- 解決方案:減少老年代內存占用,增大新生代空間,減少對象進入老年代;啟用 CMS 或 G1 GC;調整 G1 的暫停時間目標;降低 GC 觸發頻率,調整老年代的使用閾值。
7.3.4 CPU 占用過高
- 現象:應用 CPU 使用率長期居高不下,GC 日志顯示 GC 時間占比過高。
- 解決方案:檢查 GC 算法,切換到低 CPU 占用的 GC(如 G1 或 ZGC);優化代碼邏輯,檢查熱點方法,優化高頻調用代碼或使用緩存;限制 GC 線程數。
八、JVM 監控工具
8.1 內置工具
8.1.1 jps(Java Virtual Machine Process Status Tool)
用于列出當前用戶的所有 Java 進程及其 PID。常用命令如 jps -l
顯示主類全名,jps -v
顯示 JVM 啟動參數。
8.1.2 jstat(JVM Statistics Monitoring Tool)
實時監控 JVM 內存、GC、類加載等狀態。例如,jstat -gcutil <pid> 1000
可以每秒輸出堆各區域的使用率。
8.1.3 jmap(Memory Map Tool)
生成堆轉儲文件(Heap Dump)或查看堆內存對象分布。常用命令如 jmap -heap <pid>
查看堆內存使用詳情,jmap -histo:live <pid>
進行直方圖統計對象數量。
8.1.4 jstack(Stack Trace Tool)
生成線程快照,用于分析線程死鎖或高 CPU 問題。例如,jstack <pid> > thread_dump.txt
可以生成線程快照并保存到文件中。
8.1.5 jcmd(JVM Command Tool)
多功能工具,支持 GC、類加載、線程等診斷。例如,jcmd <pid> VM.flags
可以查看 JVM 參數,jcmd <pid> GC.heap_info
可以查看堆內存信息。
8.2 圖形化工具
8.2.1 JConsole
JDK 自帶的圖形化監控工具,支持內存、線程、類加載、MBean 等監控。使用時,在命令行中輸入 jconsole
,然后選擇要監控的 JVM 進程即可。
8.2.2 VisualVM
功能強大的圖形化工具,支持堆轉儲分析、線程快照、CPU/內存采樣、插件擴展等。可以實時監控堆、線程、CPU 等信息,支持插件(如 BTrace)。
8.2.3 Java Mission Control(JMC)
Oracle 提供的商業級監控工具(JDK7u40 + 自帶),支持低開銷的性能分析和事件記錄。核心特性包括 Flight Recorder 記錄 JVM 運行事件,以及提供詳細的內存、線程、GC 分析可視化報告,適用于生產環境性能診斷。
8.3 第三方工具
8.3.1 Arthas(阿里開源)
在線診斷工具,支持動態監控、熱更新、方法調用追蹤等。例如,dashboard
可以實時監控面板,thread -n 3
可以查看最忙的 3 個線程。
8.3.2 JProfiler
商業級性能分析工具,支持內存泄漏檢測、CPU 熱點分析、線程監控等。可視化能力強,可用于內存泄漏、線程爭用分析。
8.3.3 IntelliJ IDEA 內置 Profiler
提供 CPU 和內存分析,如熱點圖、調用樹、方法列表、時間軸等多種視圖,幫助開發者快速定位性能瓶頸。無需安裝額外插件,直接在 IDE 中啟動分析,適合輕量級的性能分析需求,尤其是在開發過程中快速定位問題。
8.3.4 MAT(Eclipse Memory Analyzer)
堆轉儲文件分析工具,用于定位內存泄漏和大對象。可以通過 Dominator Tree 顯示占用內存最多的對象,以及自動分析內存泄漏嫌疑點。
8.4 日志分析工具
8.4.1 GCViewer
分析 GC 日志的可視化工具,可用于優化 GC 性能。
8.4.2 ELK Stack(Elasticsearch + Logstash + Kibana)
集中化日志管理與分析,可用于生產環境日志監控。
綜上所述,JVM 是 Java 程序運行的核心,深入理解 JVM 的各個知識點,包括運行時數據區域、垃圾收集器、類加載機制、字節碼執行引擎等,對于 Java 開發者來說至關重要。同時,掌握 JVM 常見問題的排查和調優方法,以及合理使用監控工具,能夠幫助我們提高 Java 應用的性能和穩定性。"