談JVM xmx, xms等記憶體相關引數合理性設定

語言: CN / TW / HK

作者:京東零售 劉樂

吞吐量和停頓時長,這兩個優化目標是有衝突的。那麼有沒有可能提高吞吐量而不影響停頓時長,甚至縮短停頓時長呢?答案是有可能的,提高記憶體佔用(Memory Footprint)就有可能同時優化這兩個標的,這篇文章就來聊聊記憶體相關內容。

記憶體佔用一般指應用執行需要的所有記憶體,包括堆內記憶體(On-heap Memory)和堆外記憶體(Off-heap Memory)

1. 堆內記憶體

堆內記憶體是分配給JVM的部分記憶體,用來存放所有Java Class物件例項和陣列,JVM GC操作的就是這部分內容。我們先來回顧一下堆內記憶體的模型:

圖1. 堆內記憶體

堆內記憶體包括年輕代(淺綠色),老年代(淺藍色),在JDK7或者更老的版本,圖中右邊還有個永久代(永久代在邏輯上位於JVM的堆區,但又被稱為非堆記憶體,在JDK8中被元空間取代)。JVM有動態調整記憶體策略,通過-Xms,-Xmx指定堆內記憶體動態調整的上下限。 在JVM初始化時實際只分配部分記憶體,可通過-XX:InitialHeapSize指定初始堆記憶體大小,未被分配的空間為圖中virtual部分。年輕代和老年代在每次GC的時候都有可能調整大小,以保證存活物件佔用百分比在特定閾值範圍內,直到達到Xms指定的下限或Xms指定的上限。(閾值範圍通過-XX:MinHeapFreeRatio,XX:MaxHeapFreeRatio指定,預設值分別為40, 70)。

GC調優中還有個的重要引數是老年代和年輕代的比例,通過-XX:NewRatio設定,與此相關的還有-XX:MaxNewSize和-XX:NewSize,分別設定年輕代大小的上下限,-Xmn則直接指定年輕代的大小。

1.1 引數預設值

◦-Xmx: Xmx的預設值比較複雜,官方文件上有時候寫的是1GB,但實際值跟JRE版本、JVM 模式(client, server)和系統(平臺型別,32位,64位)等都有關。經過查閱原始碼和實驗,確定在生產環境下(server模式,64位Centos,JRE 8),Xmx的預設值可以採用以下規則計算:

▪容器記憶體小於等於2G:預設值為容器記憶體的1/2,最小16MB, 最大512MB。

▪容器記憶體大於2G:預設值為容器記憶體的1/4, 最大可到達32G。

◦-Xms: 預設值為容器記憶體的1/64, 最小8MB,如果明確指定了Xmx並且小於容器記憶體1/64, Xms預設值為Xmx指定的值。

◦-NewRatio: 預設2,即年輕代和年老代的比例為1:2, 年輕代大小為堆內記憶體的1/3。

NOTE:在JRE版本1.8.0_131之前,JVM無法感知Docker的資源限制,Xmx, Xms未明確指定時,會使用宿主機的記憶體計算預設值。

1.2 最佳實踐

由於每次Eden區滿就會觸發YGC,而每次YGC的時候,晉升到老年代的物件大小超過老年代剩餘空間的時候,就會觸發FGC。所以基本來說,GC頻率和堆內記憶體大小是成反比的,也就是說堆內記憶體越大,吞吐量越大。

如果Xmx設定過小,不僅浪費了容器資源,在大流量下會頻繁GC,導致一系列問題,包括吞吐量降低,響應變長,CPU升高,
java.lang.OutOfMemoryError異常等。當然Xmx也不建議設定過大,否則會導致程序hang住或者使用容器Swap。所以合理設定Xmx非常重要,特別是對於1.8.0_131之前的版本,一定要明確指定Xmx。推薦設定為容器記憶體的50%,不能超過容器記憶體的80%。

JVM的動態記憶體策略不太適合服務使用,因為每次GC需要計算Heap是否需要伸縮,記憶體抖動需要向系統申請或釋放記憶體,特別是在服務重啟的預熱階段,記憶體抖動會比較頻繁。另外,容器中如果有其他程序還在消費記憶體,JVM記憶體抖動時可能申請記憶體失敗,導致OOM。因此建議服務模式下,將Xms設定Xmx一樣的值。

NewRatio建議在2~3之間,最優選擇取決於物件的生命週期分佈。一般先確定老年代的空間(足夠放下所有live data,並適當增加10%~20%),其餘是年輕代,年輕代大小一定要小於老年代。

