Kubernetes 中的 Java 應(yīng)用的內(nèi)存調(diào)優(yōu)
前言
在 Kubernetes 環(huán)境中運行 Java 應(yīng)用程序雖然很常見,但往往也充滿各種問題,特別是在管理內(nèi)存資源時。在本文中,我們將討論配置應(yīng)用程序以優(yōu)化 Kubernetes 環(huán)境中的內(nèi)存使用并避免內(nèi)存不足問題的一些最佳實踐。
OpenJDK 17 中的內(nèi)存空間
OpenJDK 17 包含 Java 虛擬機 (JVM) 使用的多個內(nèi)存空間來管理 Java 應(yīng)用程序的內(nèi)存。了解這些不同的內(nèi)存空間可以幫助開發(fā)人員針對 Kubernetes 環(huán)境優(yōu)化其 Java 應(yīng)用程序。
Heap Memory-堆內(nèi)存
堆內(nèi)存會在Java運行時分配給對象(Object)或者JRE類。每當(dāng)我們創(chuàng)建一個對象的時候,在堆內(nèi)存中就會分配一塊儲存空間給這個對象。Java的垃圾回收機制就是運行在堆內(nèi)存上的,用以釋放那些沒有任何引用指向自身的對象(不可達(dá)的對象。注意Java的垃圾回收也會處理幾個相互引用但沒有任何外部引用的對象)。任何在堆內(nèi)存中分配的對象都有全局訪問權(quán)限,可以從應(yīng)用的任何地方被引用。
堆內(nèi)存是存儲Java應(yīng)用程序創(chuàng)建的對象的地方。它是Java應(yīng)用程序最重要的內(nèi)存空間。在 OpenJDK 17 中,默認(rèn)堆大小是根據(jù)可用物理內(nèi)存計算的,并設(shè)置為可用內(nèi)存的 1/4。
Young Generation-年輕代
對象被創(chuàng)建時,內(nèi)存的分配首先發(fā)生在年輕代(大對象可以直接被創(chuàng)建在年老代),大部分的對象在創(chuàng)建后很快就不再使用,因此很快變得不可達(dá),于是被年輕代的GC機制清理掉(IBM的研究表明,98%的對象都是很快消亡的),這個GC機制被稱為Minor GC或叫Young GC。注意,Minor GC并不代表年輕代內(nèi)存不足,它事實上只表示在Eden區(qū)上的GC。
年輕代上的內(nèi)存分配是這樣的,年輕代可以分為3個區(qū)域:Eden區(qū)(伊甸園,亞當(dāng)和夏娃偷吃禁果生娃娃的地方,用來表示內(nèi)存首次分配的區(qū)域,再貼切不過)和兩個存活區(qū)(Survivor 0 、Survivor 1)。
Old Generation-老一輩
對象如果在年輕代存活了足夠長的時間而沒有被清理掉(即在幾次Young GC后存活了下來),則會被復(fù)制到年老代,年老代的空間一般比年輕代大,能存放更多的對象,在年老代上發(fā)生的GC次數(shù)也比年輕代少。當(dāng)年老代內(nèi)存不足時,將執(zhí)行Major GC,也叫 Full GC?! ?/span>
可以使用-XX:+UseAdaptiveSizePolicy開關(guān)來控制是否采用動態(tài)控制策略,如果動態(tài)控制,則動態(tài)調(diào)整Java堆中各個區(qū)域的大小以及進(jìn)入老年代的年齡。
如果對象比較大(比如長字符串或大數(shù)組),Young空間不足,則大對象會直接分配到老年代上(大對象可能觸發(fā)提前GC,應(yīng)少用,更應(yīng)避免使用短命的大對象)。用-XX:PretenureSizeThreshold來控制直接升入老年代的對象大小,大于這個值的對象會直接分配在老年代上。
可能存在年老代對象引用新生代對象的情況,如果需要執(zhí)行Young GC,則可能需要查詢整個老年代以確定是否可以清理回收,這顯然是低效的。解決的方法是,年老代中維護(hù)一個512 byte的塊——”card table“,所有老年代對象引用新生代對象的記錄都記錄在這里。Young GC時,只要查這里即可,不用再去查全部老年代,因此性能大大提高。
Metaspace-元空間
非堆空間被 JVM 用于存儲元數(shù)據(jù)和類定義。在舊版本的 Java 中,它也稱為永久代 (PermGen)。在 OpenJDK 17 中,PermGen 空間已被新的 Metaspace 取代,其設(shè)計更加高效和靈活。
Code Cache-代碼緩存
簡而言之,JVM Code Cache (代碼緩存)是JVM存儲編譯成本機代碼的字節(jié)碼的區(qū)域。我們將可執(zhí)行本機代碼的每個塊稱為nmethod。nmethod可能是一個完整的或內(nèi)聯(lián)的Java方法。
即時(JIT)編譯器是代碼緩存區(qū)的最大消費者。這就是為什么一些開發(fā)人員將此內(nèi)存稱為JIT代碼緩存。
Thread Stack Space-線程堆棧空間
Java程序中,每個線程都有自己的Stack Space(堆棧)。這個Stack Space不是來自Heap的分配。所以Stack Space的大小不會受到-Xmx和-Xms的影響,這2個JVM參數(shù)僅僅是影響Heap的大小。
Stack Space用來做方法的遞歸調(diào)用時壓入Stack Frame(棧幀)。所以當(dāng)遞歸調(diào)用太深的時候,就有可能耗盡Stack Space,爆出StackOverflow的錯誤。
-Xss128k:設(shè)置每個線程的堆棧大小。JDK5.0以后每個線程堆 棧大小為1M,以前每個線程堆棧大小為256K。根據(jù)應(yīng)用的線程所需內(nèi)存大小進(jìn)行調(diào)整。在相同物理內(nèi)存下,減小這個值能生成更多的線程。但是操作系統(tǒng)對一 個進(jìn)程內(nèi)的線程數(shù)還是有限制的,不能無限生成,經(jīng)驗值在3000~5000左右。
線程棧的大小是個雙刃劍,如果設(shè)置過小,可能會出現(xiàn)棧溢出,特別是在該線程內(nèi)有遞歸、大的循環(huán)時出現(xiàn)溢出的可能性更大,如果該值設(shè)置過大,就有影響到創(chuàng)建棧的數(shù)量,如果是多線程的應(yīng)用,就會出現(xiàn)內(nèi)存溢出的錯誤。
Shared libs-共享庫
Java JVM 中的共享庫空間(也稱為共享類數(shù)據(jù)空間)是用于存儲共享類元數(shù)據(jù)和其他數(shù)據(jù)結(jié)構(gòu)的內(nèi)存空間。該內(nèi)存空間在多個 Java 進(jìn)程之間共享。這允許在同一臺機器上運行的各種 Java 應(yīng)用程序共享類元數(shù)據(jù)和其他數(shù)據(jù)結(jié)構(gòu)的相同副本。
共享庫空間的目的是通過避免同一類元數(shù)據(jù)的重復(fù)副本來減少內(nèi)存使用并提高性能。當(dāng)多個 Java 進(jìn)程使用相同的類元數(shù)據(jù)時,它們可以共享該元數(shù)據(jù)的相同副本,從而減少內(nèi)存使用并縮短應(yīng)用程序的啟動時間。
為什么要微調(diào)JVM的內(nèi)存設(shè)置?
JVM 的默認(rèn)行為會給 Kubernetes 帶來很多麻煩。正如我們之前看到的,堆默認(rèn)設(shè)置為可用內(nèi)存的 1/4。由于 JVM 將考慮 pod 可用的最大內(nèi)存(有限制),因此堆的大小可能會比我們想要的大。此外,其他默認(rèn)值將應(yīng)用于其他空間,例如代碼緩存或元空間。 如果從 JVM 的角度來看最大可用內(nèi)存,它將大于提供給 pod 的最大可用內(nèi)存。這將導(dǎo)致應(yīng)用程序出現(xiàn)許多內(nèi)存不足的情況(在 Kubernetes 部分)。
避免Java應(yīng)用程序在Kubernetes上出現(xiàn)OOM
大多數(shù)時候,都是為了微調(diào) JVM。由于我們看到 JVM 涉及不同的內(nèi)存空間,因此我們必須為每個空間設(shè)置特定的大小。這將幫助我們更精確地計算 pod 的內(nèi)存限制。 以下是顯示每個內(nèi)存空間可用選項的架構(gòu):
基本公式是:
Heap + Metaspace + Code Cache
意思是 :
-XmX + -XX:MaxMetaspaceSize + -XX:ReservedCodeCacheSize
由于線程的數(shù)量取決于應(yīng)用程序的上下文,因此建議為此部分添加一些“緩沖”內(nèi)存。默認(rèn)情況下,線程堆棧最大設(shè)置為 1MB。
如果想處理來自 JVM 的堆轉(zhuǎn)儲,則需要添加堆的大小作為第二次可用的“額外”內(nèi)存。 最后,設(shè)置 pod 限制的公式為:
(-XmX * 2) + -XX:MaxMetaspaceSize + -XX:ReservedCodeCacheSize + SomeBuffer
緩沖區(qū)部分取決于上下文,128 MB 應(yīng)該可以開始。
Helm模板配置
既然有了公式,我們就可以使用一些 Helm 模板自動計算 pod 的請求和限制。為開發(fā)人員提供一個簡單的選項來設(shè)置不同的參數(shù),而無需擔(dān)心 Pods 設(shè)置,這也是一個好的方式。 以下是默認(rèn)值的示例:
jvm:
garbageCollector: -XX:+UseG1GC
# values in Mi
memory:
heap: 128
metaspace: 256
compressedClassSpaceSize: 64
nonMethodCodeHeapSize: 5
profiledCodeHeapSize: 48
nonProfiledCodeHeapSize: 48
buffer: 128
使用 Helper 來設(shè)置 JAVA_TOOL_OPTIONS :
{{/*
JVM customisation
*/}}
{{- define "chart.javaToolOptions" -}}
-Xms{{.Values.jvm.memory.heap}}m -Xmx{{.Values.jvm.memory.heap}}m -XX:MetaspaceSize={{.Values.jvm.memory.metaspace}}m -XX:MaxMetaspaceSize={{.Values.jvm.memory.metaspace}}m -XX:CompressedClassSpaceSize={{.Values.jvm.memory.compressedClassSpaceSize}}m -XX:+TieredCompilation -XX:+SegmentedCodeCache -XX:Nnotallow={{.Values.jvm.memory.nonMethodCodeHeapSize}}m -XX:ProfiledCodeHeapSize={{.Values.jvm.memory.profiledCodeHeapSize}}m -XX:Nnotallow={{.Values.jvm.memory.nonProfiledCodeHeapSize}}m -XX:ReservedCodeCacheSize={{ add .Values.jvm.memory.nonMethodCodeHeapSize .Values.jvm.memory.profiledCodeHeapSize .Values.jvm.memory.nonProfiledCodeHeapSize}}m{{- end -}}
在deployment.yaml文件中使用:
- name: {{ include "chart.name" . }}
image: "{{ .Values.container.image.repository }}:{{ .Values.container.image.tag }}"
env:
- name: JAVA_TOOL_OPTIONS
value: {{ include "chart.javaToolOptions" . }}
根據(jù)提供的參數(shù)自動配置內(nèi)存請求和限制:
resources:
limits:
memory: {{ add .Values.jvm.memory.heap .Values.jvm.memory.heap .Values.jvm.memory.metaspace .Values.jvm.memory.nonMethodCodeHeapSize .Values.jvm.memory.profiledCodeHeapSize .Values.jvm.memory.nonProfiledCodeHeapSize .Values.jvm.memory.buffer | printf "%dMi"}}
cpu: {{ .Values.container.resources.limits.cpu }}
requests:
memory: {{ add .Values.jvm.memory.heap .Values.jvm.memory.metaspace .Values.jvm.memory.nonMethodCodeHeapSize .Values.jvm.memory.profiledCodeHeapSize .Values.jvm.memory.nonProfiledCodeHeapSize | printf "%dMi"}}
cpu: {{ .Values.container.resources.requests.cpu }}
結(jié)論
通過這一設(shè)置,我們將 Kubernetes 一側(cè)的 "內(nèi)存不足"(Out Of Memory)錯誤數(shù)量降至零。現(xiàn)在,JVM 會在自己這邊發(fā)生 OOM,并生成堆轉(zhuǎn)儲,幫助開發(fā)人員分析內(nèi)存。我們會發(fā)現(xiàn)是否有一些優(yōu)化需要推進(jìn),或者我們是否需要增加堆大小(或其他內(nèi)存空間)。
通過微調(diào) JVM 內(nèi)存配置,我們打破了惡性循環(huán),即每次 OOM 都意味著為 pod 增加內(nèi)存,以避免未來出現(xiàn)問題。我們能更好地了解每個內(nèi)存空間,以及如何和何時增加它們。
每次調(diào)整都需要測試,因此我們建議使用一些工具,例如 Micrometer,來獲得有關(guān) JVM 使用情況的一些指標(biāo)。 而且,最重要的是,我們減少了應(yīng)用程序的內(nèi)存需求,并通過減少內(nèi)存浪費事實上降低了基礎(chǔ)設(shè)施的成本!