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