另外,以上建議都是基於一個容器部署一個JVM例項的使用情況。有個別需求,需要在一個容器內啟用多個JVM,或者包含其他語言的,研發需要按業務需求在推薦值範圍內分配JVM的Xmx。

2. 堆外記憶體

和堆內記憶體對應的就是堆外記憶體。堆外記憶體包括很多部分,比如Code Cache, Memory Pool,Stack Memory,Direct Byte Buffers, Metaspace等等,其中我們需要重點關注的是Direct Byte Buffers和Metaspace。

2.1 Direct Byte Buffers

Direct Byte Buffers是系統原生記憶體,不位於JVM裡,狹義上的堆外記憶體就是指的Direct Byte Buffers。為什麼要使用系統原生記憶體呢? 為了更高效的進行Socket I/O或檔案讀寫等核心態資源操作,會使用JNI(Java原生介面),此時操作的記憶體需要是連續和確定的。而Heap中的記憶體不能保證連續,且GC也可能導致物件隨時移動。因此涉及Output操作時,不直接使用Heap上的資料,需要先從Heap上拷貝到原生記憶體,Input操作則相反。因此為了避免多餘的拷貝,提高I/O效率,不少第三方包和框架使用Direct Byte Buffers,比Netty。

Direct Byte Buffers雖然有上述優點,但使用起來也有一定風險。常見的Direct Byte Buffers使用方法是用java.nio.DirectByteBuffer的unsafe.allocateMemory方法來建立,DirectByteBuffer物件只儲存了系統分配的原生記憶體的大小和啟始位置,這些原生記憶體的釋放需要等到DirectByteBuffer物件被回收。有些特殊的情況下(比如JVM一直沒有FGC,設定-XX:+DisableExplicitGC禁用了System.gc),這部分物件會持續增加,直到堆外記憶體達到-XX:MaxDirectMemorySize指定的大小或者耗盡所有的系統記憶體。

MaxDirectMemorySize不明確指定的時候,預設值為0,在程式碼中實際為Runtime.getRuntime().maxMemory(),略小於-Xmx指定的值(堆內記憶體的最大值減去一個Survivor區大小)。此預設值有點過大,MaxDirectMemorySize未設定或設定過大,有可能發生堆外記憶體洩露,導致程序被系統Kill。

由於存在一定風險,建議在啟動引數裡明確指定-XX:MaxDirectMemorySize的值,並滿足下面規則:

Xmx * 110% + MaxDirectMemorySize + 系統預留記憶體 <= 容器記憶體

◦Xmx * 110% 中額外的10%是留給其他堆外記憶體的,是個保守估計,個別業務執行時執行緒較多,需自行判斷,上式中左側還需加上Xss * 執行緒數

◦系統預留記憶體512M到1G,視容器規格而定

◦I/O較多的業務適當提高MaxDirectMemorySize比例

2.2 Metaspace

Metaspace(元空間)是JDK8關於方法區新的實現,取代之前的永久代,用來儲存類、方法、資料結構等執行時資訊和元資訊的。很多研發在老版本時可能遇到過
java.lang.OutOfMemoryError: PermGen Space,這說明永久代的空間不夠用了,可以通過-XX:PermSize,-XX:MaxPermSize來指定永久代的初始大小和最大大小。Metaspace取代永久代,位置由JVM記憶體變成系統原生記憶體,也取消預設的最大空間限制。與此有關的引數主要有下面兩個:

◦-XX:MaxMetaspaceSize指定元空間的最大空間,預設為容器剩餘的所有空間

◦-XX:MetaspaceSize指定元空間首次擴充的大小,預設為20.8M

由於MaxMetaspaceSize未指定時,預設無上限,所以需要特別關注記憶體洩露的問題,如果程式動態的建立了很多類,或出現過
java.lang.OutOfMemoryError:Metaspace,建議明確指定-XX:MaxMetaspaceSize。另外Metaspace實際分配的大小是隨著需要逐步擴大的,每次擴大需要一次FGC,-XX:MetaspaceSize預設的值比較小,需要頻繁GC擴充到需要的大小。通過下面的日誌可以看到Metaspace引起的FGC:

[Full GC (Metadata GC Threshold) ...]

為減少預熱影響,可以將-XX:MetaspaceSize,-XX:MaxMetaspaceSize指定成相同的值。另外不少應用由JDK7升級到了JDK8,但是啟動引數中仍有-XX:PermSize,-XX:MaxPermSize,這些引數是不生效的,建議修改成-XX:MetaspaceSize,-XX:MaxMetaspaceSize。

