蒼穹之邊,浩瀚之摯,眰恦之美; 悟心悟性,善始善終,惟善惟道! —— 朝槿《朝槿兮年說》 寫在開頭 這些年,隨著CPU、記憶體、I/O 設備都在不斷迭代,不斷朝著更快的方向努力。在這個快速發展的過程中,有一個核心矛盾一直存在,就是這三者的速度差異。CPU 和記憶體的速度差異可以形象地描述為:CPU 是天 ...
蒼穹之邊,浩瀚之摯,眰恦之美; 悟心悟性,善始善終,惟善惟道! —— 朝槿《朝槿兮年說》
寫在開頭
這些年,隨著CPU、記憶體、I/O 設備都在不斷迭代,不斷朝著更快的方向努力。在這個快速發展的過程中,有一個核心矛盾一直存在,就是這三者的速度差異。CPU 和記憶體的速度差異可以形象地描述為:CPU 是天上一天,記憶體是地上一年(假設 CPU 執行一條普通指令需要一天,那麼 CPU 讀寫記憶體得等待一年的時間)。記憶體和 I/O 設備的速度差異就更大了,記憶體是天上一天,I/O 設備是地上十年。
我們都知道的是,程式里大部分語句都要訪問記憶體,有些還要訪問 I/O,根據木桶理論(一隻水桶能裝多少水取決於它最短的那塊木板),程式整體的性能取決於最慢的操作——讀寫 I/O 設備,也就是說單方面提高 CPU 性能是無效的。
為了合理利用 CPU 的高性能,平衡這三者的速度差異,電腦體繫結構、操作系統、編譯程式都做出了貢獻,主要體現為:
- 現代電腦在CPU 增加了緩存,以均衡與記憶體的速度差異
- 操作系統增加了進程、線程,以分時復用 CPU,進而均衡 CPU 與 I/O 設備的速度差異
- 編譯程式優化指令執行次序,使得緩存能夠得到更加合理地利用
由此可見,雖然現在我們幾乎所有的程式都默默地享受著這些成果,但是實際應用程式設計和開發過程中,還是有很多詭異問題困擾著我們。
基本概述
每當提起Java性能優化,你是否有想過,真正需要我們優化的是什麼?或者說,指導我們優化的方向和目標是否明確?甚至說,我們所做的一切,是否已經達到我們的期望了呢?接下來,我們來詳細探討一下。
性能優化根據優化的方向和目標來說,大致可以分為業務優化和技術優化。業務優化產生的影響是非常巨大的,一般最常見的就是業務需求變更和業務場景適配等,當然這是產品和項目管理的工作範疇。而對於我們開發人員來說,我們需要關註的和直接與我們相關的,主要是通過一系列的技術手段,來完成我們對既定目標的技術優化。其中,從技術手段方向來看,技術優化主要可以從復用優化,結果集合優化,高效實現優化,演算法優化,計算優化,資源衝突優化和JVM優化等七個方面著手。
一般來說,技術優化基本都集中在電腦資源和存儲資源的規划上,最直接的就是對於伺服器和業務應用程式相關的資源做具體的分析,在照顧性能的前提下,同時也兼顧業務需求的要求,從而達到資源利用最優的狀態。一味地強調利用空間換時間的方式,只看計算速度,不考慮複雜性和空間的問題,確實有點不可取。特別是在雲原生時代下和無服務時代,雖然模糊和減少了開發對這些問題的距離,但是我們更加需要瞭解和關註這些問題的實質。
特別指出的是,JVM優化。由於使用Java編寫的應用程式,本身Java是運行在JVM虛擬機上的,這就意味著它會受到JVM的制約。對於JVM虛擬機的優化。一定程度上會提升Java應用程式的性能。如果參數配置不當,導致記憶體溢出(OOM異常)等問題,甚至引發比這更嚴重的後果。
由此可見,正確認識和掌握JVM結構相關知識,對於我們何嘗不是一個進階的技術方向。當然,JVM虛擬機這一部分的內容,相對編寫Java程式來說,更加比較枯燥無味,概念比較多且抽象,需要我們要有更多的耐心和細心。我們都知道,一顆不浮躁的心,做任何事都會收穫不一樣的精彩。
Java JVM虛擬機
在開始這一部分內容之前,我們先來看一下,在Java中,Java程式是如何運行的,最後又是如何交給JVM托管的?
1.Java 程式運行過程
作為一名 Java 程式員,你應該知道,Java 代碼有很多種不同的運行方式。比如說可以在開發工具中運行,可以雙擊執行 jar 文件運行,也可以在命令行中運行,甚至可以在網頁中運行。當然,這些執行方式都離不開 JRE,也就是 Java 運行時環境。
實際上,JRE 僅包含運行 Java 程式的必需組件,包括 Java 虛擬機以及 Java 核心類庫等。我們 Java 程式員經常接觸到的 JDK(Java 開發工具包)同樣包含了 JRE,並且還附帶了一系列開發、診斷工具。
然而,運行 C++ 代碼則無需額外的運行時。我們往往把這些代碼直接編譯成 CPU 所能理解的代碼格式,也就是機器碼。
Java 作為一門高級程式語言,它的語法非常複雜,抽象程度也很高。因此,直接在硬體上運行這種複雜的程式並不現實。所以呢,在運行 Java 程式之前,我們需要對其進行一番轉換。
這個轉換具體是怎麼操作的呢?當前的主流思路是這樣子的,設計一個面向 Java 語言特性的虛擬機,並通過編譯器將 Java 程式轉換成該虛擬機所能識別的指令序列,也稱 Java 位元組碼。這裡順便說一句,之所以這麼取名,是因為 Java 位元組碼指令的操作碼(opcode)被固定為一個位元組。
並且,我們同樣可以將其反彙編為人類可讀的代碼格式(如下圖的最右列所示)。不同的是,Java 版本的編譯結果相對精簡一些。這是因為 Java 虛擬機相對於物理機而言,抽象程度更高。
Java 虛擬機可以由硬體實現[1],但更為常見的是在各個現有平臺(如 Windows_x64、Linux_aarch64)上提供軟體實現。這麼做的意義在於,一旦一個程式被轉換成 Java 位元組碼,那麼它便可以在不同平臺上的虛擬機實現里運行。這也就是我們經常說的“一次編寫,到處運行”。
虛擬機的另外一個好處是它帶來了一個托管環境(Managed Runtime)。這個托管環境能夠代替我們處理一些代碼中冗長而且容易出錯的部分。其中最廣為人知的當屬自動記憶體管理與垃圾回收,這部分內容甚至催生了一波垃圾回收調優的業務。
除此之外,托管環境還提供了諸如數組越界、動態類型、安全許可權等等的動態檢測,使我們免於書寫這些無關業務邏輯的代碼。
2.Java 程式創建過程
從 class 文件到記憶體中的類,按先後順序需要經過載入、鏈接以及初始化三大步驟。其中,鏈接過程中同樣需要驗證;而記憶體中的類沒有經過初始化,同樣不能使用。那麼,是否所有的 Java 類都需要經過這幾步呢?
我們知道 Java 語言的類型可以分為兩大類:基本類型(primitive types)和引用類型(reference types)。在上一篇中,我已經詳細介紹過了 Java 的基本類型,它們是由 Java 虛擬機預先定義好的。
至於另一大類引用類型,Java 將其細分為四種:類、介面、數組類和泛型參數。由於泛型參數會在編譯過程中被擦除(我會在專欄的第二部分詳細介紹),因此 Java 虛擬機實際上只有前三種。在類、介面和數組類中,數組類是由 Java 虛擬機直接生成的,其他兩種則有對應的位元組流。
說到位元組流,最常見的形式要屬由 Java 編譯器生成的 class 文件。除此之外,我們也可以在程式內部直接生成,或者從網路中獲取(例如網頁中內嵌的小程式 Java applet)位元組流。這些不同形式的位元組流,都會被載入到 Java 虛擬機中,成為類或介面。為了敘述方便,下麵我就用“類”來統稱它們。
無論是直接生成的數組類,還是載入的類,Java 虛擬機都需要對其進行鏈接和初始化。
其實,Java 虛擬機將位元組流轉化為 Java 類的過程,就是我們常說的Java類的創建過程。這個過程可分為載入、鏈接以及初始化三大步驟:
- 載入是指查找位元組流,並且據此創建類的過程。載入需要藉助類載入器,在 Java 虛擬機中,類載入器使用了雙親委派模型,即接收到載入請求時,會先將請求轉發給父類載入器。
- 鏈接,是指將創建成的類合併至 Java 虛擬機中,使之能夠執行的過程。鏈接還分驗證、準備和解析三個階段。其中,解析階段為非必須的。
- 初始化,則是為標記為常量值的欄位賦值,以及執行 < clinit > 方法的過程。類的初始化僅會被執行一次,這個特性被用來實現單例的延遲初始化。
3.Java 程式載入過程
從虛擬機視角來看,執行 Java 代碼首先需要將它編譯而成的 class 文件載入到 Java 虛擬機中。載入後的 Java 類會被存放於方法區(Method Area)中。實際運行時,虛擬機會執行方法區內的代碼。
如果你熟悉 X86 的話,你會發現這和段式記憶體管理中的代碼段類似。而且,Java 虛擬機同樣也在記憶體中劃分出堆和棧來存儲運行時數據。
不同的是,Java 虛擬機會將棧細分為面向 Java 方法的 Java 方法棧,面向本地方法(用 C++ 寫的 native 方法)的本地方法棧,以及存放各個線程執行位置的 PC 寄存器。
在運行過程中,每當調用進入一個 Java 方法,Java 虛擬機會在當前線程的 Java 方法棧中生成一個棧幀,用以存放局部變數以及位元組碼的操作數。這個棧幀的大小是提前計算好的,而且 Java 虛擬機不要求棧幀在記憶體空間里連續分佈。
當退出當前執行的方法時,不管是正常返回還是異常返回,Java 虛擬機均會彈出當前線程的當前棧幀,並將之捨棄。
從硬體視角來看,Java 位元組碼無法直接執行。因此,Java 虛擬機需要將位元組碼翻譯成機器碼。
啟動類載入器是由 C++ 實現的,沒有對應的 Java 對象,因此在 Java 中只能用 null 來指代。
除了啟動類載入器之外,其他的類載入器都是 java.lang.ClassLoader 的子類,因此有對應的 Java 對象。這些類載入器需要先由另一個類載入器,比如說啟動類載入器,載入至 Java 虛擬機中,方能執行類載入。
在 Java 虛擬機中,這個潛規則有個特別的名字,叫雙親委派模型。每當一個類載入器接收到載入請求時,它會先將請求轉發給父類載入器。在父類載入器沒有找到所請求的類的情況下,該類載入器才會嘗試去載入。
在 Java 9 之前,啟動類載入器負責載入最為基礎、最為重要的類,比如存放在 JRE 的 lib 目錄下 jar 包中的類(以及由虛擬機參數 -Xbootclasspath 指定的類)。除了啟動類載入器之外,另外兩個重要的類載入器是擴展類載入器(extension class loader)和應用類載入器(application class loader),均由 Java 核心類庫提供。
擴展類載入器的父類載入器是啟動類載入器。它負責載入相對次要、但又通用的類,比如存放在 JRE 的 lib/ext 目錄下 jar 包中的類(以及由系統變數 java.ext.dirs 指定的類)。
應用類載入器的父類載入器則是擴展類載入器。它負責載入應用程式路徑下的類。(這裡的應用程式路徑,便是指虛擬機參數 -cp/-classpath、系統變數 java.class.path 或環境變數 CLASSPATH 所指定的路徑。)預設情況下,應用程式中包含的類便是由應用類載入器載入的。
Java 9 引入了模塊系統,並且略微更改了上述的類載入器1。擴展類載入器被改名為平臺類載入器(platform class loader)。Java SE 中除了少數幾個關鍵模塊,比如說 java.base 是由啟動類載入器載入之外,其他的模塊均由平臺類載入器所載入。
除了由 Java 核心類庫提供的類載入器外,我們還可以加入自定義的類載入器,來實現特殊的載入方式。舉例來說,我們可以對 class 文件進行加密,載入時再利用自定義的類載入器對其解密。
除了載入功能之外,類載入器還提供了命名空間的作用。在 Java 虛擬機中,類的唯一性是由類載入器實例以及類的全名一同確定的。即便是同一串位元組流,經由不同的類載入器載入,也會得到兩個不同的類。在大型應用中,我們往往藉助這一特性,來運行同一個類的不同版本。
4.Java 程式編譯過程
在 HotSpot 裡面,上述翻譯過程有兩種形式:
- 第一種是解釋執行,即逐條將位元組碼翻譯成機器碼並執行;
- 第二種是即時編譯(Just-In-Time compilation,JIT),即將一個方法中包含的所有位元組碼編譯成機器碼後再執行。
前者的優勢在於無需等待編譯,而後者的優勢在於實際運行速度更快。HotSpot 預設採用混合模式,綜合瞭解釋執行和即時編譯兩者的優點。它會先解釋執行位元組碼,而後將其中反覆執行的熱點代碼,以方法為單位進行即時編譯。
HotSpot 採用了多種技術來提升啟動性能以及峰值性能,剛剛提到的即時編譯便是其中最重要的技術之一。
即時編譯建立在程式符合二八定律的假設上,也就是百分之二十的代碼占據了百分之八十的計算資源。
對於占據大部分的不常用的代碼,我們無需耗費時間將其編譯成機器碼,而是採取解釋執行的方式運行;另一方面,對於僅占據小部分的熱點代碼,我們則可以將其編譯成機器碼,以達到理想的運行速度。
理論上講,即時編譯後的 Java 程式的執行效率,是可能超過 C++ 程式的。這是因為與靜態編譯相比,即時編譯擁有程式的運行時信息,並且能夠根據這個信息做出相應的優化。
舉個例子,我們知道虛方法是用來實現面向對象語言多態性的。對於一個虛方法調用,儘管它有很多個目標方法,但在實際運行過程中它可能只調用其中的一個。這個信息便可以被即時編譯器所利用,來規避虛方法調用的開銷,從而達到比靜態編譯的 C++ 程式更高的性能。
為了滿足不同用戶場景的需要,HotSpot 內置了多個即時編譯器:C1、C2 和 Graal。
- Graal 是 Java 10 正式引入的實驗性即時編譯器,在專欄的第四部分我會詳細介紹,這裡暫不做討論。之所以引入多個即時編譯器,是為了在編譯時間和生成代碼的執行效率之間進行取捨。
- C1 又叫做 Client 編譯器,面向的是對啟動性能有要求的客戶端 GUI 程式,採用的優化手段相對簡單,因此編譯時間較短。
- C2 又叫做 Server 編譯器,面向的是對峰值性能有要求的伺服器端程式,採用的優化手段相對複雜,因此編譯時間較長,但同時生成代碼的執行效率較高。
從 Java 7 開始,HotSpot 預設採用分層編譯的方式:熱點方法首先會被 C1 編譯,而後熱點方法中的熱點會進一步被 C2 編譯。
為了不幹擾應用的正常運行,HotSpot 的即時編譯是放在額外的編譯線程中進行的。HotSpot 會根據 CPU 的數量設置編譯線程的數目,並且按 1:2 的比例配置給 C1 及 C2 編譯器。
在計算資源充足的情況下,位元組碼的解釋執行和即時編譯可同時進行。編譯完成後的機器碼會在下次調用該方法時啟用,以替換原本的解釋執行。
5.Java 虛擬機結構
從組成結構上看,一個Java 虛擬機(HotSpot 為例),主要包括指令集合,指令解析器,程式執行指令 等3個方面,其中:
- 指令集合:指的是我們常說的位元組碼(Byte Code),主要指將源文件代碼(Source File Code) 編譯運行生成的,比如在Java中是通過javac命令編譯(.java)文件生成,而在Python中是通過jython命令來編譯(.py)文件生成。
- 指令解析器:主要是指位元組碼解釋器(Byte Code Interpreter)和即時編譯器(JIT Compiler),比如一個Java 虛擬機(HotSpot 為例),就有一個位元組碼解釋器和兩個即時編譯器(Server編譯器和Client 編譯器)。
- 程式執行指令: 主要是指操作記憶體區域,以裝載和執行,一般是JVM負責 將 位元組碼 解釋成具體的機器指令來執行。
一般來說,任何一個Java虛擬機都會包含這三個方面的,但是具體的有各有所不同:
- 位元組碼指令:JVM 具有針對以下任務組的位元組碼指令規範:載入和存儲,算術,類型轉換,對象創建和操作,操作數棧管理(push/pop),控制轉移(分支),方法調用和返回,拋出異常,基於監視器的併發。被載入到JVM後可以被執行,其中位元組碼是實現跨平臺的基礎。
- 位元組碼解釋器:用於將位元組碼解析成電腦能執行的語言,一臺電腦有了 Java 位元組碼解釋器後,它就可以運行任何 Java 位元組碼程式。同樣的 Java 程式就可以在具有了這種解釋器的硬體架構的電腦上運行,實現了“跨平臺”。
- JIT即時編譯器:JIT 編譯器可以在執行程式時將 Java 位元組碼翻譯成本地機器語言。一般來講,Java 位元組碼經過 位元組碼解釋器執行時,執行速度總是比編譯成本地機器語言的同一程式的執行速度慢。而 即時編譯器 在執行程式時將 Java 位元組碼翻譯成本地機器語言,以顯著加快整體執行時間。
- JVM 操作記憶體:JVM 有一個堆( heap )用於存儲對象和數組。垃圾回收器要在這裡工作。代碼、常量和其他類數據存儲在方法區( method area )中。每個 JVM 線程也有自己的調用棧( JVM stack ),用於存儲 “幀”。每次調用方法時都會創建一個新的 幀(放到棧里),併在該方法退出時銷毀該幀。每個幀提供一個操作數堆棧 ( operand stack)和一個局部變數數組 ( local variables )。操作數棧用於計算操作數和接收被調用方法的 "返回值",而局部變數數據用於傳遞“方法參數”。
除此之外,每個特定的主機操作系統都需要自己的 JVM 和運行時實現。
6.Java GC垃圾回收
Java 虛擬機提供了一系列的垃圾回收機制(Garbage Collection),又或者說是垃圾回收器(Garbage Collector),其中常見的垃圾回收器如下:
- Serial GC(Serial Garbage Collection):第一代GC,是1999年在JDK1.3中發佈的串列方式的單線程GC。一般適用於 最小化地使用記憶體和並行開銷的場景。
- Parallel GC(Parallel Garbage Collection):第二代GC,是2002年在JDK1.4.2中發佈的,相比Serial GC,基於多線程方式加速運行垃圾回收,在JDK6版本之後成為Hotspot VM的預設GC。一般是最大化應用程式的吞吐量。
- CMS GC(Concurrent Mark Sweep Garbage Collection ):第二代GC,是2002年在JDK1.4.2中發佈的,相比Serial GC,基於多線程方式加速運行垃圾回收,可以讓應用程式和GC分享處理器資源的GC。一般是最小化GC的中斷和停頓時間的場景。
- G1 GC (Garbage First Garbage Collection):第三代GC,是JDK7版本中誕生的一個並行回收器,主要是針對“垃圾優先”的原則而誕生的GC,也是時下我們比較新的GC。
在常見的垃圾回收中,我們一般採用引用計數法和可達性分析兩種方式來確定垃圾是否產生,其中:
- 引用計數法:在Java中,引用和對象是有關聯的。如果要操作對象則必須用引用進行。因此,很顯然一個簡單的辦法是通過引用計數來判斷一個對象是否可以回收。簡單說,即一個對象如果沒有任何與之關聯的引用,即他們的引用計數都不為0,則說明對象不太可能再被用到,那麼這個對象就是可回收對象。
- 可達性分析(根搜索演算法):為瞭解決引用計數法的迴圈引用問題,Java使用了可達性分析的方法。通過一系列的“GC roots”對象作為起點搜索。如果在“GC roots”和一個對象之間沒有可達路徑,則稱該對象是不可達的。要註意的是,不可達對象不等價於可回收對象,不可達對象變為可回收對象至少要經過兩次標記過程。兩次標記後仍然是可回收對象,則將面臨回收。
一般來說,當成功區分出記憶體中存活對象和死亡對象之後,GC接著就會執行垃圾回收,釋放掉無用對象所占用的記憶體空間,以便有足夠可用的記憶體空間為新的對象分配記憶體。
目前,在JVM中採用的垃圾收集演算法主要有:
- 標記-清除演算法(Mark-Sweep ): 最基礎的垃圾回收演算法,分為兩個階段,標註和清除。標記階段標記出所有需要回收的對象,清除階段回收被標記的對象所占用的空間。該演算法最大的問題是記憶體碎片化嚴重,後續可能發生大對象不能找到可利用空間的問題。
- 複製演算法(Copying): 為瞭解決Mark-Sweep演算法記憶體碎片化的缺陷而被提出的演算法。按記憶體容量將記憶體劃分為等大小的兩塊。每次只使用其中一塊,當這一塊記憶體滿後將尚存活的對象複製到另一塊上去,把已使用的記憶體清掉。這種演算法雖然實現簡單,記憶體效率高,不易產生碎片,但是最大的問題是可用記憶體被壓縮到了原本的一半。且存活對象增多的話,Copying演算法的效率會大大降低。
- 標記-壓縮演算法(Mark-Compact): 為了避免缺陷而提出。標記階段和Mark-Sweep演算法相同,標記後不是清理對象,而是將存活對象移向記憶體的一端,然後清除端邊界外的對象。
- 增量演算法(Incremental Collecting): 也可以成為分區收集演算法(Region Collenting),將整個堆空間劃分為連續的不同小區間, 每個小區間獨立使用, 獨立回收. 這樣做的好處是可以控制一次回收多少個小區間 , 根據目標停頓時間, 每次合理地回收若幹個小區間(而不是整個堆), 從而減少一次GC所產生的停頓。
- 分代收集演算法(Generational Collenting): 是目前大部分JVM所採用的方法,其核心思想是根據對象存活的不同生命周期將記憶體劃分為不同的域,一般情況下將GC堆劃分為老生代(Tenured/Old Generation)和新生代(Young Generation)。老生代的特點是每次垃圾回收時只有少量對象需要被回收,新生代的特點是每次垃圾回收時都有大量垃圾需要被回收,因此可以根據不同區域選擇不同的演算法。
7.Java JVM 調優
JVM調優涉及到兩個很重要的概念:吞吐量和響應時間。jvm調優主要是針對他們進行調整優化,達到一個理想的目標,根據業務確定目標是吞吐量優先還是響應時間優先。
- 吞吐量:用戶代碼執行時間/(用戶代碼執行時間+GC執行時間)。
- 響應時間:整個介面的響應時間(用戶代碼執行時間+GC執行時間),stw時間越短,響應時間越短。
調優的前提是熟悉業務場景,先判斷出當前業務場景是吞吐量優先還是響應時間優先。調優需要建立在監控之上,由壓力測試來判斷是否達到業務要求和性能要求。 調優的步驟大致可以分為:
-
熟悉業務場景,瞭解當前業務系統的要求,是吞吐量優先還是響應時間優先;
-
選擇合適的垃圾回收器組合,如果是吞吐量優先,則選擇ps+po組合;如果是響應時間優先,在1.8以後選擇G1,在1.8之前選擇ParNew+CMS組合;
-
規劃記憶體需求,只能進行大致的規劃。
-
CPU選擇,在預算之內性能越高越好;
-
根據實際情況設置升級年齡,最大年齡為15;
-
根據需要設定相關的JVM日誌參數:
-Xloggc:/path/name-gc-%t.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogs=5 -XX:GCLogFileSize=20M -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCCauses
其中需要註意的是:
-XX:+UseGCLogFileRotation:GC文件迴圈使用 -XX:NumberOfGCLogs=5:使用5個GC文件 -XX:GCLogFileSize=20M:每個GC文件的大小
上面這三個參數放在一起代表的含義是:5個GC文件迴圈使用,每個GC文件20M,總共使用100M存儲日誌文件,當5個GC文件都使用完畢以後,覆蓋第一個GC日誌文件,生成新的GC文件。
當cpu經常飆升到100%的使用率,那麼證明有線程長時間占用系統資源不進行釋放,需要定位到具體是哪個線程在占用,定位問題的步驟如下(linux系統):
1.使用top命令常看當前伺服器中所有進程(jps命令可以查看當前伺服器運行java進程),找到當前cpu使用率最高的進程,獲取到對應的pid;
2.然後使用top -Hp pid,查看該進程中的各個線程信息的cpu使用,找到占用cpu高的線程pid
3.使用jstack pid列印它的線程信息,需要註意的是,通過jstack命令列印的線程號和通過top -Hp列印的線程號進位不一樣,需要進行轉換才能進行匹配,jstack中的線程號為16進位,而top -Hp列印的是10進位。
當記憶體飆高一般都是堆中對象無法回收造成,因為java中的對象大部分存儲在堆記憶體中。其實也就是常見的oom問題(Out Of Memory),一般:
1.jinfo pid,可以查看當前進行虛擬機的相關信息列舉出來
2.jstat -gc pid ms,多長毫秒列印一次gc信息,列印信息如下,裡面包含gc測試,年輕代/老年帶gc信息等
3. jmap -histo pid | head -20,查找當前進程堆中的對象信息,加上管道符後面的信息以後,代表查詢對象數量最多的20個
4. jmap -dump:format=b,file=xxx pid,可以生成堆信息的文件,但是這個命令不建議在生產環境使用,因為當記憶體較大時,執行該命令會占用大量系統資源,甚至造成卡頓。建議在項目啟動時添加下麵的命令,在發生oom時自動生成堆信息文件:-XX:+HeapDumpOnOutOfMemory。如果需要線上上進行堆信息分析,如果當前服務存在多個節點,可以下線一個節點,生成堆信息,或者使用第三方工具,阿裡的arthas。
除此之外,我們還可以使用 jvisualvm是jdk自帶的圖形化分析工具,可以對運行進程的線程,堆進行詳細分析。但是這種分析工具可以對本地代碼或者測試環境進行監控分析,不建議線上上環境使用該工具,因為它會占用系統資源。如果必須要線上上執行,建議當前服務存在多個節點,然後下線其中一個節點進行問題分析。也可以使用第三方收費的圖形分析界面jprofiler。
⚠️[註意事項] :
在日常JVM調優常用參數主要如下:
- 通用GC常用參數:
-Xmn:年輕代大小
-Xms:堆初始大小
-Xmx:堆最大大小
-Xss:棧大小
-XX:+UseTlab:使用tlab,預設打開,涉及到對象分配問題
-XX:+PrintTlab:列印tlab使用情況
-XX:+TlabSize:設置Tlab大小
-XX:+DisabledExplictGC:java代碼中的System.gc()不再生效,防止代碼中誤寫,導致頻繁觸動GC,預設不起用。
-XX:+PrintGC(+PrintGCDetails/+PrintGCTimeStamps) : 列印GC信息(列印GC詳細信息/列印GC執行時間)
-XX:+PrintHeapAtGC列印GC時的堆信息
-XX:+PrintGCApplicationConcurrentTime: 列印應用程式的時間
-XX:+PrintGCApplicationStopedTime: 列印應用程式暫停時間
-XX:+PrintReferenceGC: 列印回收多少種引用類型的引用
-verboss:class : 類載入詳細過程
-XX:+PrintVMOptions : 列印JVM運行參數
-XX:+PrintFlagsFinal(+PrintFlagsInitial) -version | grep : 查找想要瞭解的命令
-X:loggc:/opt/gc/log/path : 輸出gc信息到文件
-XX:MaxTenuringThreshold : 設置gc升到年齡,最大值為15 - Parallel GC 常用參數:
-XX:PreTenureSizeThreshold 多大的對象判定為大對象,直接晉升老年代
-XX:+ParallelGCThreads 用於併發垃圾回收的線程
-XX:+UseAdaptiveSizePolicy 自動選擇各區比例 - CMS GC 常用參數:
-XX:+UseConcMarkSweepGC :使用CMS垃圾回收器
-XX:parallelCMSThreads : CMS線程數量
-XX:CMSInitiatingOccupancyFraction : 占用多少比例的老年代時開始CMS回收,預設值68%,如果頻繁發生serial old,適當調小該比例,降低FGC頻率
-XX:+UseCMSCompactAtFullCollection : 進行壓縮整理
-XX:CMSFullGCBeforeCompaction :多少次FGC以後進行壓縮整理
-XX:+CMSClassUnloadingEnabled :回收永久代
-XX:+CMSInitiatingPermOccupancyFraction :達到什麼比例時進行永久代回收
-XX:GCTimeTatio : 設置GC時間占用程式運行時間的百分比,該參數只能是儘量達到該百分比,不是肯定達到
-XX:MaxGCPauseMills : GCt停頓時間,該參數也是儘量達到,而不是肯定達到 - G1 GC 常用參數:
-XX:+UseG1 : 使用G1垃圾回收器
-XX:MaxGCPauseMills : GCt停頓時間,該參數也是儘量達到,G1會調整yong區的塊數來達到這個值
-XX:+G1HeapRegionSize : 分區大小,範圍為1M~32M,必須是2的n次冪,size越大,GC回收間隔越大,但是GC所用時間越長
JVM 記憶體區域
在Java虛擬機中,JVM 記憶體區域主要分為線程私有、線程共用、直接記憶體三個區域,具體詳情如下:
- 線程私有(Theard Local Region): 數據區域生命周期與線程相同, 依賴用戶線程的啟動/結束 而 創建/銷毀(在Hotspot VM內, 每個線程都與操作系統的本地線程直接映射, 因此這部分記憶體區域的存/否跟隨本地線程的生/死對應)。
- 線程共用(Theard Shared Region): 隨虛擬機的啟動/關閉而創建/銷毀
- 直接記憶體(Direct Memory) : 非Java 虛擬機中JVM運行時數據區的一部分, 但也會被頻繁的使用: 在JDK 1.4引入的NIO提供了基於Channel與Buffer的IO方式, 它可以使用Native函數庫直接分配堆外記憶體, 然後使用DirectByteBuffer對象作為這塊記憶體的引用進行操作(詳見: Java I/O 擴展), 這樣就避免了在Java堆和Native堆中來回覆制數據, 因此在一些場景中可以顯著提高性能
由此可見,在Java 虛擬機JVM運行時數據區中,【程式計數器、虛擬機棧、本地方法區】屬於線程私有區域,【 JAVA 堆、方法區】屬於線程共用區域,都需要JVM GC管理的,而直接記憶體不受JVM GC管理的。
首先,對於線程私有區域中的【程式計數器、虛擬機棧、本地方法區】,主要詳情如下:
- 程式計數器:一塊較小的記憶體空間, 是當前線程所執行的位元組碼的行號指示器,每條線程都要有一個獨立的程式計數器,這類記憶體也稱為“線程私有”的記憶體。正在執行java方法的話,計數器記錄的是虛擬機位元組碼指令的地址(當前指令的地址)。如果還是Native方法,則為空。這個記憶體區域是唯一一個在虛擬機中沒有規定任何OutOfMemoryError情況的區域。
- 虛擬機棧:是描述java方法執行的記憶體模型,每個方法在執行的同時都會創建一個棧幀(Stack Frame)用於存儲局部變數表、操作數棧、動態鏈接、方法出口等信息。每一個方法從調用直至執行完成的過程,就對應著一個棧幀在虛擬機棧中入棧到出棧的過程。棧幀( Frame)是用來存儲數據和部分過程結果的數據結構,同時也被用來處理動態鏈接 (Dynamic Linking)、 方法返回值和異常分派( Dispatch Exception)。棧幀隨著方法調用而創建,隨著方法結束而銷毀——無論方法是正常完成還是異常完成(拋出了在方法內未被捕獲的異常)都算作方法結束。
- 本地方法區:本地方法區和Java Stack作用類似, 區別是虛擬機棧為執行Java方法服務, 而本地方法棧則為Native方法服務, 如果一個VM實現使用C-linkage模型來支持Native調用, 那麼該棧將會是一個C棧,但HotSpot VM直接就把本地方法棧和虛擬機棧合二為一。
其次,對於線程共用區域中的【 JAVA 堆、方法區】,主要詳情如下:
- Java 堆(Java Heap): 是Java 虛擬機JVM運行時數據區中,被線程共用的一塊記憶體區域,創建的對象和數組都保存在Java堆記憶體中,也是垃圾收集器進行垃圾收集的最重要的記憶體區域。由於現代VM採用分代收集演算法, 因此Java堆從GC的角度還可以細分為: 新生代(Eden區、From Survivor區和To Survivor區)和老年代。
- 方法區(Method Area)/永久代(Permanent Generation):我們常說的永久代, 用於存儲被JVM載入的類信息、常量、靜態變數、即時編譯器編譯後的代碼等數據. HotSpot VM把GC分代收集擴展至方法區, 即使用Java堆的永久代來實現方法區, 這樣HotSpot的垃圾收集器就可以像管理Java堆一樣管理這部分記憶體, 而不必為方法區開發專門的記憶體管理器(永久帶的記憶體回收的主要目標是針對常量池的回收和類型的卸載, 因此收益一般很小)。運行時常量池(Runtime Constant Pool)是方法區的一部分。Class文件中除了有類的版本、欄位、方法、介面等描述等信息外,還有一項信息是常量池(Constant Pool Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類載入後存放到方法區的運行時常量池中。 Java虛擬機對Class文件的每一部分(自然也包括常量池)的格式都有嚴格的規定,每一個位元組用於存儲哪種數據都必須符合規範上的要求,這樣才會被虛擬機認可、裝載和執行。
其中對於Java虛擬機JVM中的Java 堆主要分為【 新生代 、老年代 、永久代、元數據區】:
- 新生代(Young Generation):用來存放新生的對象。一般占據堆的1/3空間。由於頻繁創建對象,所以新生代會頻繁觸發MinorGC進行垃圾回收。新生代又分為 Eden區、ServivorFrom、ServivorTo三個區。
- 老年代(Old Generation):主要存放應用程式中生命周期長的記憶體對象。老年代的對象比較穩定,所以MajorGC不會頻繁執行。在進行MajorGC前一般都先進行了一次MinorGC,使得有新生代的對象晉身入老年代,導致空間不夠用時才觸發。當無法找到足夠大的連續空間分配給新創建的較大對象時也會提前觸發一次MajorGC進行垃圾回收騰出空間。MajorGC採用標記清除演算法:首先掃描一次所有老年代,標記出存活的對象,然後回收沒有標記的對象。MajorGC的耗時比較長,因為要掃描再回收。MajorGC會產生記憶體碎片,為了減少記憶體損耗,我們一般需要進行合併或者標記出來方便下次直接分配。當老年代也滿了裝不下的時候,就會拋出OOM(Out of Memory)異常。
- 永久代(Permanent Generation):指記憶體的永久保存區域,主要存放Class和Meta(元數據)的信息,Class在被載入的時候被放入永久區域,它和和存放實例的區域不同,GC不會在主程式運行期對永久區域進行清理。所以這也導致了永久代的區域會隨著載入的Class的增多而脹滿,最終拋出OOM異常。
- 元數據區(Metaspace): 在Java8中,永久代已經被移除,被一個稱為“元數據區”(元空間)的區域所取代。元空間的本質和永久代類似,元空間與永久代之間最大的區別在於:元空間並不在虛擬機中,而是使用本地記憶體。因此,預設情況下,元空間的大小僅受本地記憶體限制。類的元數據放入 native memory, 字元串池和類的靜態變數放入java堆中,這樣可以載入多少類的元數據就不再由MaxPermSize控制, 而由系統的實際可用空間來控制。
Java 記憶體模型
你已經知道,導致可見性的原因是緩存,導致有序性的原因是編譯優化,那解決可見性、有序性最直接的辦法就是禁用緩存和編譯優化,但是這樣問題雖然解決了,我們程式的性能可就堪憂了。
合理的方案應該是按需禁用緩存以及編譯優化。那麼,如何做到“按需禁用”呢?對於併發程式,何時禁用緩存以及編譯優化只有程式員知道,那所謂“按需禁用”其實就是指按照程式員的要求來禁用。所以,為瞭解決可見性和有序性問題,只需要提供給程式員按需禁用緩存和編譯優化的方法即可。
Java 記憶體模型是個很複雜的規範,可以從不同的視角來解讀,站在我們這些程式員的視角,本質上可以理解為,Java 記憶體模型規範了 JVM 如何提供按需禁用緩存和編譯優化的方法。具體來說,這些方法包括 volatile、synchronized 和 final 三個關鍵字。
Java 的記憶體模型是併發編程領域的一次重要創新,之後 C++、C#、Golang 等高級語言都開始支持記憶體模型。Java 記憶體模型裡面,最晦澀的部分就是 Happens-Before 規則,接下來我們詳細介紹一下。
Happens-Before 規則
在瞭解完Java 記憶體模型之後,我們再來具體學習一下針對於這些問題提出的Happens-Before 規則。如何理解 Happens-Before 呢?如果望文生義(很多網文也都愛按字面意思翻譯成“先行發生”),那就南轅北轍了,Happens-Before 並不是說前面一個操作發生在後續操作的前面,它真正要表達的是:前面一個操作的結果對後續操作是可見的。就像有心靈感應的兩個人,雖然遠隔千里,一個人心之所想,另一個人都看得到。Happens-Before 規則就是要保證線程之間的這種“心靈感應”。所以比較正式的說法是:Happens-Before 約束了編譯器的優化行為,雖允許編譯器優化,但是要求編譯器優化後一定遵守 Happens-Before 規則。
Happens-Before 規則應該是 Java 記憶體模型裡面最晦澀的內容了,和程式員相關的規則一共有如下六項,都是關於可見性的,具體如下:
- 程式的順序性規則:指在一個線程中,按照程式順序,前面的操作 Happens-Before 於後續的任意操作。
- volatile 變數規則:指對一個 volatile 變數的寫操作, Happens-Before 於後續對這個 volatile 變數的讀操作。
- 傳遞性規則:指如果 A Happens-Before B,且 B Happens-Before C,那麼 A Happens-Before C。
- 管程中鎖的規則:指對一個鎖的解鎖 Happens-Before 於後續對這個鎖的加鎖。管程是一種通用的同步原語,在 Java 中指的就是 synchronized,synchronized 是 Java 里對管程的實現。管程中的鎖在 Java 里是隱式實現的,在進入同步塊之前,會自動加鎖,而在代碼塊執行完會自動釋放鎖,加鎖以及釋放鎖都是編譯器幫我們實現的。
- 線程 start() 規則:關於線程啟動的。它是指主線程 A 啟動子線程 B 後,子線程 B 能夠看到主線程在啟動子線程 B 前的操作。換句話說就是,如果線程 A 調用線程 B 的 start() 方法(即線上程 A 中啟動線程 B),那麼該 start() 操作 Happens-Before 於線程 B 中的任意操作。
- 線程 join() 規則:關於線程等待的。它是指主線程 A 等待子線程 B 完成(主線程 A 通過調用子線程 B 的 join() 方法實現),當子線程 B 完成後(主線程 A 中 join() 方法返回),主線程能夠看到子線程的操作。當然所謂的“看到”,指的是對共用變數的操作。換句話說就是,如果線上程 A 中,調用線程 B 的 join() 併成功返回,那麼線程 B 中的任意操作 Happens-Before 於該 join() 操作的返回。
在 Java 語言裡面,Happens-Before 的語義本質上是一種可見性,A Happens-Before B 意味著 A 事件對 B 事件來說是可見的,無論 A 事件和 B 事件是否發生在同一個線程里。例如 A 事件發生線上程 1 上,B 事件發生線上程 2 上,Happens-Before 規則保證線程 2 上也能看到 A 事件的發生。
Java 記憶體模型主要分為兩部分,一部分面向你我這種編寫併發程式的應用開發人員,另一部分是面向 JVM 的實現人員的,我們可以重點關註前者,也就是和編寫併發程式相關的部分,這部分內容的核心就是 Happens-Before 規則。
代碼設計原則
對於一個開發人員來說,瞭解上述知識只是一個開始,更多的是我們在實際工作中如何運用。個人覺得,瞭解一些設計原則,並掌握這些設計原則,才能幫助我們寫出高質量的代碼。
當然,設計原則是代碼設計時的一些經驗總結。最大的一問題就就是:設計原則看起來比較抽象,其定義也比較模糊,不同的人對於同一個設計原則都會有不同的感悟。如果,我們只是單純的抽象記憶這些定義,對於我們編程技術和代碼設計的能力來說,並不會有什麼實質性的幫助。
針對於每一個設計原則,我們需要掌握它能幫助我們解決什麼問題和可以適合什麼樣的應用場景。可以這樣說,設計原則是心法,設計模式是招式,而編程是實實在在的運用。常見的設計原則有:
- 單一職責原則(Single Responsibility Principle, SRP原則): 一個類(Class) 和模塊(Module)只負責完成一個職責(Principle)或者功能(Funtion).
- 開閉原則(Open Closed Principle, OCP原則):軟體實體,比如模塊,類,方法等需要支撐 "對擴展開發,對修改關閉"的原則。
- 里氏替代原則(Liskov Substitution Principle, LSP原則):子類對象能夠替代程式中的父類對象出現的任何地方,並且保證原有邏輯行為不變和正確性不被破壞。
- 介面隔離原則(Interface Segregation Principle, ISP原則):介面調用方和使用者只關心自己相關的,不用依賴於自己不需要的介面。
- 依賴反轉原則(Dependency Inversion Principle,DIP 原則):高模塊不用依賴低模塊,不用關註其細節,需要通過抽象來互相依賴。
- KISS原則(Keep it Simple and Stupid Principle, KISS原則):保持代碼可讀和可維護的原則。
- YAGNI原則(You Ai Not Gonna Need It Principle,YAGNI原則):避免過度設計的原則,不用去設計用不到的功能和不用去編寫用不到的代碼。
- DRY原則(Do Not Repeat Yourself Principle,DRY原則): 減少編寫重覆的代碼的原則,提高代碼復用。
- 迪米特原則(Law of Demeter Principle, LoD原則 ): 就是我們常說的“高內聚,低耦合”的最佳參考原則,不應該存在直接依賴關係的類之間不要有依賴。
綜上所述,前面五種原則就是我們常說的SOLID原則,其他四種原則也是我們最常用的原則,這些設計原則都是我們的編程方法論。
寫在最後
Java 記憶體模型通過定義了一系列的 Happens-Before 操作,讓應用程式開發者能夠輕易地表達不同線程的操作之間的記憶體可見性。
在遵守 Java 記憶體模型的前提下,即時編譯器以及底層體系架構能夠調整記憶體訪問操作,以達到性能優化的效果。如果開發者沒有正確地利用 Happens-Before 規則,那麼將可能導致數據競爭。
Java 記憶體模型是通過記憶體屏障來禁止重排序的。對於即時編譯器來說,記憶體屏障將限制它所能做的重排序優化。對於處理器來說,記憶體屏障會導致緩存的刷新操作。
在設計Java代碼的時候,遵循一些必要的設計原則,也能更好地幫助我們寫出好的代碼,減少記憶體開銷,對於我們自我提升也有更好的幫助。
版權聲明:本文為博主原創文章,遵循相關版權協議,如若轉載或者分享請附上原文出處鏈接和鏈接來源。