搞JAVA開發的朋友,最怕的就是之一:JVM調優。實話實說,在工作中用的不是很多,只有出現問題了才會用到(也可以在項目發布時調整好相關參數,避免線上出問題)。但是在面試中,這一塊就是必須掌握的了,否則和HR聊薪水都會受到限制。
主要內容:
- 小白也能看懂的JVM運行時數據區詳解。
- 垃圾回收,到底是怎么回收的?
- JDK8為什么要引入元空間替換永久代?
- CMS和G1垃圾收集器介紹
- 常見JVM參數介紹
- 堆空間如何設置?
- 元空間如何設置?
- 棧相關參數如何設置?
- 日志參數如何設置?
- 垃圾收集器如何配置?
- CMS和G1參數設置介紹
- 其他參數設置介紹
- 調優案例
適合人群:所有Java開發人員,對JVM調優感興趣的朋友
我們先從 JVM(Java Virtual machine)的基本知識點開始聊。Java 中的一些代碼優化技巧,和JVM的關系非常的大,比如逃逸分析對非捕獲型 Lambda 表達式的優化。
在進行這些優化之前,你需要對 JVM 的一些運行原理有較深刻的認識,在優化時才會有針對性的方向。
JVM 內存區域劃分
學習 JVM,內存區域劃分是繞不過去的知識點,這幾乎是面試必考的題目。如下圖所示,內存區域劃分主要包括堆、Java 虛擬機棧、程序計數器、本地方法棧、元空間和直接內存這五部分,我將逐一介紹。
1.堆
如 JVM 內存區域劃分圖所示,JVM 中占用內存最大的區域,就是堆(Heap),我們平常編碼創建的對象,大多數是在這上面分配的,也是垃圾回收器回收的主要目標區域。
2.Java 虛擬機棧
JVM 的解釋過程是基于棧的,程序的執行過程也就是入棧出棧的過程,這也是 Java 虛擬機棧這個名稱的由來。
Java 虛擬機棧是和線程相關的。當你啟動一個新的線程,Java 就會為它分配一個虛擬機棧,之后所有這個線程的運行,都會在棧里進行。
Java 虛擬機棧,從方法入棧到具體的字節碼執行,其實是一個雙層的棧結構,也就是棧里面還包含棧。
如上圖,Java 虛擬機棧里的每一個元素,叫作棧幀。每一個棧幀,包含四個區域: 局部變量表 、操作數棧、動態連接和返回地址。
其中,操作數棧就是具體的字節碼指令所操作的棧區域,考慮到下面這段代碼:
package com.tian.utils;
public class Test {
public int test() {
int a = 1;
a++;
return a;
}
}
JVM 將會為 test 方法生成一個棧幀,然后入棧,等 test 方法執行完畢,就會將對應的棧幀彈出。在對變量 a 進行加一操作的時候,就會對棧幀中的操作數棧運用相關的字節碼指令。
我們對上面這個類進行編譯成Test.class文件后,使用命令:
javap -verbose -c Test.class >test.txt
這樣就會把這個類的字節碼指令輸出到test.txt文件中:
Classfile /E:/workspace/other/hAppy-mall/target/classes/com/tian/utils/Test.class
Last modified 2022-3-23; size 369 bytes
MD5 checksum 58d655b96f21dd36600ad0a8df0efa70
Compiled from "Test.java"
public class com.tian.utils.Test
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #3.#17 // java/lang/Object."<init>":()V
#2 = Class #18 // com/tian/utils/Test
#3 = Class #19 // java/lang/Object
#4 = Utf8 <init>
#5 = Utf8 ()V
#6 = Utf8 Code
#7 = Utf8 LineNumberTable
#8 = Utf8 LocalVariableTable
#9 = Utf8 this
#10 = Utf8 Lcom/tian/utils/Test;
#11 = Utf8 test
#12 = Utf8 ()I
#13 = Utf8 a
#14 = Utf8 I
#15 = Utf8 SourceFile
#16 = Utf8 Test.java
#17 = NameAndType #4:#5 // "<init>":()V
#18 = Utf8 com/tian/utils/Test
#19 = Utf8 java/lang/Object
{
public com.tian.utils.Test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/tian/utils/Test;
public int test();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=1, locals=2, args_size=1
0: iconst_1
1: istore_1
2: iinc 1, 1
5: iload_1
6: ireturn
LineNumberTable:
line 5: 0
line 6: 2
line 7: 5
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 this Lcom/tian/utils/Test;
2 5 1 a I
}
SourceFile: "Test.java"
上面這段字節碼代碼,可能很多人都看不懂,建議結合:
推薦博客(如果英語好的話,也可以去官網查看):
字節碼指令大全
然后,再結合我們上面的Java虛擬機棧這塊知識,就能輕松閱讀了。
棧幀的創建是需要耗費資源的,尤其是對于 Java 中常見的 getter、setter 方法來說,這些代碼通常只有一行,每次都創建棧幀的話就太浪費了。
另外,Java 虛擬機棧對代碼的執行,采用的是字節碼解釋的方式,考慮到下面這段代碼,變量 a 聲明之后,就再也不被使用,要是按照字節碼指令解釋執行的話,就要做很多無用功。
另外,我們了解到垃圾回收器回收的目標區域主要是堆,堆上創建的對象越多,GC 的壓力就越大。要是能把一些變量,直接在棧上分配,那 GC 的壓力就會小一些。
3.程序計數器
既然是線程,就要接受操作系統的調度,但總有時候,某些線程是獲取不到 CPU 時間片的,那么當這個線程恢復執行的時候,它是如何確保找到切換之前執行的位置呢?這就是程序計數器的功能。
和 Java 虛擬機棧一樣,它也是線程私有的。程序計數器只需要記錄一個執行位置就可以,所以不需要太大的空間。事實上,程序計數器是 JVM 規范中唯一沒有規定 OutOfMemoryError 情況的區域。
4.本地方法棧
與 Java 虛擬機棧類似,本地方法棧,是針對 native 方法的。我們常用的 HotSpot,將 Java 虛擬機棧和本地方法棧合二為一,其實就是一個本地方法棧,大家注意規范里的這些差別就可以了。
5.元空間
元空間是一個容易引起混淆的區域,原因就在于它經歷了多次迭代才成為現在的模樣。關于這部分區域,我們來講解兩個面試題,大家就明白了。
- 元空間是在堆上嗎?
答案:元空間并不是在堆上分配的,而是在堆外空間進行分配的,它的大小默認沒有上限,我們常說的方法區,就在元空間中。
- 字符串常量池在那個區域中?
答案:這個要看 JDK 版本。
在 JDK 1.8 之前,是沒有元空間這個概念的,當時的方法區是放在一個叫作永久代的空間中。
而在 JDK 1.7 之前,字符串常量池也放在這個叫作永久帶的空間中。但在 JDK 1.7 版本,已經將字符串常量池從永久帶移動到了堆上。
所以,從 1.7 版本開始,字符串常量池就一直存在于堆上。
- 為什么使用元空間替換永久代?
表面上看是為了避免OOM異常。因為通常使用PermSize和MaxPermSize設置永久代的大小就決定了永久代的上限,但是不是總能知道應該設置為多大合適, 如果使用默認值很容易遇到OOM錯誤。當使用元空間時,可以加載多少類的元數據就不再由MaxPermSize控制, 而由系統的實際可用空間來控制啦。
6.直接內存
直接內存,指的是使用了 Java 的直接內存 API,進行操作的內存。這部分內存可以受到 JVM 的管控,比如 ByteBuffer 類所申請的內存,就可以使用具體的參數進行控制。
需要注意的是直接內存和本地內存不是一個概念。
- 直接內存比較專一,有具體的 API(這里指的是ByteBuffer),也可以使用 -XX:MaxDirectMemorySize 參數控制它的大小;
- 本地內存是一個統稱,比如使用 native 函數操作的內存就是本地內存,本地內存的使用 JVM 是限制不住的,使用的時候一定要小心。
GC Roots
對象主要是在堆上分配的,我們可以把它想象成一個池子,對象不停地創建,后臺的垃圾回收進程不斷地清理不再使用的對象。當內存回收的速度,趕不上對象創建的速度,這個對象池子就會產生溢出,也就是我們常說的 OOM。
把不再使用的對象及時地從堆空間清理出去,是避免 OOM 有效的方法。那 JVM 是如何判斷哪些對象應該被清理,哪些對象需要被繼續使用呢?
這里首先強調一個概念,這對理解垃圾回收的過程非常有幫助,面試時也能很好地展示自己。
垃圾回收,并不是找到不再使用的對象,然后將這些對象清除掉。它的過程正好相反,JVM 會找到正在使用的對象,對這些使用的對象進行標記和追溯,然后一股腦地把剩下的對象判定為垃圾,進行清理。
了解了這個概念,我們就可以看下一些基本的衍生分析:
- GC 的速度,和堆內存活對象的多少有關,與堆內所有對象的數量無關;
- GC 的速度與堆的大小無關,32GB 的堆和 4GB 的堆,只要存活對象是一樣的,垃圾回收速度也會差不多;
- 垃圾回收不必每次都把垃圾清理得干干凈凈,最重要的是不要把正在使用的對象判定為垃圾。
那么,如何找到這些存活對象,也就是哪些對象是正在被使用的,就成了問題的核心。
大家可以想一下寫代碼的時候,如果想要保證一個 HashMap 能夠被持續使用,可以把它聲明成靜態變量,這樣就不會被垃圾回收器回收掉。我們把這些正在使用的引用的入口,叫作GC Roots。
這種使用 tracing 方式尋找存活對象的方法,還有一個好聽的名字,叫作可達性分析法。
概括來講,GC Roots 包括:
- Java 線程中,當前所有正在被調用的方法的引用類型參數、局部變量、臨時值等。也就是與我們棧幀相關的各種引用;
- 所有當前被加載的 Java 類;
- Java 類的引用類型靜態變量;
- 運行時常量池里的引用類型常量(String 或 Class 類型);
- JVM 內部數據結構的一些引用,比如 sun.jvm.hotspot.memory.Universe 類;
- 用于同步的監控對象,比如調用了對象的 wait() 方法;
- JNI handles,包括 global handles 和 local handles。
對于這個知識點,不要死記硬背,可以對比著 JVM 內存區域劃分那張圖去看,入口大約有三個:線程、靜態變量和 JNI 引用。
強、軟、弱、虛引用
那么,通過 GC Roots 能夠追溯到的對象,就一定不會被垃圾回收嗎?這要看情況。
Java 對象與對象之間的引用,存在著四種不同的引用級別,強度從高到低依次是:強引用、軟引用、弱引用、虛引用。
- 強應用 默認的對象關系是強引用,也就是我們默認的對象創建方式。這種引用屬于最普通最強硬的一種存在,只有在和 GC Roots 斷絕關系時,才會被消滅掉。
- 軟引用 用于維護一些可有可無的對象。在內存足夠的時候,軟引用對象不會被回收;只有在內存不足時,系統則會回收軟引用對象;如果回收了軟引用對象之后,仍然沒有足夠的內存,才會拋出內存溢出異常。
- 弱引用 級別就更低一些,當 JVM 進行垃圾回收時,無論內存是否充足,都會回收被弱引用關聯的對象。軟引用和弱引用在堆內緩存系統中使用非常頻繁,可以在內存緊張時優先被回收掉。
- 虛引用 是一種形同虛設的引用,在現實場景中用得不是很多。這里有一個冷門的知識點:Java 9.0 以后新加入了 Cleaner 類,用來替代 Object 類的 finalizer 方法,這就是虛引用的一種應用場景。
分代垃圾回收
上面我們提到,垃圾回收的速度,是和存活的對象數量有關系的,如果這些對象太多,JVM 再做標記和追溯的時候,就會很慢。
一般情況下,JVM 在做這些事情的時候,都會停止業務線程的所有工作,進入 SafePoint 狀態,這也就是我們通常說的 Stop the World。所以,現在的垃圾回收器,有一個主要目標,就是減少 STW 的時間。
其中一種有效的方式,就是采用分代垃圾回收,減少單次回收區域的大小。這是因為,大部分對象,可以分為兩類:
- 大部分對象的生命周期都很短
- 其他對象則很可能會存活很長時間
這個假設我們稱之為弱代假設(weak generational hypothesis)。
如下圖,分代垃圾回收器會在邏輯上,把堆空間分為兩部分:年輕代(Young generation)和老年代(Old generation)。
1.年輕代
年輕代中又分為一個伊甸園空間(Eden),兩個幸存者空間(Survivor)。對象會首先在年輕代中的 Eden 區進行分配,當 Eden 區分配滿的時候,就會觸發年輕代的 GC。
此時,存活的對象會被移動到其中一個 Survivor 分區(以下簡稱 from);年輕代再次發生垃圾回收,存活對象,包括 from 區中的存活對象,會被移動到 to 區。所以,from 和 to 兩個區域,總有一個是空的。
Eden、from、to 的默認比例是 8:1:1,所以只會造成 10% 的空間浪費。這個比例,是由參數 -XX:SurvivorRatio 進行配置的(默認為 8)。
2.老年代
對垃圾回收的優化,就是要讓對象盡快在年輕代就回收掉,減少到老年代的對象。那么對象是如何進入老年代的呢?它主要有以下四種方式。
- 正常提升(Promotion)
上面提到了年輕代的垃圾回收,如果對象能夠熬過年輕代垃圾回收,它的年齡(age)就會加一,當對象的年齡達到一定閾值,就會被移動到老年代中。
- 分配擔保
如果年輕代的空間不足,又有新的對象需要分配空間,就需要依賴其他內存(這里是老年代)進行分配擔保,對象將直接在老年代創建。
- 大對象直接在老年代分配
超出某個閾值大小的對象,將直接在老年代分配,可以通過
-XX:PretenureSizeThreshold 配置這個閾值。
- 動態對象年齡判定
有的垃圾回收算法,并不要求 age 必須達到 15 才能晉升到老年代,它會使用一些動態的計算方法。比如 G1,通過 TargetSurvivorRatio 這個參數,動態更改對象提升的閾值。
老年代的空間一般比較大,回收的時間更長,當老年代的空間被占滿了,將發生老年代垃圾回收。
目前,被廣泛使用的是 G1 垃圾回收器。G1 的目標是用來干掉 CMS 的,它同樣有年輕代和老年代的概念。不過,G1 把整個堆切成了很多份,把每一份當作一個小目標,部分上目標很容易達成。
如上圖,G1 也是有 Eden 區和 Survivor 區的概念的,只不過它們在內存上不是連續的,而是由一小份一小份組成的。G1 在進行垃圾回收的時候,將會根據最大停頓時間(MaxGCPauseMillis)設置的值,動態地選取部分小堆區進行垃圾回收。
G1 的配置非常簡單,我們只需要配置三個參數,一般就可以獲取優異的性能:
- MaxGCPauseMillis 設置最大停頓的預定目標,G1 垃圾回收器會自動調整,選取特定的小堆區;
- G1HeapRegionSize 設置小堆區的大小;
- InitiatingHeapOccupancyPercent當整個堆內存使用達到一定比例(默認是45%),并發標記階段就會被啟動。
逃逸分析
下面著重講解一下逃逸分析,這個知識點在面試的時候經常會被問到。
我們常說的對象,除了基本數據類型,一定是在堆上分配的嗎?
答案是否定的,通過逃逸分析,JVM 能夠分析出一個新的對象的使用范圍,從而決定是否要將這個對象分配到堆上。逃逸分析現在是 JVM 的默認行為,可以通過參數 -XX:-DoEscapeAnalysis 關掉它。
那什么樣的對象算是逃逸的呢?可以看一下下面的兩種典型情況。
如代碼所示,對象被賦值給成員變量或者靜態變量,可能被外部使用,變量就發生了逃逸。
public class EscapeAttr {
Object attr;
public void test() {
attr = new Object();
}
}
再看下面這段代碼,對象通過 return 語句返回。由于程序并不能確定這個對象后續會不會被使用,外部的線程能夠訪問到這個結果,對象也發生了逃逸。
public class EscapeReturn {
Object attr;
public Object test() {
Object obj = new Object();
return obj;
}
}
那逃逸分析有什么好處呢? 1. 棧上分配
如果一個對象在子程序中被分配,指向該對象的指針永遠不會逃逸,對象有可能會被優化為棧分配。棧分配可以快速地在棧幀上創建和銷毀對象,不用再分配到堆空間,可以有效地減少 GC 的壓力。
2. 分離對象或標量替換
但對象結構通常都比較復雜,如何將對象保存在棧上呢?
JIT 可以將對象打散,全部替換為一個個小的局部變量,這個打散的過程,就叫作標量替換(標量就是不能被進一步分割的變量,比如 int、long 等基本類型)。也就是說,標量替換后的對象,全部變成了局部變量,可以方便地進行棧上分配,而無須改動其他的代碼。
從上面的描述我們可以看到,并不是所有的對象或者數組,都會在堆上分配。由于JIT的存在,如果發現某些對象沒有逃逸出方法,那么就有可能被優化成棧分配。
3.同步消除
如果一個對象被發現只能從一個線程被訪問到,那么對于這個對象的操作可以不考慮同步。
注意這是針對 synchronized 來說的,JUC 中的 Lock 并不能被消除。
要開啟同步消除,需要加上-XX:+EliminateLocks參數。由于這個參數依賴逃逸分析,所以同時要打開-XX:+DoEscapeAnalysis 選項。
JVM 常見優化參數
現在大家用得最多的 Java 版本是 Java 8,如果你的公司比較保守,那么使用較多的垃圾回收器就是 CMS 。但 CMS 已經在 Java 14 中被正式廢除,隨著 ZGC 的誕生和 G1 的穩定,CMS 終將成為過去式。
Java 9 之后,Java 版本已經進入了快速發布階段,大約是每半年發布一次,Java 8 和 Java 11 是目前支持的 LTS 版本。
由于 JVM 一直處在變化之中,所以一些參數的配置并不總是有效的。有時候你加入一個參數,“感覺上”運行速度加快了,但通過 -XX:+PrintFlagsFinal來查看,卻發現這個參數默認就是這樣了。
所以,在不同的 JVM 版本上,不同的垃圾回收器上,要先看一下這個參數默認是什么,不要輕信別人的建議,命令行示例如下:
java -XX:+PrintFlagsFinal -XX:+UseG1GC 2>&1 | grep UseAdaptiveSizePolicy
還有一個與之類似的參數叫作PrintCommandLineFlags,通過它,你能夠查看當前所使用的垃圾回收器和一些默認的值。
可以看到下面的 JVM 默認使用的就是并行收集器:
# java -XX:+PrintCommandLineFlags -version
-XX:InitialHeapSize=127905216 -XX:MaxHeapSize=2046483456 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC
openjdk version "1.8.0_41"
OpenJDK Runtime Environment (build 1.8.0_41-b04)
OpenJDK 64-Bit Server VM (build 25.40-b25, mixed mode)
下面我大致羅列一些JVM調優參數:
堆相關參數
-Xms10g :JVM啟動時申請的初始堆內存值
-Xmx20G :JVM可申請的最大Heap值
-Xmn3g : 新生代大小,一般設置為堆空間的1/3 1/4左右,新生代大則老年代小
-Xss :Java每個線程的Stack大小
-XX:PermSize :持久代(方法區)的初始內存大小
-XX:MaxPermSize : 持久代(方法區)的最大內存大小
-XX:SurvivorRatio : 設置新生代eden空間和from/to空間的比例關系,關系(eden/from=eden/to)
-XX:NewRatio : 設置新生代和老年代的比例老年代/新生代
日志相關參數
-XX:+PrintGC :打印GC日志
-XX:+PrintGCDetailsGC :時的詳細堆信息
-XX:+PrintHeapAtGC :打印GC前后的堆信息
-XX:+PrintGCTimeStamps :輸出GC發生時間,輸出的時間為虛擬機啟動的偏移量
-XX:+PrintGCApplicationConcurrentTime :輸出應用程序執行時間
-XX:+PrintGCApplicationStoppedTime :輸出應用程序由于GC產生停頓的時間
-XX:+PrintRefrenceGC :輸出軟引用、弱引用、虛引用和Finalize隊列
-XX:+HeapDumpOnOutOfMemoryError :產生OOM時可以在內存溢出時導出整個堆信息
-XX:HeapDumpPath :導出堆文件存放路徑
-XX:+TraceClassLoading :跟蹤類加載信息
-XX:+TraceClassUnloading :跟:蹤類卸載信息
-XX:PrintClassHitogram :查看系統中的類的分布情況(占用空間最多、實例數量空間大小)
-XX:+PrintVMOptions :打印虛擬機接收到的命令行顯示參數
-XX:+PrintCommandLineFlags :打印虛擬機的顯式和隱式參數
-XX:+PrintFlagsFinal :打印虛擬機的所有系統參數
GC相關參數
-XX:+UseSerialGC :新生代、老年代使用串行收集器
-XX:SurvivorRatio :設置eden區和survivor區大小的比例
-XX:PretenureSizeThreshold,:當對象大小超過此值時,直接分配到老年代
-XX:MaxTenuringThreshold :設置對象進入老年代的最大年齡
-XX:+UseParNewGC :新生代使用并行收集器
-XX:+UseParallelOldGC :老年代使用并行回收收集器
-XX:+ParallelGCThreads :設置垃圾回收線程數,一般最好與CPU數量相當,默認情況下,當CPU數量小于8個時,ParallelGCThreads的值相當于CPU數量,當CPU數量大于8個時,ParallelGCThreads的值等于3+((5*CPU_COUNT)/8
-XX:MaxGCPauseMillis :設置最大垃圾收集停頓時間
-XX:GCTimeRatio :設置吞吐量大小,它的值是一個0~100之間的整數,假設值為n,那么系統將花費不超過1/(1+n)的時間用于垃圾收集
-XX:+UseAdaptiveSizePolicy :打開自適應GC策略,JVM對新生代的大小、eden和survivior的比例、晉升老年代對象年齡等參數自動調整
-XX:+UseConcMarkSweepGC :啟用CMS
-XX:ParallelCMSThreads :設置CMS線程數量
-XX:CMSInitiatingOccupancyFraction :默認68當老年代的空間超過68%時會執行一次CMS回收
-XX:UseCMSCompactAtFullCollection :設置CMS結束后是否需要進行一次內存空間整理
-XX:CMSFullGCsBeforeCompaction :進行多少次CMS后進行內存空間壓縮
-XX:+CMSClassUnloadingEnabled :允許對類元數據區進行回收
-XX:CMSInitiatingPermOccupancyFraction :當永久區占用率達到此值時進行CMS回收(須激活CMSClassUnloadingEnabled)
-XX:UseCMSInitiatingOccupancyOnly:只要達到閾值時進行CMS回收
-XX:+UseG1GC :使用G1
-XX:MaxGCPauseMillis :最大垃圾收集停頓時間
-XX:GCPauseIntervalMillis :最大停頓間隔時間
JVM 的參數配置繁多,但大多數不需要我們去關心。
調優案例
下面,我們通過對 ES 服務的 JVM 參數分析,來看一下常見的優化點。
ElasticSearch(簡稱 ES)是一個高性能的開源分布式搜索引擎。ES 是基于 Java 語言開發的,既然是Java開發,那肯定會涉及到JVM調優了,在它的 conf 目錄下,有一個叫作jvm.options的文件,JVM 的配置就放在這里。
堆空間的配置
下面是 ES 對于堆空間大小的配置。
-Xms1g
-Xmx1g
JVM 中空間最大的一塊就是堆,垃圾回收也主要是針對這塊區域。通過 Xmx 可指定堆的最大值,通過 Xms 可指定堆的初始大小。我們通常把這兩個參數,設置成一樣大小的,可避免堆空間在動態擴容時的時間開銷。
在配置文件中還有AlwaysPreTouch這個參數。
-XX:+AlwaysPreTouch
其實,通過 Xmx 指定了的堆內存,只有在 JVM 真正使用的時候,才會進行分配。這個參數,在 JVM 啟動的時候,就把它所有的內存在操作系統分配了。在堆比較大的時候,會加大啟動時間,但它能夠減少內存動態分配的性能損耗,提高運行時的速度。
如下圖,JVM 的內存,分為堆和堆外內存,其中堆的大小可以通過 Xmx 和 Xms 來配置。
但我們在配置 ES 的堆內存時,通常把堆的初始化大小,設置成物理內存的一半。這是因為 ES 是存儲類型的服務,我們需要預留一半的內存給文件緩存 ,等下次用到相同的文件時,就不用與磁盤進行頻繁的交互。這一塊區域一般叫作 PageCache,占用的空間很大。
對于計算型節點來說,比如我們普通的 Web 服務,通常會把堆內存設置為物理內存的 2/3,剩下的 1/3 就是給堆外內存使用的。
我們這張圖,對堆外內存進行了非常細致的劃分,解釋如下:
- 元空間 參數 -XX:MaxMetaspaceSize 和 -XX:MetaspaceSize,分別指定了元空間的最大內存和初始化內存。因為元空間默認是沒有上限的,所以極端情況下,元空間會一直擠占操作系統剩余內存。
- JIT 編譯后代碼存放 -XX:ReservedCodeCacheSize。JIT 是 JVM 一個非常重要的特性,CodeCahe 存放的,就是即時編譯器所生成的二進制代碼。另外,JNI 的代碼也是放在這里的。
- 本地內存 本地內存是一些其他 attch 在 JVM 進程上的內存區域的統稱。比如網絡連接占用的內存、線程創建占用的內存等。在高并發應用下,由于連接和線程都比較多,這部分內存累加起來還是比較可觀的。
- 直接內存 這里要著重提一下直接內存,因為它是本地內存中唯一可以使用參數來限制大小的區域。使用參數 -XX:MaxDirectMemorySize,即可設定 ByteBuffer 類所申請的內存上限。
- JNI 內存 上面談到 CodeCache 存放的 JNI 代碼,JNI 內存就是指的這部分代碼所 malloc 的具體內存。很可惜的是,這部分內存的使用 JVM 是無法控制的,它依賴于具體的 JNI 代碼實現。
日志參數配置
下面是 ES 的日志參數配置,由于 Java 8 和 Java 9 的參數配置已經完全不一樣了,ES 在這里也分了兩份。
Java8參數設置:
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintTenuringDistribution
-XX:+PrintGCApplicationStoppedTime
-Xloggc:logs/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=32
-XX:GCLogFileSize=64m
Java9參數設置:
-Xlog:gc*,gc+age=trace,safepoint:file=logs/gc.log:utctime,pid,tags:filecount=32,filesize=64m
下面解釋一下這些參數的意義,以 Java 8 為例。
- PrintGCDetails 打印詳細 GC 日志。
- PrintGCDateStamps 打印當前系統時間,更加可讀;與之對應的是 PrintGCTimeStamps,打印的是 JVM 啟動后的相對時間,可讀性較差。
- PrintTenuringDistribution 打印對象年齡分布,對調優 MaxTenuringThreshold 參數幫助很大。
- PrintGCApplicationStoppedTime 打印 STW 時間
- 下面幾個日志參數是配置了類似于 Logback 的滾動日志,比較簡單,不再詳細介紹
從 Java 9 開始,JVM 移除了 40 多個 GC 日志相關的參數,具體參見 JEP 158。所以這部分的日志配置有很大的變化,GC 日志的打印方式,已經完全不一樣了,比以前的日志參數規整了許多。
參數如下所示:
-Xlog:gc*,gc+age=trace,safepoint:file=logs/gc.log:utctime,pid,tags:filecount=32,filesize=64m
再來看下 ES 在異常情況下的配置參數:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=data
-XX:ErrorFile=logs/hs_err_pid%p.log
HeapDumpOnOutOfMemoryError、HeapDumpPath、ErrorFile是每個 Java 應用都應該配置的參數。正常情況下,我們通過 jmap 獲取應用程序的堆信息;異常情況下,比如發生了 OOM,通過這三個配置參數,即可在發生OOM的時候,自動 dump 一份堆信息到指定的目錄中。
拿到了這份 dump 信息,我們就可以使用 MAT 等工具詳細分析,找到具體的 OOM 原因。
垃圾回收器配置
ES 默認使用 CMS 垃圾回收器,它有以下三行主要的配置。
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=75
-XX:+UseCMSInitiatingOccupancyOnly
下面介紹一下這兩個參數:
- UseConcMarkSweepGC,表示年輕代使用 ParNew,老年代的用 CMS 垃圾回收器
- -XX:CMSInitiatingOccupancyFraction 由于 CMS 在執行過程中,用戶線程還需要運行,那就需要保證有充足的內存空間供用戶使用。如果等到老年代空間快滿了,再開啟這個回收過程,用戶線程可能會產生“Concurrent Mode Failure”的錯誤,這時會臨時啟用 Serial Old 收集器來重新進行老年代的垃圾收集,這樣停頓時間就很長了(STW)。
這部分空間預留,一般在 30% 左右即可,那么能用的大概只有 70%。參數
-XX:CMSInitiatingOccupancyFraction 用來配置這個比例,但它首先必須配置 -XX:+UseCMSInitiatingOccupancyOnly 參數。
另外,對于 CMS 垃圾回收器,常用的還有下面的配置參數:
- -XX:ExplicitGCInvokesConcurrent 當代碼里顯示的調用了 System.gc(),實際上是想讓回收器進行FullGC,如果發生這種情況,則使用這個參數開始并行 FullGC。建議加上。
- -XX:CMSFullGCsBeforeCompaction 默認為 0,就是每次FullGC都對老年代進行碎片整理壓縮,建議保持默認。
- -XX:CMSScavengeBeforeRemark 開啟或關閉在 CMS 重新標記階段之前的清除(YGC)嘗試。可以降低 remark 時間,建議加上。
- -XX:+ParallelRefProcEnabled 可以用來并行處理 Reference,以加快處理速度,縮短耗時。
CMS 垃圾回收器,已經在 Java14 中被移除,由于它的 GC 時間不可控,有條件應該盡量避免使用。
針對 Java10(普通 Java 應用在 Java 8 中即可開啟 G1),ES 可采用 G1 垃圾回收器。
G1垃圾收集器,它可以通過配置參數 MaxGCPauseMillis,指定一個期望的停頓時間,使用相對比較簡單。
下面是主要的配置參數:
- -XX:MaxGCPauseMillis 設置目標停頓時間,G1 會盡力達成。
- -XX:G1HeapRegionSize 設置小堆區大小。這個值為 2 的次冪,不要太大,也不要太小。如果是在不知道如何設置,保持默認。
- -XX:InitiatingHeapOccupancyPercent 當整個堆內存使用達到一定比例(默認是45%),并發標記階段就會被啟動。
- -XX:ConcGCThreads 并發垃圾收集器使用的線程數量。默認值隨 JVM 運行的平臺不同而不同。不建議修改。
JVM 支持非常多的垃圾回收器,下面是最常用的幾個,以及配置參數:
- -XX:+UseSerialGC 年輕代和老年代都用串行收集器
- -XX:+UseParallelGC 年輕代使用 ParallerGC,老年代使用 Serial Old
- -XX:+UseParallelOldGC 新生代和老年代都使用并行收集器
- -XX:+UseG1GC 使用 G1 垃圾回收器
- -XX:+UseZGC 使用 ZGC 垃圾回收器
額外配置
我們再來看下幾個額外的配置。
-Xss1m
-Xss設置每個 Java 虛擬機棧的容量為 1MB。這個參數和 -XX:ThreadStackSize是一樣的,默認就是 1MB。
-XX:-OmitStackTraceInFastThrow
把 - 換成 +,可以減少異常棧的輸出,進行合并。雖然會對調試有一定的困擾,但能在發生異常時顯著增加性能。隨之而來的就是異常信息不好排查,ES 為了找問題方便,就把錯誤合并給關掉了。
-Djava.awt.headless=true
Headless 模式是系統的一種配置模式,在該模式下,系統缺少了顯示設備、鍵盤或鼠標。在服務器上一般是沒這些設備的,這個參數是告訴虛擬機使用軟件去模擬這些設備。
-Djava.locale.providers=COMPAT
-Dfile.encoding=UTF-8
-Des.networkaddress.cache.ttl=60
-Des.networkaddress.cache.negative.ttl=10
-Dio.netty.noUnsafe=true
-Dio.netty.noKeySetOptimization=true
-Dio.netty.recycler.maxCapacityPerThread=0
-Dlog4j.shutdownHookEnabled=false
-Dlog4j2.disable.jmx=true
-Djava.io.tmpdir=${ES_TMPDIR}
-Djna.nosys=true
上面這些參數,通過 -D 參數,在啟動一個 Java 程序時,設置系統屬性值,也就是在 System 類中通過getProperties()得到的一串系統屬性。
這部分自定義性比較強,不做過多介紹。
其他調優
以上就是 ES 默認的 JVM 參數配置,大多數還是比較基礎的。在平常的應用服務中,我們希望得到更細粒度的控制,其中比較常用的就是調整各個分代之間的比例。
- -Xmn 年輕代大小,默認年輕代占堆大小的 1/3。高并發快消亡場景可適當加大這個區域,對半或者更多都是可以的。但是在 G1 下,就不用再設置這個值了,它會自動調整;
- -XX:SurvivorRatio 默認值為 8,表示伊甸區和幸存區的比例;
- -XX:MaxTenuringThreshold 這個值在 CMS 下默認為 6,G1 下默認為 15。這個值和我們前面提到的對象提升有關,改動效果會比較明顯。對象的年齡分布可以使用-XX:+PrintTenuringDistribution打印,如果后面幾代的大小總是差不多,證明過了某個年齡后的對象總能晉升到老年代,就可以把晉升閾值設的小一些;
- PretenureSizeThreshold 超過一定大小的對象,將直接在老年代分配,不過這個參數用得不是很多。