解讀 Java 云原生實(shí)踐中的內(nèi)存問(wèn)題
?Java 憑借著自身活躍的開(kāi)源社區(qū)和完善的生態(tài)優(yōu)勢(shì),在過(guò)去的二十幾年一直是最受歡迎的編程語(yǔ)言之一。步入云原生時(shí)代,蓬勃發(fā)展的云原生技術(shù)釋放云計(jì)算紅利,推動(dòng)業(yè)務(wù)進(jìn)行云原生化改造,加速企業(yè)數(shù)字化轉(zhuǎn)型。
然而 Java 的云原生轉(zhuǎn)型之路面臨著巨大的挑戰(zhàn),Java 的運(yùn)行機(jī)制和云原生特性存在著諸多矛盾。企業(yè)借助云原生技術(shù)進(jìn)行深層次成本優(yōu)化,資源成本管理被上升到前所未有的高度。公有云上資源按量收費(fèi),用戶(hù)對(duì)資源用量十分敏感。在內(nèi)存使用方面,基于 Java 虛擬機(jī)的執(zhí)行機(jī)制使得任何 Java 程序都會(huì)有固定的基礎(chǔ)內(nèi)存開(kāi)銷(xiāo),相比 C++/Golang 等原生語(yǔ)言,Java 應(yīng)用占用的內(nèi)存巨大,被稱(chēng)為“內(nèi)存吞噬者”,因此 Java 應(yīng)用上云更加昂貴。并且應(yīng)用集成到云上之后系統(tǒng)復(fù)雜度增加,普通用戶(hù)對(duì)云上 Java 應(yīng)用內(nèi)存沒(méi)有清晰的認(rèn)識(shí),不知道如何為應(yīng)用合理配置內(nèi)存,出現(xiàn) OOM 問(wèn)題時(shí)也很難排障,遇到了許多問(wèn)題。
為什么堆內(nèi)存未超過(guò) Xmx 卻發(fā)生了 OOM?怎么理解操作系統(tǒng)和JVM的內(nèi)存關(guān)系?為什么程序占用的內(nèi)存比 Xmx 大不少,內(nèi)存都用在哪兒了?為什么線上容器內(nèi)的程序內(nèi)存需求更大?本文將 EDAS 用戶(hù)在 Java 應(yīng)用云原生化演進(jìn)實(shí)踐中遇到的這些問(wèn)題進(jìn)行了抽絲剝繭的分析,并給出云原生 Java 應(yīng)用內(nèi)存的配置建議。
一、背景知識(shí)
K8s 應(yīng)用的資源配置?
云原生架構(gòu)以 K8s 為基石,應(yīng)用在 K8s 上部署,以容器組的形態(tài)運(yùn)行。K8s 的資源模型有兩個(gè)定義,資源請(qǐng)求(request)和資源限制(limit),K8s 保障容器擁有 request數(shù)量的資源,但不允許使用超過(guò)limit數(shù)量的資源。以如下的內(nèi)存配置為例,容器至少能獲得 1024Mi 的內(nèi)存資源,但不允許超過(guò) 4096Mi,一旦內(nèi)存使用超限,該容器將發(fā)生OOM,而后被 K8s 控制器重啟。
容器 OOM?
對(duì)于容器的 OOM 機(jī)制,首先需要來(lái)復(fù)習(xí)一下容器的概念。當(dāng)我們談到容器的時(shí)候,會(huì)說(shuō)這是一種沙盒技術(shù),容器作為一個(gè)沙盒,內(nèi)部是相對(duì)獨(dú)立的,并且是有邊界有大小的。容器內(nèi)獨(dú)立的運(yùn)行環(huán)境通過(guò) Linux的Namespace 機(jī)制實(shí)現(xiàn),對(duì)容器內(nèi) PID、Mount、UTS、IPD、Network 等 Namespace 進(jìn)行了障眼法處理,使得容器內(nèi)看不到宿主機(jī) Namespace 也看不到其他容器的 Namespace;而所謂容器的邊界和大小,是指要對(duì)容器使用 CPU、內(nèi)存、IO 等資源進(jìn)行約束,不然單個(gè)容器占用資源過(guò)多可能導(dǎo)致其他容器運(yùn)行緩慢或者異常。Cgroup 是 Linux 內(nèi)核提供的一種可以限制單個(gè)進(jìn)程或者多個(gè)進(jìn)程所使用資源的機(jī)制,也是實(shí)現(xiàn)容器資源約束的核心技術(shù)。容器在操作系統(tǒng)看來(lái)只不過(guò)是一種特殊進(jìn)程,該進(jìn)程對(duì)資源的使用受 Cgroup 的約束。當(dāng)進(jìn)程使用的內(nèi)存量超過(guò) Cgroup 的限制量,就會(huì)被系統(tǒng) OOM Killer 無(wú)情地殺死。
所以,所謂的容器 OOM,實(shí)質(zhì)是運(yùn)行在Linux系統(tǒng)上的容器進(jìn)程發(fā)生了 OOM。Cgroup 并不是一種晦澀難懂的技術(shù),Linux 將其實(shí)現(xiàn)為了文件系統(tǒng),這很符合 Unix 一切皆文件的哲學(xué)。對(duì)于 Cgroup V1 版本,我們可以直接在容器內(nèi)的 /sys/fs/cgroup/ 目錄下查看當(dāng)前容器的 Cgroup 配置。
對(duì)于容器內(nèi)存來(lái)說(shuō),memory.limit_in_bytes 和 memory.usage_in_bytes 是內(nèi)存控制組中最重要的兩個(gè)參數(shù),前者標(biāo)識(shí)了當(dāng)前容器進(jìn)程組可使用內(nèi)存的最大值,后者是當(dāng)前容器進(jìn)程組實(shí)際使用的內(nèi)存總和。一般來(lái)說(shuō),使用值和最大值越接近,OOM 的風(fēng)險(xiǎn)越高。
JVM OOM?
說(shuō)到 OOM,Java 開(kāi)發(fā)者更熟悉的是 JVM OOM,當(dāng) JVM 因?yàn)闆](méi)有足夠的內(nèi)存來(lái)為對(duì)象分配空間并且垃圾回收器也已經(jīng)沒(méi)有空間可回收時(shí),將會(huì)拋出 java.lang.OutOfMemoryError。按照 JVM 規(guī)范,除了程序計(jì)數(shù)器不會(huì)拋出 OOM 外,其他各個(gè)內(nèi)存區(qū)域都可能會(huì)拋出 OOM。最常見(jiàn)的 JVM OOM 情況有幾種:
- java.lang.OutOfMemoryError:Java heap space 堆內(nèi)存溢出。當(dāng)堆內(nèi)存 (Heap Space) 沒(méi)有足夠空間存放新創(chuàng)建的對(duì)象時(shí),就會(huì)拋出該錯(cuò)誤。一般由于內(nèi)存泄露或者堆的大小設(shè)置不當(dāng)引起。對(duì)于內(nèi)存泄露,需要通過(guò)內(nèi)存監(jiān)控軟件查找程序中的泄露代碼,而堆大小可以通過(guò)-Xms,-Xmx等參數(shù)修改。
- java.lang.OutOfMemoryError:PermGen space / Metaspace 永久代/元空間溢出。永久代存儲(chǔ)對(duì)象包括class信息和常量,JDK 1.8 使用 Metaspace 替換了永久代(Permanent Generation)。通常因?yàn)榧虞d的 class 數(shù)目太多或體積太大,導(dǎo)致拋出該錯(cuò)誤。可以通過(guò)修改 -XX:MaxPermSize 或者 -XX:MaxMetaspaceSize 啟動(dòng)參數(shù), 調(diào)大永久代/元空間大小。
- java.lang.OutOfMemoryError:Unable to create new native thread 無(wú)法創(chuàng)建新線程。每個(gè) Java 線程都需要占用一定的內(nèi)存空間, 當(dāng) JVM 向底層操作系統(tǒng)請(qǐng)求創(chuàng)建一個(gè)新的 native 線程時(shí), 如果沒(méi)有足夠的資源分配就會(huì)報(bào)此類(lèi)錯(cuò)誤。可能原因是 native 內(nèi)存不足、線程泄露導(dǎo)致線程數(shù)超過(guò)操作系統(tǒng)最大線程數(shù) ulimit 限制或是線程數(shù)超過(guò) kernel.pid_max。需要根據(jù)情況進(jìn)行資源升配、限制線程池大小、減少線程棧大小等操作。
二、為什么堆內(nèi)存未超過(guò) Xmx 卻發(fā)生了 OOM?
相信很多人都遇到過(guò)這一場(chǎng)景,在 K8s 部署的 Java 應(yīng)用經(jīng)常重啟,查看容器退出狀態(tài)為exit code 137 reason: OOM Killed 各方信息都指向明顯的 OOM,然而 JVM 監(jiān)控?cái)?shù)據(jù)顯示堆內(nèi)存用量并未超過(guò)最大堆內(nèi)存限制Xmx,并且配置了 OOM 自動(dòng) heapdump 參數(shù)之后,發(fā)生 OOM 時(shí)卻沒(méi)有產(chǎn)生 dump 文件。
根據(jù)上面的背景知識(shí)介紹,容器內(nèi)的 Java 應(yīng)用可能會(huì)發(fā)生兩種類(lèi)型的 OOM 異常,一種是 JVM OOM,一種是容器 OOM。JVM 的 OOM 是 JVM 內(nèi)存區(qū)域空間不足導(dǎo)致的錯(cuò)誤,JVM 主動(dòng)拋出錯(cuò)誤并退出進(jìn)程,通過(guò)觀測(cè)數(shù)據(jù)可以看到內(nèi)存用量超限,并且 JVM 會(huì)留下相應(yīng)的錯(cuò)誤記錄。而容器的 OOM 是系統(tǒng)行為,整個(gè)容器進(jìn)程組使用的內(nèi)存超過(guò) Cgroup 限制,被系統(tǒng) OOM Killer 殺死,在系統(tǒng)日志和 K8s 事件中會(huì)留下相關(guān)記錄。
總的來(lái)說(shuō),Java程序內(nèi)存使用同時(shí)受到來(lái)自 JVM 和 Cgroup 的限制,其中 Java 堆內(nèi)存受限于 Xmx 參數(shù),超限后發(fā)生 JVM OOM;整個(gè)進(jìn)程內(nèi)存受限于容器內(nèi)存limit值,超限后發(fā)生容器 OOM。需要結(jié)合觀測(cè)數(shù)據(jù)、JVM 錯(cuò)誤記錄、系統(tǒng)日志和 K8s 事件對(duì) OOM 進(jìn)行區(qū)分、排障,并按需進(jìn)行配置調(diào)整。
三、怎么理解操作系統(tǒng)和 JVM 的內(nèi)存關(guān)系?
上文說(shuō)到 Java 容器 OOM 實(shí)質(zhì)是 Java 進(jìn)程使用的內(nèi)存超過(guò) Cgroup 限制,被操作系統(tǒng)的 OOM Killer 殺死。那在操作系統(tǒng)的視角里,如何看待 Java 進(jìn)程的內(nèi)存?操作系統(tǒng)和 JVM 都有各自的內(nèi)存模型,二者是如何映射的?對(duì)于探究 Java 進(jìn)程的 OOM 問(wèn)題,理解 JVM 和操作系統(tǒng)之間的內(nèi)存關(guān)系非常重要。
以最常用的 OpenJDK 為例,JVM 本質(zhì)上是運(yùn)行在操作系統(tǒng)上的一個(gè) C++ 進(jìn)程,因此其內(nèi)存模型也有 Linux 進(jìn)程的一般特點(diǎn)。Linux 進(jìn)程的虛擬地址空間分為內(nèi)核空間和用戶(hù)空間,用戶(hù)空間又細(xì)分為很多個(gè)段,此處選取幾個(gè)和本文討論相關(guān)度高的幾個(gè)段,描述 JVM 內(nèi)存與進(jìn)程內(nèi)存的映射關(guān)系。
- 代碼段。一般指程序代碼在內(nèi)存中的映射,這里特別指出是 JVM 自身的代碼,而不是Java代碼。
- 數(shù)據(jù)段。在程序運(yùn)行初已經(jīng)對(duì)變量進(jìn)行初始化的數(shù)據(jù),此處是 JVM 自身的數(shù)據(jù)。
- 堆空間。運(yùn)行時(shí)堆是 Java 進(jìn)程和普通進(jìn)程區(qū)別最大的一個(gè)內(nèi)存段。Linux 進(jìn)程內(nèi)存模型里的堆是為進(jìn)程在運(yùn)行時(shí)動(dòng)態(tài)分配的對(duì)象提供內(nèi)存空間,而幾乎所有JVM內(nèi)存模型里的東西,都是 JVM 這個(gè)進(jìn)程在運(yùn)行時(shí)新建出來(lái)的對(duì)象。而 JVM 內(nèi)存模型中的 Java 堆,只不過(guò)是 JVM 在其進(jìn)程堆空間上建立的一段邏輯空間。
- 棧空間。存放進(jìn)程的運(yùn)行棧,此處并不是 JVM 內(nèi)存模型中的線程棧,而是操作系統(tǒng)運(yùn)行 JVM 本身需要留存的一些運(yùn)行數(shù)據(jù)。
如上所述,堆空間作為 Linux 進(jìn)程內(nèi)存布局和 JVM 內(nèi)存布局都有的概念,是最容易混淆也是差別最大的一個(gè)概念。Java 堆相較于 Linux 進(jìn)程的堆,范圍更小,是 JVM 在其進(jìn)程堆空間上建立的一段邏輯空間,而進(jìn)程堆空間還包含支撐 JVM 虛擬機(jī)運(yùn)行的內(nèi)存數(shù)據(jù),例如 Java 線程堆棧、代碼緩存、GC 和編譯器數(shù)據(jù)等。
四、為什么程序占用的內(nèi)存比 Xmx 大不少,內(nèi)存都用在哪了?
實(shí)質(zhì)上除了大家所熟悉的堆內(nèi)存(Heap),JVM 還有所謂的非堆內(nèi)存(Non-Heap),除去 JVM 管理的內(nèi)存,還有繞過(guò) JVM 直接開(kāi)辟的本地內(nèi)存。Java 進(jìn)程的內(nèi)存占用情況可以簡(jiǎn)略地總結(jié)為下圖:
JDK8 引入了 Native Memory Tracking (NMT)特性,可以追蹤 JVM 的內(nèi)部?jī)?nèi)存使用。默認(rèn)情況下,NMT 是關(guān)閉狀態(tài),使用 JVM 參數(shù)開(kāi)啟:-XX:NativeMemoryTracking=[off | summary | detail]
此處限制最大堆內(nèi)存為 300M,使用 G1 作為 GC 算法,開(kāi)啟 NMT 追蹤進(jìn)程的內(nèi)存使用情況。
注意:?jiǎn)⒂?NMT 會(huì)導(dǎo)致 5% -10% 的性能開(kāi)銷(xiāo)。
開(kāi)啟 NMT 后,可以使用 jcmd 命令打印 JVM 內(nèi)存的占用情況。此處僅查看內(nèi)存摘要信息,設(shè)置單位為 MB。
JVM 總內(nèi)存
NMT 報(bào)告顯示進(jìn)程當(dāng)前保留內(nèi)存為 1764MB,已提交內(nèi)存為 534MB,遠(yuǎn)遠(yuǎn)高于最大堆內(nèi)存 300M。保留指為進(jìn)程開(kāi)辟一段連續(xù)的虛擬地址內(nèi)存,可以理解為進(jìn)程可能使用的內(nèi)存量;提交指將虛擬地址與物理內(nèi)存進(jìn)行映射,可以理解為進(jìn)程當(dāng)前占用的內(nèi)存量。
需要特別說(shuō)明的是,NMT 所統(tǒng)計(jì)的內(nèi)存與操作系統(tǒng)統(tǒng)計(jì)的內(nèi)存有所差異,Linux 在分配內(nèi)存時(shí)遵循 lazy allocation 機(jī)制,只有在進(jìn)程真正訪問(wèn)內(nèi)存頁(yè)時(shí)才將其換入物理內(nèi)存中,所以使用 top 命令看到的進(jìn)程物理內(nèi)存占用量與 NMT 報(bào)告中看到的有差別。此處只用 NMT 說(shuō)明 JVM 視角下內(nèi)存的占用情況。
Java Heap
Java 堆內(nèi)存如設(shè)置的一樣,實(shí)際開(kāi)辟了 300M 的內(nèi)存空間。
Metaspace
加載的類(lèi)被存儲(chǔ)在 Metaspace,此處元空間加載了 11183 個(gè)類(lèi),保留了近 1G,提交了 61M。
加載的類(lèi)越多,使用的元空間就越多。元空間大小受限于-XX:MaxMetaspaceSize(默認(rèn)無(wú)限制)和 -XX:CompressedClassSpaceSize(默認(rèn) 1G)。
Thread
JVM 線程堆棧也需要占據(jù)一定空間。此處 61 個(gè)線程占用了 60M 空間,每個(gè)線程堆棧默認(rèn)約為 1M。堆棧大小由 -Xss 參數(shù)控制。
Code Cache
代碼緩存區(qū)主要用來(lái)保存 JIT 即時(shí)編譯器編譯后的代碼和 Native 方法,目前緩存了 36M 的代碼。代碼緩存區(qū)可以通過(guò) -XX:ReservedCodeCacheSize 參數(shù)進(jìn)行容量設(shè)置。
GC
GC 垃圾收集器也需要一些內(nèi)存空間支撐 GC 操作,GC 占用的空間與具體選用的 GC 算法有關(guān),此處的 GC 算法使用了 47M。在其他配置相同的情況下,換用 SerialGC:
可以看到 SerialGC 算法僅使用 1M 內(nèi)存。這是因?yàn)?SerialGC 是一種簡(jiǎn)單的串行算法,涉及數(shù)據(jù)結(jié)構(gòu)簡(jiǎn)單,計(jì)算數(shù)據(jù)量小,所以?xún)?nèi)存占用也小。但是簡(jiǎn)單的 GC 算法可能會(huì)帶來(lái)性能的下降,需要平衡性能和內(nèi)存表現(xiàn)進(jìn)行選擇。
Symbol
JVM 的 Symbol 包含符號(hào)表和字符串表,此處占用 15M。
非 JVM 內(nèi)存
NMT 只能統(tǒng)計(jì) JVM 內(nèi)部的內(nèi)存情況,還有一部分內(nèi)存不由JVM管理。除了 JVM 托管的內(nèi)存之外,程序也可以顯式地請(qǐng)求堆外內(nèi)存 ByteBuffer.allocateDirect,這部分內(nèi)存受限于 -XX:MaxDirectMemorySize 參數(shù)(默認(rèn)等于-Xmx)。System.loadLibrary 所加載的 JNI 模塊也可以不受 JVM 控制地申請(qǐng)堆外內(nèi)存。
綜上,其實(shí)并沒(méi)有一個(gè)能準(zhǔn)確估量 Java 進(jìn)程內(nèi)存用量的模型,只能夠盡可能多地考慮到各種因素。其中有一些內(nèi)存區(qū)域能通過(guò) JVM 參數(shù)進(jìn)行容量限制,例如代碼緩存、元空間等,但有些內(nèi)存區(qū)域不受 JVM 控制,而與具體應(yīng)用的代碼有關(guān)。
五、為什么線上容器比本地測(cè)試內(nèi)存需求更大?
沒(méi)有使用容器感知的 JVM 版本?
在一般的物理機(jī)或虛擬機(jī)上,當(dāng)未設(shè)置 -Xmx 參數(shù)時(shí),JVM 會(huì)從常見(jiàn)位置(例如,Linux 中的 /proc目錄下)查找其可以使用的最大內(nèi)存量,然后按照主機(jī)最大內(nèi)存的 1/4 作為默認(rèn)的 JVM 最大堆內(nèi)存量。而早期的 JVM 版本并未對(duì)容器進(jìn)行適配,當(dāng)運(yùn)行在容器中時(shí),仍然按照主機(jī)內(nèi)存的 1/4 設(shè)置 JVM最 大堆,而一般集群節(jié)點(diǎn)的主機(jī)內(nèi)存比本地開(kāi)發(fā)機(jī)大得多,容器內(nèi)的 Java 進(jìn)程堆空間開(kāi)得大,自然更耗內(nèi)存。同時(shí)在容器中又受到 Cgroup 資源限制,當(dāng)容器進(jìn)程組內(nèi)存使用量超過(guò) Cgroup 限制時(shí),便會(huì)被 OOM。為此,8u191 之后的 OpenJDK 引入了默認(rèn)開(kāi)啟的 UseContainerSupport 參數(shù),使得容器內(nèi)的 JVM 能感知容器內(nèi)存限制,按照 Cgroup 內(nèi)存限制量的 1/4 設(shè)置最大堆內(nèi)存量。
線上業(yè)務(wù)耗費(fèi)更多內(nèi)存
對(duì)外提供服務(wù)的業(yè)務(wù)往往會(huì)帶來(lái)更活躍的內(nèi)存分配動(dòng)作,比如創(chuàng)建新的對(duì)象、開(kāi)啟執(zhí)行線程,這些操作都需要開(kāi)辟內(nèi)存空間,所以線上業(yè)務(wù)往往耗費(fèi)更多內(nèi)存。耗費(fèi)的內(nèi)存會(huì)更多。所以為了保證服務(wù)質(zhì)量,需要依據(jù)自身業(yè)務(wù)流量,對(duì)應(yīng)用內(nèi)存配置進(jìn)行相應(yīng)擴(kuò)容。
六、云原生 Java 應(yīng)用內(nèi)存的配置建議
- 使用容器感知的 JDK 版本。對(duì)于使用 Cgroup V1 的集群,需要升級(jí)至 8u191+、Java 9、Java 10 以及更高版本;對(duì)于使用 Cgroup V2 的集群,需要升級(jí)至 8u372+ 或 Java 15 及更高版本。
- 使用 NativeMemoryTracking(NMT) 了解應(yīng)用的 JVM 內(nèi)存用量。NMT 能夠追蹤 JVM 的內(nèi)存使用情況,在測(cè)試階段可以使用 NMT 了解程序JVM使用內(nèi)存的大致分布情況,作為內(nèi)存容量配置的參考依據(jù)。JVM 參數(shù) -XX:NativeMemoryTracking 用于啟用 NMT,開(kāi)啟 NMT 后,可以使用 jcmd 命令打印 JVM 內(nèi)存的占用情況。
- 根據(jù) Java 程序內(nèi)存使用量設(shè)置容器內(nèi)存 limit。容器 Cgroup 內(nèi)存限制值來(lái)源于對(duì)容器設(shè)置的內(nèi)存 limit 值,當(dāng)容器進(jìn)程使用的內(nèi)存量超過(guò) limit,就會(huì)發(fā)生容器 OOM。為了程序在正常運(yùn)行或業(yè)務(wù)波動(dòng)時(shí)發(fā)生 OOM,應(yīng)該按照 Java 進(jìn)程使用的內(nèi)存量上浮 20%~30% 設(shè)置容器內(nèi)存 limit。如果初次運(yùn)行的程序,并不了解其實(shí)際內(nèi)存使用量,可以先設(shè)置一個(gè)較大的 limit 讓程序運(yùn)行一段時(shí)間,按照觀測(cè)到的進(jìn)程內(nèi)存量對(duì)容器內(nèi)存 limit 進(jìn)行調(diào)整。
- OOM 時(shí)自動(dòng) dump 內(nèi)存快照,并為 dump 文件配置持久化存儲(chǔ),比如使用 PVC 掛載到 hostPath、OSS 或 NAS,盡可能保留現(xiàn)場(chǎng)數(shù)據(jù),支撐后續(xù)的故障排查。?