3. 應用健康度檢查規則

泰山應用健康度現在已支援掃描JVM相關風險,在應用TAB的JVM配置檢測項下。主要包括以下檢測:

檢測指標 風險等級 巡檢規則
JVM版本 中危 版本不低於1.8.0_191
JVM GC方法 中危 所有分組GC方法一致
Xmx 高危 明確指定,並且在容器記憶體的50%~80%範圍內
Xms 中危 明確指定,並且等於Xmx指定的值
堆外記憶體 中危 明確指定,並且 堆內*1.1+堆外+系統預留<=容器記憶體
ParallelGCThreads 高危 ParallelGCThreads在容器CPU核數的50%~100%範圍內
ConcGCThreads 低危 ConcGCThreads在ParallelGCThreads的20%~50%範圍內(限CMS,G1)
CICompilerCount 低危 指定CICompilerCount在推薦值50%~150%內(限1.8<JRE<1.8.0_131)

 

上一篇文章已經說了ParallelGCThreads,這裡再補充一下新支援的兩個檢測,ConcGCThreads,CICompilerCount。

ConcGCThreads一般稱為併發標記執行緒數,為了減少GC的STW的時間,CMS和G1都有併發標記的過程,此時業務執行緒仍在工作,只是併發標記是CPU密集型任務,業務的吞吐量會下降,RT會變長。ConcGCThreads的預設值不同GC策略略有不同,CMS下是(ParallelGCThreads + 3) / 4 向下取整,G1下是ParallelGCThreads / 4 四捨五入。一般來說採用預設值就可以了,但是還是由於在JRE版本1.8.0_131之前,JVM無法感知Docker的資源限制的問題,ConcGCThreads的預設值會比較大(20左右),對業務會有影響。

CICompilerCount是JIT進行熱點編譯的執行緒數,和併發標記執行緒數一樣,熱點編譯也是CPU密集型任務,預設值為2。在CICompilerCountPerCPU開啟的時候(JDK7預設關閉,JDK8預設開啟),手動指定CICompilerCount是不會生效的,JVM會使用系統CPU核數進行計算。所以當使用JRE8並且版本小於1.8.0_131,採用預設引數時,CICompilerCount會在20左右,對業務效能影響較大,特別是啟動階段。建議升級Java版本,特殊情況要使用老版本Java 8,請加上-XX:CICompilerCount=[n], 同時不能指定-XX:+CICompilerCountPerCPU,下表給出了生產環境下常見規格的推薦值。

容器CPU核數 1 2 4 8 16
CICompilerCount手動指定推薦值 2 2 3 3 8

4. 修改建議

1) 再次建議升級JRE版本到1.8.0_191及以上; 2) 建議在Shell指令碼中,Export JAVA_OPTS環境變數, 至少包含以下幾項(方括號中的值根據文中推薦選取):

-server -Xms[8192m] -Xmx[8192m] -XX:MaxDirectMemorySize=[4096m]

如果特殊原因要使用1.8.0_131以下版本, 則同時需要加上以下引數(方括號中的值根據文中推薦選取):

-XX:ParallelGCThreads=[8] -XX:ConcGCThreads=[2] -XX:CICompilerCount=[2]

下面的項建議測試後使用,需自行確定具體大小(特別是使用JRE8但仍配置-XX:PermSize,-XX:MaxPermSize的應用):

-XX:MaxMetaspaceSize=256m -XX:MetaspaceSize=256m

環境變數設定如下例子:

export JAVA_OPTS="-Djava.library.path=/usr/local/lib -server -Xms4096m -Xmx4096m -XX:MaxMetaspaceSize=512m -XX:MetaspaceSize=512m -XX:MaxDirectMemorySize=2048m -XX:ParallelGCThreads=8 -XX:ConcGCThreads=2 -XX:CICompilerCount=2 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/export/Logs -XX:+UseG1GC [other_options...] -jar jarfile [args...]"

另外,如果應用未接入UMP或PFinder, JAVA_OPTS中儘量不要用Shell函式或者變數,否則健康度有可能會提示解析失敗。

NOTE: Java options 的使用應該按照下面的順序:

◦執行類: java [-options] class [args...]

◦執行包:java [-options] -jar jarfile [args...] 或 java -jar [-options] jarfile [args...]

即options要放到執行物件之前,部分應用使用了以下順序:

java -jar jarfile [-options] [args...] 或者 java -jar jarfile [args...] [-options]

這些Java options都不會生效。