把大象裝入貨櫃裡——Java容器記憶體拆解

MarkZhu 發表於 2021-10-08
Java

把大象裝入貨櫃裡——Java容器記憶體拆解
[圖片源:https://bell-sw.com/announcem...]

介紹

相信很多人都知道,雲環境中,所有服務都必須作資源限制。記憶體作為一個重要資源當然不會例外。限制說得容易,但如何在限制的同時,保證服務的效能指標(SLA)就是個技術和藝術活。

為應用記憶體設定上限,從來不是個容易的事。因為設定上限的理據是:

  • 應用程式對記憶體的使用和回收邏輯,而這個邏輯一般異常地複雜
  • 現代作業系統複雜的虛擬記憶體管理、實體記憶體分配、回收機制

如果是 Java ,還要加上:

  • JVM 中各型別元件的記憶體管理機制

以上 3 個方面還可以進一步細分。每一個細分都有它的記憶體機制。而只要我們漏算了其中一個,就有可能讓應用總記憶體使用超限。

而讓人揪心的是,當應用總記憶體使用超限時,作業系統會無情地殺死應用程式(OOM, Out Of Memory)。而很多人對這一無所覺,只知道容器重啟了。而這可能是連鎖反應的開端:

  • 如果容器 OOM 的原因只是個偶然,那還好說。如果是個 BUG 引起的,那麼這種 OOM 可能會在服務的所有容器中逐個爆發,最後服務癱瘓
  • 原來服務容器群的資源就緊張,一個容器 OOM 關閉了,負載均衡把流量分到其它容器,於是其它容器也出現同樣的 OOM。最後服務癱瘓

JVM 是個 Nice 的經理,在發現記憶體緊張時,就不厭其煩地停止應用執行緒和執行 GC,而這種記憶體緊張的訊號,在設計界稱為“背壓(Backpressure)”。
但作業系統相反,是個雷厲風行的司令,一發現有程式超限,直接一槍 OOM Killed。

或者你深入研究過 cgroup memory,它其實也有一個 Backpressure 的通知機制,不過現在的容器和 JVM 均忽略之。

終上所述,容器程式 OOM Kllled 是件應該避免,但需要深入研究才能避免的事情。

網路上,我們可以找到很多現實案例和教訓:
把大象裝入貨櫃裡——Java容器記憶體拆解

Java 記憶體管理很複雜。我們對它瞭解越多,應用出現 OOM Killed 的可能性就越低。下面我拿一個遇到的測試案例進行分析。

分析報告分為兩個部分:

  1. 研究應用實測出的指標、記憶體消耗,記憶體限制配置
  2. 潛在的問題和改進建議

測試環境

主機:裸機(BareMetal)
CPU: 40 cores, 共 80 個超執行緒
Linux:
  Kernel: 5.3.18
  glibc: libc-2.26.so
Java: 1.8.0_261-b12
Web/Servlet 容器: Jetty

配置容量

POD 容量配置

    resources:
      limits:
        cpu: "8"
        memory: 4Gi
        # 4Gi = 4 * 1024Mb = 4*1024*1024k = 4194304k = 4294967296 bytes = 4096Mb
      requests:
        cpu: "2"
        memory: 4Gi

JVM 容量配置

開始說 JVM 容量配置前,我假設你已經對 JVM 記憶體使用情況有個基本印象:

把大象裝入貨櫃裡——Java容器記憶體拆解
圖片源:https://www.twblogs.net/a/5d8...

下面是我在測試環境收集到的配置:

配置實際生效配置(Mbyte)
Young Heap + Old Heap-Xmx3G -XX:+AlwaysPreTouch3072
MaxMetaspaceSize[預設]Unlimited
CompressedClassSpaceSize[預設]1024
MaxDirectMemorySize[預設]3072
ReservedCodeCacheSize[預設]240
ThreadStackSize*maxThreadCount[預設] * 276(實測執行緒數)276
彙總 7684 + (沒限制 MaxMetaspaceSize)
神祕的 MaxDirectMemorySize 預設值

MaxDirectMemorySize 預設值,https://docs.oracle.com/javas... 如事說:

Sets the maximum total size (in bytes) of the New I/O (the java.nio package) direct-buffer allocations. Append the letter k or K to indicate kilobytes, m or M to indicate megabytes, g or G to indicate gigabytes. By 預設, the size is set to 0, meaning that the JVM chooses the size for NIO direct-buffer allocations automatically.

意思就是說了等於沒說 🤨。

在我的測試環境中, 我使用 Arthas attached 到 JVM 然後檢視內部的靜態變數:

[[email protected]]$ dashboard
ognl -c 30367620 '@[email protected]()'
@Long[3,221,225,472]

ognl '@[email protected]'
@Long[3,221,225,472]

3221225472/1024/1024 = 3072.0 Mb

如果你想深入,請參考資料:

maxThreadCount 最大執行緒數來源

既然上面用了 Arthas , 下面學是繼續 Arthas 吧:

[[email protected]]$ dashboard
   Threads Total: 276

應用使用的是 Jetty, 執行緒池配置 jetty-threadpool.xml

<Configure>
  <New id="threadPool" class="org.eclipse.jetty.util.thread.QueuedThreadPool">
    <Set name="maxThreads" type="int"><Property name="jetty.threadPool.maxThreads" deprecated="threads.max" default="200"/></Set>
...
  </New>
</Configure>

因為除了 Jetty,還有其它各種執行緒。

使用量

Java 的視角看使用量

容量配置生效配置(Mbyte)實際使用(Mbyte)
Young Heap + Old Heap-Xmx3G -XX:+AlwaysPreTouch30723072
MaxMetaspaceSize[預設]Unlimited128
CompressedClassSpaceSize[預設]102415
MaxDirectMemorySize[預設]3072270
ReservedCodeCacheSize[預設]24082
ThreadStackSize*maxThreadCount[預設]*276執行緒276276
Sum 7684 + (沒限制 MaxMetaspaceSize)3843

如何採集實際使用量

  • ReservedCodeCache

在應用經過熱身、壓力測試之後,用 Arthas attached:

[[email protected]]$ dashboard
code_cache : 82Mb
  • DirectMemory
[[email protected]]$ 
ognl '@[email protected]()'
@Long[1,524,039]
ognl -c 30367620 '@[email protected]()'
@Long[268,435,456]
  • Metaspace
  • CompressedClassSpaceSize
$ jcmd $PID GC.heap_info

 garbage-first heap   total 3145728K, used 1079227K [0x0000000700000000, 0x0000000700106000, 0x00000007c0000000)
  region size 1024K, 698 young (714752K), 16 survivors (16384K)
 Metaspace       used 127,323K, capacity 132,290K, committed 132,864K, reserved 1,167,360K
  class space    used 14,890K, capacity 15,785K, committed 15,872K, reserved 1,048,576K

原生應用的視角看使用量

原生應用的視角看使用量,包括下面這個方面:

  • *lib.so 動態庫佔用: 16Mb
  • *.jar 檔案對映佔用: 8Mb
  • GC 演算法消耗: 未調查
  • glibc malloc 空間回收不及時消耗: 158Mb

總的原生應用消耗: 16+8+158 = 182Mb

小結一下:
Java 角度看使用量: 3843Mb
總應用使用量 = 3843 + 158 ~= 4001Mb

4001Mb,這裡我們沒有算 *lib.so 動態庫佔用*.jar 檔案對映佔用。為什麼?將在下面內容中作出解釋。
4001Mb 這個數字有點可怕,離容器配置的上限 4096Mb 不遠了。但這個數字有一定水分。為什麼?將在下面內容中作出解釋。

以下我嘗試分析每個子項的資料來源

*lib.so 動態庫佔用

執行命令:

pmap -X $PID

部分輸出:

         Address Perm   Offset Device      Inode     Size     Rss     Pss Referenced Anonymous  Mapping
...
    7f281b1b1000 r-xp 00000000  08:03 1243611251       48      48       3         48         0  /lib64/libcrypt-2.26.so
    7f281b1bd000 ---p 0000c000  08:03 1243611251     2044       0       0          0         0  /lib64/libcrypt-2.26.so
    7f281b3bc000 r--p 0000b000  08:03 1243611251        4       4       4          4         4  /lib64/libcrypt-2.26.so
    7f281b3bd000 rw-p 0000c000  08:03 1243611251        4       4       4          4         4  /lib64/libcrypt-2.26.so
...
    7f28775a5000 r-xp 00000000  08:03 1243611255       92      92       5         92         0  /lib64/libgcc_s.so.1
    7f28775bc000 ---p 00017000  08:03 1243611255     2048       0       0          0         0  /lib64/libgcc_s.so.1
    7f28777bc000 r--p 00017000  08:03 1243611255        4       4       4          4         4  /lib64/libgcc_s.so.1
    7f28777bd000 rw-p 00018000  08:03 1243611255        4       4       4          4         4  /lib64/libgcc_s.so.1
    7f28777be000 r-xp 00000000  08:03 1800445487      224      64       4         64         0  /opt/jdk1.8.0_261/jre/lib/amd64/libsunec.so
    7f28777f6000 ---p 00038000  08:03 1800445487     2044       0       0          0         0  /opt/jdk1.8.0_261/jre/lib/amd64/libsunec.so
    7f28779f5000 r--p 00037000  08:03 1800445487       20      20      20         20        20  /opt/jdk1.8.0_261/jre/lib/amd64/libsunec.so
    7f28779fa000 rw-p 0003c000  08:03 1800445487        8       8       8          8         8  /opt/jdk1.8.0_261/jre/lib/amd64/libsunec.so
...
    7f28f43a7000 r-xp 00000000  08:03 1243611284       76      76       3         76         0  /lib64/libresolv-2.26.so
    7f28f43ba000 ---p 00013000  08:03 1243611284     2048       0       0          0         0  /lib64/libresolv-2.26.so
    7f28f45ba000 r--p 00013000  08:03 1243611284        4       4       4          4         4  /lib64/libresolv-2.26.so
    7f28f45bb000 rw-p 00014000  08:03 1243611284        4       4       4          4         4  /lib64/libresolv-2.26.so
    7f28f45bc000 rw-p 00000000  00:00          0        8       0       0          0         0  
    7f28f45be000 r-xp 00000000  08:03 1243611272       20      20       1         20         0  /lib64/libnss_dns-2.26.so
    7f28f45c3000 ---p 00005000  08:03 1243611272     2044       0       0          0         0  /lib64/libnss_dns-2.26.so
    7f28f47c2000 r--p 00004000  08:03 1243611272        4       4       4          4         4  /lib64/libnss_dns-2.26.so
    7f28f47c3000 rw-p 00005000  08:03 1243611272        4       4       4          4         4  /lib64/libnss_dns-2.26.so
    7f28f47c4000 r-xp 00000000  08:03 1243611274       48      48       2         48         0  /lib64/libnss_files-2.26.so
    7f28f47d0000 ---p 0000c000  08:03 1243611274     2044       0       0          0         0  /lib64/libnss_files-2.26.so
    7f28f49cf000 r--p 0000b000  08:03 1243611274        4       4       4          4         4  /lib64/libnss_files-2.26.so
    7f28f49d0000 rw-p 0000c000  08:03 1243611274        4       4       4          4         4  /lib64/libnss_files-2.26.so
    7f28f49d1000 rw-p 00000000  00:00          0     2072    2048    2048       2048      2048  
    7f28f4bd7000 r-xp 00000000  08:03 1800445476       88      88       6         88         0  /opt/jdk1.8.0_261/jre/lib/amd64/libnet.so
    7f28f4bed000 ---p 00016000  08:03 1800445476     2044       0       0          0         0  /opt/jdk1.8.0_261/jre/lib/amd64/libnet.so
    7f28f4dec000 r--p 00015000  08:03 1800445476        4       4       4          4         4  /opt/jdk1.8.0_261/jre/lib/amd64/libnet.so
    7f28f4ded000 rw-p 00016000  08:03 1800445476        4       4       4          4         4  /opt/jdk1.8.0_261/jre/lib/amd64/libnet.so
    7f28f4dee000 r-xp 00000000  08:03 1800445477       68      64       4         64         0  /opt/jdk1.8.0_261/jre/lib/amd64/libnio.so
    7f28f4dff000 ---p 00011000  08:03 1800445477     2044       0       0          0         0  /opt/jdk1.8.0_261/jre/lib/amd64/libnio.so
    7f28f4ffe000 r--p 00010000  08:03 1800445477        4       4       4          4         4  /opt/jdk1.8.0_261/jre/lib/amd64/libnio.so
    7f28f4fff000 rw-p 00011000  08:03 1800445477        4       4       4          4         4  /opt/jdk1.8.0_261/jre/lib/amd64/libnio.so
💡 如果你不太瞭解 Linux 的 memory map 和 pmap 的輸出,建議閱讀: https://www.labcorner.de/chea...
如果你懶惰如我,我還是上個圖吧:
把大象裝入貨櫃裡——Java容器記憶體拆解

大家知道,現代作業系統都有程式間共享實體記憶體的機制,以節省實體記憶體。如果你瞭解COW(Copy on Write)就更好了。一臺物理機上,執行著多個容器,而容器的映象其實是分層的。對於同一個機構生成的不同服務的映象,很多時候是會基於同一個基礎層,而這個基礎層包括是 Java 的相關庫。而所謂的層不過是主機上的目錄。即不同容器可能會共享讀(Mapping)同一檔案。

回到我們的主題,記憶體限制。容器通過 cgroup 限制記憶體。而 cgroup 會記賬容器內程式的每一次記憶體分配。而檔案對映共享記憶體的計算方法顯然要特別處理,因為跨了程式和容器。現在能查到的資料是說,只有第一個讀/寫這塊 mapping 記憶體的 cgroup 才記賬(https://www.kernel.org/doc/Do... 中 [2.3 Shared Page Accounting])。所以這個賬比較難預計的,一般我們只做再壞情況的保留。

*.jar mapping 佔用

pmap -X $PID

記賬原理和上面的 .so 類似。不過 Java 9 後,就不再做 .jar mapping 了。就算是 Java 8 ,也只是 mapping 檔案中的目錄結構部分。

在我的測試中,只使用了 8Mb 記憶體.

glibc malloc 消耗

Java 在兩種情況下使用 glibc malloc:

  1. NIO Direct Byte Buffer / Netty Direct Byte Buffer
  2. JVM 內部基礎程式

業界對 glibc malloc 的浪費頗有微詞. 主要集中在不及時的記憶體歸還(給作業系統)。這種浪費和主機的 CPU 數成比例,可參考:

不幸的是,我的測試環境是祼機,所有 CPU 都給容器看到了。而主機是 80 個 CPU 的。那麼問題來了,如何測量浪費了多少?
glibc 提供了一個 malloc_stats(3) 函式,它會輸出堆資訊(包括使用和保留)到標準輸出流。那麼問題又來了。如果呼叫這個函式?修改程式碼,寫JNI嗎?當然可以。不過,作為一個 Geek,當然要使用 gdb

cat <<"EOF" > ~/.gdbinit
handle SIGSEGV nostop noprint pass
handle SIGBUS nostop noprint pass
handle SIGFPE nostop noprint pass
handle SIGPIPE nostop noprint pass
handle SIGILL nostop noprint pass
EOF

export PID=`pgrep java`
gdb --batch --pid $PID --ex 'call malloc_stats()'

輸出:

Arena 0:
system bytes     =     135168
in use bytes     =      89712
Arena 1:
system bytes     =     135168
in use bytes     =       2224
Arena 2:
system bytes     =     319488
in use bytes     =      24960
Arena 3:
system bytes     =     249856
in use bytes     =       2992
...
Arena 270:
system bytes     =    1462272
in use bytes     =     583280
Arena 271:
system bytes     =   67661824
in use bytes     =   61308192


Total (incl. mmap):
system bytes     =  638345216
in use bytes     =  472750720
max mmap regions =         45
max mmap bytes   =  343977984

所以結果是: 638345216 - 472750720 = 165594496 ~= 158Mb
即浪費了 158Mb。因為我測試場景負載不大,在負載大,併發大的場景下,80個CPU 的浪費遠不止這樣。

有一點需要指出的,作業系統實體記憶體分配是 Lazy 分配的,即只在實際讀寫記憶體時,才分配,所以,上面的 158Mb 從作業系統的 RSS 來看,可能會變小。

GC 記憶體消耗

未調查

tmpfs 記憶體消耗

未調查

作業系統 RSS

RSS(pmap -X $PID) = 3920MB。即作業系統認為使用了 3920MB 的實體記憶體。

CGroup 限制

cgroup limit 4Gi = 4*1024Mb = 4096Mb
pagecache 可用空間 : 4096 - 3920 = 176Mb

下面看看 cgroup 的 memory.stat 檔案

$ cat cgroup `memory.stat` file
    rss 3920Mb
    cache 272Mb
    active_anon 3740Mb
    inactive_file 203Mb
    active_file 72Mb  # bytes of file-backed memory on active LRU list

細心如你會發現:

3920 + 272 = 4192 > 4096Mb

不對啊,為何還不 OOM killed?

說來話長, pagecache 是塊有彈性的記憶體空間,當應用需要 anonymous 記憶體時,核心可以自動回收 pagecache.

💡 感興趣可參考:
https://engineering.linkedin....
https://github.com/kubernetes...
https://www.kernel.org/doc/ht...

潛在問題和推薦解決方法

Native Buffer 限制

預設 MaxDirectMemorySize ~= -Xmx - survivor size ~= 3G .

這在高併發時,記憶體得不到及時回收時,會使用大量的 Direct Byte Buffer。所以建議顯式設定限制:

java ... -XX:MaxDirectMemorySize=350Mb

💡 感興趣可參考:

  • Cassandra 客戶端和 Redisson 均基於 Netty,固均使用了 Native Buffer. 注意的是 NettyUnsafe.class 基礎上,還有內部的記憶體池。

glibc malloc arena 的浪費

在我的測試環境中,主機有 80 個CPU。glibc 為了減少多執行緒分配記憶體時的鎖競爭,在高併發時最多為每個 CPU 保留 8 個記憶體塊(Arena),而 Arena 的空間歸還給作業系統的時機是不可預期的,和堆中記憶體碎片等情況有關。
在我的測試環境中觀察的結果是:共建立了 271 個Arena。使用了 608Mb 的 RSS。而實際程式用到的記憶體只有 450Mb。浪費了 157 Mb。浪費的情況有隨機性,和記憶體碎片等情況有關。對於容器,我們不可能分配所有主機的 CPU。可以設定一個顯式上限是合理的,且這個上限和容器的 memory limit、CPU limit 應該聯動。

MALLOC_ARENA_MAX 這個環境變數就是用於配置這個上限的。

  • 和記憶體使用的聯絡:
    我們實測中,共使用了 700Mb glibc 堆記憶體. 而每個 Arena 大小為 64Mb. 所以:

    700/64=10 Arena
  • 和容器 cpu limit 的聯絡:

  • cpu * (每個cpu 8 arena) = 64 Arena.

我們保守地使用大的保留空間:

export MALLOC_ARENA_MAX=64
💡 感興趣可參考:
https://www.gnu.org/software/...

Jetty 執行緒池

經調查,每 API 的呼叫用時大約 100 ms。而現有配置指定了最大 200 個執行緒。所以:

200 thread / 0.1s = 2000 TPS

在我們的測試中,單容器的 TPS 不出 1000。所以 100 個執行緒足以。減少執行緒數的好處是,可以同時可以減少過度的執行緒上下文切換、cgroup CPU 限流(cpu throttling)、執行緒堆疊記憶體、Native Buffer 記憶體。讓請求堆在 Request Queue,而不是核心的 Runnale Queue。

<!-- jetty-threadpool.xml -->
<Configure>
  <New id="threadPool" class="org.eclipse.jetty.util.thread.QueuedThreadPool">
...
    <Set name="maxThreads" type="int"><Property name="jetty.threadPool.maxThreads" deprecated="threads.max" default="100"/></Set>
...
  </New>
</Configure>

Java code cache 慢漲

在我們測試中,在經過系統預熱後,Java code cache 仍然會慢漲。Java 8 的 code cache 最大值是 240Mb。 如果 code cache 消耗了大量的記憶體,可能會觸發 OOM killed。 所以還是要作顯式限制的。 從測試環境的觀察,100Mb 的空間已經足夠。

java ... -XX:ReservedCodeCacheSize=100M -XX:UseCodeCacheFlushing=true
💡 感興趣可參考:
https://docs.oracle.com/javas...

容器的記憶體限制

從上面的調查可知, 3G java heap + JVM overhead + DirectByteBuffer 已經很接近 4Gi 的容器記憶體上限了。在高併發情況下,OOM killed 風險還是很高的。而且這個問題在測試環境不一定能出現,有它的隨機性。

cgroup 對容器接近 OOM 的次數是有記錄(memory.failcnt)的,在測試時發現這個數字在慢張。在記憶體緊張的時候,核心通過丟棄檔案快取(pagecache)來優先滿足應用對記憶體的需求。而丟棄檔案快取意味什麼?更慢的讀,更頻繁和慢的寫硬碟。如果應用有讀寫IO壓力,如果讀 *.jar,寫日誌,那麼 IO 慢問題會隨之而來。

watch cat ./memory.failcnt 
19369
💡 感興趣可參考:
https://engineering.linkedin....
https://www.kernel.org/doc/Do...
https://srvaroa.github.io/jvm...

對於我的應用,我建議是放寬記憶體限制:

    resources:
      limits:
        memory: 4.5Gi
      requests:
        memory: 4.5Gi

展望

不全面地說,從服務運維者的角度看, 服務的資源分配基於這些係數:

  • 容器的 SLA

    • 目標容器的呑吐量

如我把上面係數作為一個工具程式的 輸入, 那麼 輸出 應該是:

  • 應該部署多少個容器
  • 每個容器的資源配置應該如何

    • CPU

      • 容器 CPU limit
      • 應用執行緒池 limit
    • Memory

      • 容器 memory limit
      • 應用執行緒池d limit:

        • java: 堆內/堆外
💡 有一個開源工具可參考:
https://github.com/cloudfound...

免責宣告

Every coin has two sides, 應用調優更是,每種調優方法均有其所需要的環境前提,不然就不叫調優,直接上開源專案的預設配置 Pull Request 了。大師常說,不要簡單 copy 調參就用。要考慮自己的實際情況,然後作充分測試方可使用。

體會

2016 年開始,各大公司開始追趕時尚,把應該的應用放入容器。而由於很多舊專案和元件在設計時,沒考慮在一個受限容器中執行,說白了,就是非 contaier aware。時隔數年,情況有所好轉,但還是有不少坑。而作為一個合格的架構師,除了 PPT 和遠方外,我們還得有個玻璃心。

以上是對一個 Java 容器記憶體的分析,如果你對 Java 容器 CPU和執行緒引數有興趣,請移步:Java 容器化的歷史坑(史坑) - 資源限制篇

用一個漫畫了結本文:
把大象裝入貨櫃裡——Java容器記憶體拆解